Files
diachron/express/user.ts
Michael Wolf 788ea2ab19 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>
2026-01-03 12:59:47 -06:00

189 lines
5.4 KiB
TypeScript

// 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;