From 788ea2ab195eaf1c13c533105bcef3f06b69181b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 12:59:47 -0600 Subject: [PATCH] Add User class with role and permission-based authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .beads/issues.jsonl | 3 + express/user.ts | 188 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 .beads/issues.jsonl create mode 100644 express/user.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..49047b5 --- /dev/null +++ b/.beads/issues.jsonl @@ -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"} diff --git a/express/user.ts b/express/user.ts new file mode 100644 index 0000000..078a344 --- /dev/null +++ b/express/user.ts @@ -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; + +// Role - simple string identifier +const roleParser = z.string().min(1); +export type Role = z.infer; + +// 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; + +// 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; + +// Role-to-permission mappings +// In a real system this might be database-driven or configurable +type RolePermissionMap = Map; + +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 { + const perms = new Set(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;