Add User class with role and permission-based authorization
Foundation for authentication/authorization with: - Stable UUID id for database keys, email as human identifier - Account status (active/suspended/pending) - Role-based auth with role-to-permission mappings - Direct permissions in resource:action format - Methods: hasRole(), hasPermission(), can(), effectivePermissions() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.beads/issues.jsonl
Normal file
3
.beads/issues.jsonl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{"id":"diachron-2vh","title":"Add unit testing to golang programs","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:41.281891462-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:41.281891462-06:00"}
|
||||||
|
{"id":"diachron-64w","title":"Add unit testing to express backend","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:30.439206099-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:30.439206099-06:00"}
|
||||||
|
{"id":"diachron-fzd","title":"Add generic 'user' functionality","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:35:53.73213604-06:00","created_by":"mw","updated_at":"2026-01-03T12:35:53.73213604-06:00"}
|
||||||
188
express/user.ts
Normal file
188
express/user.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// user.ts
|
||||||
|
//
|
||||||
|
// User model for authentication and authorization.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - `id` is the stable internal identifier (UUID when database-backed)
|
||||||
|
// - `email` is the primary human-facing identifier
|
||||||
|
// - Roles provide coarse-grained authorization (admin, editor, etc.)
|
||||||
|
// - Permissions provide fine-grained authorization (posts:create, etc.)
|
||||||
|
// - Users can have both roles (which grant permissions) and direct permissions
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Branded type for user IDs to prevent accidental mixing with other strings
|
||||||
|
export type UserId = string & { readonly __brand: "UserId" };
|
||||||
|
|
||||||
|
// User account status
|
||||||
|
const userStatusParser = z.enum(["active", "suspended", "pending"]);
|
||||||
|
export type UserStatus = z.infer<typeof userStatusParser>;
|
||||||
|
|
||||||
|
// Role - simple string identifier
|
||||||
|
const roleParser = z.string().min(1);
|
||||||
|
export type Role = z.infer<typeof roleParser>;
|
||||||
|
|
||||||
|
// Permission format: "resource:action" e.g. "posts:create", "users:delete"
|
||||||
|
const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, {
|
||||||
|
message: "Permission must be in format 'resource:action'",
|
||||||
|
});
|
||||||
|
export type Permission = z.infer<typeof permissionParser>;
|
||||||
|
|
||||||
|
// Core user data schema - this is what gets stored/serialized
|
||||||
|
const userDataParser = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
email: z.email(),
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
status: userStatusParser,
|
||||||
|
roles: z.array(roleParser),
|
||||||
|
permissions: z.array(permissionParser),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
updatedAt: z.coerce.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserData = z.infer<typeof userDataParser>;
|
||||||
|
|
||||||
|
// Role-to-permission mappings
|
||||||
|
// In a real system this might be database-driven or configurable
|
||||||
|
type RolePermissionMap = Map<Role, Permission[]>;
|
||||||
|
|
||||||
|
const defaultRolePermissions: RolePermissionMap = new Map([
|
||||||
|
["admin", ["users:read", "users:create", "users:update", "users:delete"]],
|
||||||
|
["user", ["users:read"]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
private readonly data: UserData;
|
||||||
|
private rolePermissions: RolePermissionMap;
|
||||||
|
|
||||||
|
constructor(data: UserData, rolePermissions?: RolePermissionMap) {
|
||||||
|
this.data = userDataParser.parse(data);
|
||||||
|
this.rolePermissions = rolePermissions ?? defaultRolePermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory for creating new users with sensible defaults
|
||||||
|
static create(
|
||||||
|
email: string,
|
||||||
|
options?: {
|
||||||
|
id?: string;
|
||||||
|
displayName?: string;
|
||||||
|
status?: UserStatus;
|
||||||
|
roles?: Role[];
|
||||||
|
permissions?: Permission[];
|
||||||
|
},
|
||||||
|
): User {
|
||||||
|
const now = new Date();
|
||||||
|
return new User({
|
||||||
|
id: options?.id ?? crypto.randomUUID(),
|
||||||
|
email,
|
||||||
|
displayName: options?.displayName,
|
||||||
|
status: options?.status ?? "active",
|
||||||
|
roles: options?.roles ?? [],
|
||||||
|
permissions: options?.permissions ?? [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
get id(): UserId {
|
||||||
|
return this.data.id as UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get email(): string {
|
||||||
|
return this.data.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayName(): string | undefined {
|
||||||
|
return this.data.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
get status(): UserStatus {
|
||||||
|
return this.data.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.data.status === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
get roles(): readonly Role[] {
|
||||||
|
return this.data.roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRole(role: Role): boolean {
|
||||||
|
return this.data.roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyRole(roles: Role[]): boolean {
|
||||||
|
return roles.some((role) => this.hasRole(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAllRoles(roles: Role[]): boolean {
|
||||||
|
return roles.every((role) => this.hasRole(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
get permissions(): readonly Permission[] {
|
||||||
|
return this.data.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all permissions: direct + role-derived
|
||||||
|
effectivePermissions(): Set<Permission> {
|
||||||
|
const perms = new Set<Permission>(this.data.permissions);
|
||||||
|
|
||||||
|
for (const role of this.data.roles) {
|
||||||
|
const rolePerms = this.rolePermissions.get(role);
|
||||||
|
if (rolePerms) {
|
||||||
|
for (const p of rolePerms) {
|
||||||
|
perms.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has a specific permission (direct or via role)
|
||||||
|
hasPermission(permission: Permission): boolean {
|
||||||
|
// Check direct permissions first
|
||||||
|
if (this.data.permissions.includes(permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role-derived permissions
|
||||||
|
for (const role of this.data.roles) {
|
||||||
|
const rolePerms = this.rolePermissions.get(role);
|
||||||
|
if (rolePerms?.includes(permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience method: can user perform action on resource?
|
||||||
|
can(action: string, resource: string): boolean {
|
||||||
|
const permission = `${resource}:${action}` as Permission;
|
||||||
|
return this.hasPermission(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this.data.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this.data.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialization - returns plain object for storage/transmission
|
||||||
|
toJSON(): UserData {
|
||||||
|
return { ...this.data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For representing "no user" in contexts where user is optional
|
||||||
|
export const AnonymousUser = Symbol("AnonymousUser");
|
||||||
|
export type MaybeUser = User | typeof AnonymousUser;
|
||||||
Reference in New Issue
Block a user