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:
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