// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of // permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's // creator), and other roles are role_assignment rows. import { and, eq, schema, type Database } from "@t5de/db"; const { catalog, product, roleAssignment, user } = schema; export const permissions = [ "product:edit", "product:edit-locked", "product:lock", "product:rate-lower", "product:publish", "product:delete", "product:restore", "product:remove", "product:unremove", "product:transfer", "revision:upload", "revision:retract", "catalog:approve", "catalog:edit", "catalog:rotate-key", "catalog:suspend", "catalog:delete-request", "catalog:delete-review", "catalog:transfer", "team:manage", "member:approve", "member:invite", "member:remove", "member:ban", "hosting:choose", "hosting:delist", "import:grant", "import:grant-on-behalf", "import:accept", "import:run", "import:pause", "import:withdraw", "case:read", "case:resolve", "case:assign", "audit:read", "audit:read-sensitive", "stats:read", "person:approve", "person:ban", "person:link-cap", "person:notes", "person:role", "person:view-as", "room:edit", "room:close", "room:kick", "room:ban", "room:mute", "settings:edit", ] as const; export type Permission = (typeof permissions)[number]; /** Where a permission applies. The hub covers everything. */ export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string }; export const hubScope: Scope = { type: "hub" }; const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":")); const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p)); const catalogOwner: Permission[] = [ "catalog:edit", "catalog:rotate-key", "catalog:delete-request", "catalog:transfer", "team:manage", ...all("member"), "hosting:delist", "import:grant", "import:accept", "import:run", "import:pause", "import:withdraw", "case:read", "case:resolve", "audit:read", "stats:read", ]; /** The built-in roles: name, the scope they're given at, and their permissions. */ export const roles = { admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] }, moderator: { name: "Moderator", scope: "hub", permissions: [ ...without(all("product"), "product:transfer"), "revision:retract", "catalog:approve", "catalog:suspend", "catalog:delete-review", "hosting:delist", "import:grant-on-behalf", "import:withdraw", "import:pause", ...all("case"), "person:approve", "person:ban", "person:link-cap", "person:notes", "audit:read", "stats:read", ...all("room"), ] as Permission[], }, "catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner }, "catalog-operator": { name: "Operator", scope: "catalog", permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"), }, creator: { name: "Creator", scope: "product", permissions: [ "product:edit", "product:publish", "product:delete", "product:restore", "product:transfer", "revision:upload", "revision:retract", "hosting:choose", "case:read", "audit:read", "stats:read", ] as Permission[], }, } as const satisfies Record; export type RoleId = keyof typeof roles; /** Roles that can be given at a catalog (the owner is derived, never given). */ export const assignableCatalogRoles = ["catalog-operator"] as const; /** A person, as the check needs them. */ export type Who = { id: string; role: string }; /** Thrown when a permission is missing; the API answers 403 with what was missing. */ export class Forbidden extends Error { constructor( readonly permission: Permission, readonly scope: Scope, ) { super("You don't have permission to do that."); } } export class Permissions { constructor(private readonly db: Database) {} /** A person's hub-wide permissions (their user.role). */ hub(who: Who | undefined): Set { if (who?.role === "admin") return new Set(roles.admin.permissions); if (who?.role === "moderator") return new Set(roles.moderator.permissions); return new Set(); } /** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */ async rolesAt(who: Who | undefined, scope: Scope): Promise { if (!who) return []; const out: RoleId[] = []; if (who.role === "admin") out.push("admin"); else if (who.role === "moderator") out.push("moderator"); if (scope.type === "catalog") { const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id)); if (c?.ownerId === who.id) out.push("catalog-owner"); const given = await this.db .select({ role: roleAssignment.role }) .from(roleAssignment) .where( and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)), ); for (const g of given) if (g.role in roles) out.push(g.role as RoleId); } else if (scope.type === "product") { const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id)); if (p?.creatorId === who.id) out.push("creator"); } return out; } /** Every permission a person holds at a scope. */ async at(who: Who | undefined, scope: Scope): Promise> { const out = new Set(); for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p); return out; } async can(who: Who | undefined, permission: Permission, scope: Scope): Promise { if (this.hub(who).has(permission)) return true; if (scope.type === "hub") return false; return (await this.at(who, scope)).has(permission); } /** Throws Forbidden unless the person holds the permission at the scope. */ async require(who: Who | undefined, permission: Permission, scope: Scope): Promise { if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope); } /** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */ async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise { const held = await this.at(who, scope); return roles[role].permissions.every((p) => held.has(p)); } /** The catalogs a person is on the team of (owner or a role there). */ async teamCatalogs(personId: string): Promise { const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId)); const given = await this.db .select({ id: roleAssignment.scopeId }) .from(roleAssignment) .where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"))); return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])]; } /** The products a person created. */ async ownProducts(personId: string): Promise { return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id); } /** A catalog's team: the owner and everyone given a role there, with their names. */ async team(catalogId: string) { const [c] = await this.db .select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username }) .from(catalog) .innerJoin(user, eq(user.id, catalog.ownerId)) .where(eq(catalog.id, catalogId)); const given = await this.db .select({ personId: roleAssignment.userId, role: roleAssignment.role, name: user.displayUsername, username: user.username, since: roleAssignment.createdAt, }) .from(roleAssignment) .innerJoin(user, eq(user.id, roleAssignment.userId)) .where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId))); return { owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined, roles: given.map((g) => ({ personId: g.personId, role: g.role, username: g.name ?? g.username ?? "", since: g.since.toISOString(), })), }; } }