// The app's authorization vocabulary, in ONE place. // // Split out of `app.config.ts` so the tests can build the real plugin from the // real role map. A test that redeclares the roles it is testing proves only // that its own copy is self-consistent — the copy drifts, the test stays // green, and the drift is exactly the thing worth catching. /** * role → the scopes it grants. Scopes are `domain:action`; `'*'` expands to * the framework's blanket bypass (`admin:full`), which passes every check. * * This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes * the union of it, and `voltro check` errors on any descriptor guard requiring * a scope that appears nowhere here. So: add a scope to a guard → add it to a * role, or the build tells you the procedure is uncallable. * * (`notes:purge`, used by `notes.delete` via `can()`, is deliberately absent — * only the wildcard `admin` role reaches it. See that handler for why that is * a lesson rather than an oversight.) */ export const roles = { viewer: ['notes:read'], editor: ['notes:read', 'notes:write'], owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'], admin: ['*'], } /** * DEMO membership table — which roles a caller holds ON A SPECIFIC team. * PRODUCTION does a DB read (a `memberships` lookup keyed by `subject.id` + * the resource id); this literal keeps the template infra-free. */ export const teamMemberships: Record>> = { // subjectId → teamId → the roles held on THAT team u_alice: { team_core: ['owner'], team_marketing: ['viewer'] }, u_bob: { team_marketing: ['owner'] }, } /** * Roles the caller holds on ONE resource. `Subject.id` is `string | null` * (an anonymous caller has no id), and an anonymous caller is a member of * nothing — so the null case returns `[]` rather than being coerced into a * lookup key that could collide. */ export const rolesOnTeam = ( subjectId: string | null | undefined, teamId: string, ): ReadonlyArray => subjectId == null ? [] : teamMemberships[subjectId]?.[teamId] ?? [] /** * DEMO role resolver — assigns a role by the request's TENANT so every role is * reachable with an `x-tenant` header and zero auth setup. * * PRODUCTION reads the caller's REAL roles: the default resolver reads * `subject.metadata.roles` (set by your auth strategy), or do a DB lookup here * (`subject.id` / `subject.tenantId` → roles). Delete this and omit * `resolveRoles` entirely to fall back to the metadata default. * * Know the failure posture whatever you put here: if this throws or rejects, * rbac logs and degrades to the subject's OWN scopes. It never grants on * failure — but it also does not deny a caller whose api key already carried * valid scopes. */ export const demoRolesForTenant = ( // `Subject.tenantId` is `string | null` — an anonymous caller has no tenant. tenantId: string | null | undefined, ): ReadonlyArray => { const demo: Record> = { acme: ['admin'], owners: ['owner'], editors: ['editor'], readers: ['viewer'], } // A caller with NO tenant gets NOTHING. The line above this one is careful // about exactly that for `rolesOnTeam` — "an anonymous caller is a member of // nothing" — and this function used to do the opposite one screen later: // `demo[tenantId ?? '']` coerced null to a key that misses, and the `??` // handed the anonymous caller `['viewer']`, which grants `notes:read`. // // The result was a shipped example whose `guards: [{ scope: 'notes:read' }]` // answered an unauthenticated call. `voltro probe access` reports it as // `notes.list — ANSWERED an unauthenticated call`, which is accurate: the // guard runs, and the resolver had already given the caller the scope. // // An UNKNOWN tenant still gets `viewer` — that is the demo convenience, and // it needs a header to reach. Presenting nothing is not the same as // presenting something unrecognised. if (tenantId == null || tenantId === '') return [] return demo[tenantId] ?? ['viewer'] }