From 571944dbffd2631e329df26e0a72be1490166952 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 13:47:05 +0200 Subject: [PATCH] feat(access): project effective roles and the catalog in the backend snapshot Each subject now carries its effective roles, the granted ones plus every role they inherit from through parents, with the latest expiry among the paths that grant them, merged exactly like permissions. A top-level catalog names every role and every permission the projection carries, so the backend can offer them in a picker. Both are additive to polinetwork.access-snapshot/v1. The catalog is part of the fingerprint, so renaming a role or permission bumps the generation and the ETag. --- README.md | 2 +- docs/idp-telegram-integration-rfc-v3.md | 22 ++++ src/auth/access-snapshot.integration.test.ts | 7 ++ src/auth/access-snapshot.test.ts | 107 ++++++++++++++++++- src/auth/access-snapshot.ts | 91 +++++++++++----- 5 files changed, 199 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3ff2970..54edaeb 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ Dynamic registration is disabled. Client-credentials grants are available when b ### Phase 1 backend access snapshot -Set `OAUTH_BACKEND_RESOURCE_URI` and `OAUTH_INTERNAL_RESOURCE_URI` to distinct HTTPS audience identifiers, then set `OAUTH_BACKEND_CLIENT_ID` to the registered backend snapshot-pull client's ID. A client-credentials token for the internal resource needs `idp:access:read`; the authenticated client ID selects the reviewed `backend` projection. `GET /api/internal/access-snapshot` returns a versioned list of subjects with backend permissions, their individual expiry times, and linked Telegram IDs. An exact `If-None-Match` match returns `304`; a new Graph observation changes the ETag even if membership is unchanged. Unknown clients and missing audience or scope are rejected. +Set `OAUTH_BACKEND_RESOURCE_URI` and `OAUTH_INTERNAL_RESOURCE_URI` to distinct HTTPS audience identifiers, then set `OAUTH_BACKEND_CLIENT_ID` to the registered backend snapshot-pull client's ID. A client-credentials token for the internal resource needs `idp:access:read`; the authenticated client ID selects the reviewed `backend` projection. `GET /api/internal/access-snapshot` returns a versioned list of subjects with backend permissions and effective roles, their individual expiry times, and linked Telegram IDs, plus the names of every role and backend permission. An exact `If-None-Match` match returns `304`; a new Graph observation changes the ETag even if membership is unchanged. Unknown clients and missing audience or scope are rejected. Entra direct-membership observations are stored by group. A pull lists a group through Graph only if no replica has observed it in the last 15 seconds, and concurrent pulls on one replica share that listing. A failed Graph request preserves the last observation, marks that source degraded, and lets its permissions expire one hour after the last success. Student rights expire at the verification timestamp. Manual assignments do not expire. Telegram IDs are included only while the linked account exists, regardless of the Telegram ID token's expiry. Duplicate Telegram IDs are excluded from the projection. diff --git a/docs/idp-telegram-integration-rfc-v3.md b/docs/idp-telegram-integration-rfc-v3.md index 66d9040..4feddcd 100644 --- a/docs/idp-telegram-integration-rfc-v3.md +++ b/docs/idp-telegram-integration-rfc-v3.md @@ -277,10 +277,27 @@ If-None-Match: "sha256-9f1c…" }, "polimi-email": { "health": "ok", "observedAt": "2026-10-07T10:15:02Z" } }, + "catalog": { + "roles": [ + { "key": "admin", "name": "Admin" }, + { "key": "moderator", "name": "Moderator" }, + { "key": "socio", "name": "Socio" } + ], + "permissions": [ + { "key": "admin:access", "name": "Open the admin dashboard" }, + { "key": "tg:grants:manage", "name": "Manage Telegram grants" }, + { "key": "tg:moderate", "name": "Moderate groups" }, + { "key": "tg:moderate:global", "name": "Moderate every group" } + ] + }, "subjects": [ { "sub": "usr_8f2a", "telegramId": "123456789", + "roles": { + "moderator": { "validUntil": null }, + "socio": { "validUntil": "2026-10-07T11:14:31Z" } + }, "permissions": { "tg:moderate": { "validUntil": null }, "tg:moderate:global": { "validUntil": "2026-10-07T11:14:31Z" } @@ -289,6 +306,7 @@ If-None-Match: "sha256-9f1c…" { "sub": "usr_luca", "telegramId": null, + "roles": { "admin": { "validUntil": null } }, "permissions": { "admin:access": { "validUntil": null }, "tg:grants:manage": { "validUntil": null } @@ -311,6 +329,10 @@ If-None-Match: "sha256-9f1c…" - The backend drops a permission locally when `now ≥ validUntil`, even if no new snapshot arrives. This one mechanism handles both student expiry and Entra staleness. A permanent right is never removed because an unrelated path expired. +**`roles` lists the subject's effective roles** with the same expiry rule: every role granted through a path above plus every role it inherits from, each with the latest validity among the paths that reach it. It does not change which subjects are included. + +**`catalog` names every role and every permission the projection carries,** sorted by key, so a consumer can offer them in a picker without asking the IdP. + **`telegramId` comes from the linked Telegram account,** not from the evidence row's validity. - The evidence row's `valid_until` is the Telegram ID token's `exp`. It must not make the link expire. diff --git a/src/auth/access-snapshot.integration.test.ts b/src/auth/access-snapshot.integration.test.ts index a7a791c..7c0e87d 100644 --- a/src/auth/access-snapshot.integration.test.ts +++ b/src/auth/access-snapshot.integration.test.ts @@ -107,10 +107,17 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)( expect(body).toMatchObject({ schema: "polinetwork.access-snapshot/v1", projection: "backend", + catalog: { + roles: expect.arrayContaining([ + { key: "snapshot-integration-role", name: "Snapshot test" }, + ]), + permissions: expect.arrayContaining([{ key: "tg:snapshot:test", name: "Snapshot test" }]), + }, subjects: expect.arrayContaining([ { sub: "snapshot-integration-user", telegramId: "987654321", + roles: { "snapshot-integration-role": { validUntil: null } }, permissions: { "tg:snapshot:test": { validUntil: null } }, }, ]), diff --git a/src/auth/access-snapshot.test.ts b/src/auth/access-snapshot.test.ts index 8088222..6fdbc48 100644 --- a/src/auth/access-snapshot.test.ts +++ b/src/auth/access-snapshot.test.ts @@ -8,7 +8,7 @@ vi.mock("../db", () => ({ db: {} })); vi.mock("./membership", () => ({ listEntraGroupMembers: vi.fn() })); vi.mock("./rbac-store", () => ({ readCatalog: vi.fn() })); -import { projectBackendSubjects } from "./access-snapshot"; +import { projectBackendCatalog, projectBackendSubjects } from "./access-snapshot"; const permission = (key: string, implies: string[] = []): PermissionSummary => ({ id: key, @@ -21,7 +21,12 @@ const permission = (key: string, implies: string[] = []): PermissionSummary => ( createdAt: null, updatedAt: null, }); -const role = (key: string, permissions: string[], managed = false): RoleSummary => ({ +const role = ( + key: string, + permissions: string[], + managed = false, + parents: string[] = [], +): RoleSummary => ({ id: key, key, name: key, @@ -29,7 +34,7 @@ const role = (key: string, permissions: string[], managed = false): RoleSummary managed, sourceState: managed ? key : null, permissions, - parents: [], + parents, memberCount: 0, createdAt: null, updatedAt: null, @@ -105,11 +110,16 @@ describe("backend access projection", () => { { sub: "entra-user", telegramId: null, + roles: { socio: { validUntil: "2026-10-08T10:30:00.000Z" } }, permissions: { "tg:read": { validUntil: "2026-10-08T10:30:00.000Z" } }, }, { sub: "manual-user", telegramId: "123456", + roles: { + manual: { validUntil: null }, + socio: { validUntil: "2026-10-08T10:30:00.000Z" }, + }, permissions: { "tg:moderate": { validUntil: null }, "tg:read": { validUntil: null }, @@ -118,6 +128,7 @@ describe("backend access projection", () => { { sub: "root", telegramId: null, + roles: { "master-admin": { validUntil: null } }, permissions: { "admin:access": { validUntil: null }, "tg:moderate": { validUntil: null }, @@ -127,6 +138,7 @@ describe("backend access projection", () => { { sub: "student-user", telegramId: null, + roles: { student: { validUntil: "2026-10-08T10:30:00.000Z" } }, permissions: { "tg:moderate": { validUntil: "2026-10-08T10:30:00.000Z" }, "tg:read": { validUntil: "2026-10-08T10:30:00.000Z" }, @@ -170,7 +182,94 @@ describe("backend access projection", () => { now, }); expect(subjects).toEqual([ - { sub: "signer", telegramId: null, permissions: { "esign:sign": { validUntil: null } } }, + { + sub: "signer", + telegramId: null, + roles: { secretary: { validUntil: null } }, + permissions: { "esign:sign": { validUntil: null } }, + }, ]); }); + + it("carries inherited roles with the latest expiry among the paths that grant them", () => { + const studentProof = (userId: string, validUntil: string) => ({ + userId, + providerId: "polimi-email", + issuer: "https://mail.polimi.it", + externalId: `${userId}@mail.polimi.it`, + states: ["student"], + validUntil: new Date(validUntil), + telegramId: null, + }); + const subjects = projectBackendSubjects({ + users: ["both", "board", "expired", "legacy"], + manualRoles: [ + { userId: "board", roleKey: "board" }, + { userId: "legacy", roleKey: "legacy" }, + ], + proofs: [ + pnProof("both", "b"), + studentProof("both", "2026-10-08T11:00:00.000Z"), + studentProof("expired", "2026-10-08T09:59:59.000Z"), + ], + catalog: { + permissions: [permission("tg:read"), permission("tg:moderate")], + roles: [ + role("member", ["tg:read"]), + role("board", ["tg:moderate"], false, ["member"]), + role("legacy", ["tg:read"], false, ["master-admin"]), + role("socio", [], true, ["member"]), + role("student", [], true, ["member"]), + role("master-admin", [], true), + ], + }, + groups, + observations: [ + { + source: "entra:soci", + groupId: "soci-group", + members: ["b"], + observedAt: new Date("2026-10-08T09:30:00.000Z"), + }, + ], + now, + }); + expect(subjects.map(({ sub, roles }) => ({ sub, roles }))).toEqual([ + { sub: "board", roles: { board: { validUntil: null }, member: { validUntil: null } } }, + { + sub: "both", + roles: { + member: { validUntil: "2026-10-08T11:00:00.000Z" }, + socio: { validUntil: "2026-10-08T10:30:00.000Z" }, + student: { validUntil: "2026-10-08T11:00:00.000Z" }, + }, + }, + { sub: "legacy", roles: { legacy: { validUntil: null } } }, + ]); + }); +}); + +describe("backend access catalog", () => { + it("names every role and only the permissions the projection carries, by key", () => { + expect( + projectBackendCatalog({ + ...catalog, + permissions: catalog.permissions.map((entry) => + entry.key === "tg:read" ? { ...entry, name: "Read groups" } : entry, + ), + }), + ).toEqual({ + roles: [ + { key: "manual", name: "manual" }, + { key: "master-admin", name: "master-admin" }, + { key: "socio", name: "socio" }, + { key: "student", name: "student" }, + ], + permissions: [ + { key: "admin:access", name: "admin:access" }, + { key: "tg:moderate", name: "tg:moderate" }, + { key: "tg:read", name: "Read groups" }, + ], + }); + }); }); diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index bfc9873..8f7e60b 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -13,7 +13,7 @@ import { import { authorizationMutationLock } from "../db/security-lock"; import { env } from "../env"; import { listEntraGroupMembers } from "./membership"; -import { effectiveRolePermissions, isManagedPermissionKey, type RbacCatalog } from "./rbac"; +import { isManagedPermissionKey, resolveAccess, type RbacCatalog } from "./rbac"; import { readCatalog } from "./rbac-store"; const ENTRA_GRACE_MS = 3_600_000; @@ -29,14 +29,21 @@ export type AccessSource = { export type AccessSubject = { sub: string; telegramId: string | null; + /** Effective roles: those granted plus every role they inherit from. */ + roles: Record; permissions: Record; }; +export type AccessCatalog = { + roles: { key: string; name: string }[]; + permissions: { key: string; name: string }[]; +}; export type AccessSnapshot = { schema: typeof SCHEMA; projection: "backend"; generation: number; builtAt: string; sources: Record; + catalog: AccessCatalog; subjects: AccessSubject[]; }; @@ -119,9 +126,34 @@ function refreshObservations(groups: Group[]): Promise> { return refreshing; } +/** Keeps the latest expiry among the paths that grant a key, where null never expires. */ +function extend(target: Map, key: string, expiry: string | null) { + const previous = target.get(key); + if (previous === null) return; + if (previous === undefined || expiry === null || expiry > previous) target.set(key, expiry); +} + +function byKey(held: Map): Record { + return Object.fromEntries( + [...held.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, validUntil]) => [key, { validUntil }]), + ); +} + +/** Names for the backend's pickers: every role, and the permissions the projection carries. */ +export function projectBackendCatalog(catalog: RbacCatalog): AccessCatalog { + const named = (entries: { key: string; name: string }[]) => + entries.map(({ key, name }) => ({ key, name })).sort((a, b) => a.key.localeCompare(b.key)); + return { + roles: named(catalog.roles), + permissions: named(catalog.permissions.filter((entry) => !isManagedPermissionKey(entry.key))), + }; +} + /** * Builds only the backend's reviewed projection: every permission except the identity provider's - * own. Every path carries its own expiry. + * own, and every effective role. Every path carries its own expiry. */ export function projectBackendSubjects(input: { users: string[]; @@ -133,26 +165,28 @@ export function projectBackendSubjects(input: { now: Date; }): AccessSubject[] { const { users, manualRoles, proofs, catalog, groups, observations, now } = input; - const permissions = new Map>( - users.map((id) => [id, new Map()]), + const held = new Map( + users.map((id) => [ + id, + { roles: new Map(), permissions: new Map() }, + ]), ); - const rolePermissions = new Map(); + const conferred = new Map(); const grant = (userId: string, roleKey: string, validUntil: Date | null) => { - const target = permissions.get(userId); + const target = held.get(userId); if (!target || (validUntil && validUntil <= now)) return; - let keys = rolePermissions.get(roleKey); - if (!keys) { - keys = effectiveRolePermissions(catalog, roleKey).filter( - (key) => !isManagedPermissionKey(key), - ); - rolePermissions.set(roleKey, keys); + let access = conferred.get(roleKey); + if (!access) { + const resolved = resolveAccess(catalog, [roleKey]); + access = { + roles: resolved.roles, + permissions: resolved.permissions.filter((key) => !isManagedPermissionKey(key)), + }; + conferred.set(roleKey, access); } const expiry = validUntil?.toISOString() ?? null; - for (const key of keys) { - const previous = target.get(key); - if (previous === null) continue; - if (previous === undefined || expiry === null || expiry > previous) target.set(key, expiry); - } + for (const key of access.roles) extend(target.roles, key, expiry); + for (const key of access.permissions) extend(target.permissions, key, expiry); }; for (const row of manualRoles) grant(row.userId, row.roleKey, null); @@ -205,8 +239,8 @@ export function projectBackendSubjects(input: { return users .flatMap((sub): AccessSubject[] => { - const held = permissions.get(sub); - if (!held?.size) return []; + const access = held.get(sub); + if (!access?.permissions.size) return []; const ids = telegramIds.get(sub); const id = ids?.size === 1 ? [...ids][0]! : null; const telegramId = id && owners.get(id) === 1 ? id : null; @@ -214,11 +248,8 @@ export function projectBackendSubjects(input: { { sub, telegramId, - permissions: Object.fromEntries( - [...held.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, validUntil]) => [key, { validUntil }]), - ), + roles: byKey(access.roles), + permissions: byKey(access.permissions), }, ]; }) @@ -281,8 +312,17 @@ export async function buildBackendSnapshot(): Promise { observations, now, }); + const names = projectBackendCatalog(catalog); const fingerprint = createHash("sha256") - .update(JSON.stringify({ schema: SCHEMA, projection: "backend", sources, subjects })) + .update( + JSON.stringify({ + schema: SCHEMA, + projection: "backend", + sources, + catalog: names, + subjects, + }), + ) .digest("hex"); const [previous] = await transaction .select() @@ -300,6 +340,7 @@ export async function buildBackendSnapshot(): Promise { ...sources, "polimi-email": { health: "ok", observedAt: builtAt }, }, + catalog: names, subjects, }; const serialized = JSON.stringify(body);