diff --git a/docs/idp-telegram-integration-rfc-v3.md b/docs/idp-telegram-integration-rfc-v3.md index fea5fa6..66d9040 100644 --- a/docs/idp-telegram-integration-rfc-v3.md +++ b/docs/idp-telegram-integration-rfc-v3.md @@ -236,7 +236,7 @@ The legacy names `owner`, `president`, `direttivo`, `hr`, `admin`, `web` and `cr A **projection** is a named, admin-controlled view of the access data. It is defined in the IdP as configuration (reviewed by pull request), and lists: - which **clients** may pull it; -- which **permission prefixes** it contains; +- which **permissions** it contains (the `backend` projection: every permission except the IdP's own `idp:*`, so permissions created in the dashboard need no code change); - which **identity fields** it includes (for example, the Telegram ID). ```ts @@ -244,7 +244,6 @@ A **projection** is a named, admin-controlled view of the access data. It is def export const ACCESS_PROJECTIONS = { backend: { clients: ["backend"], - prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:"], fields: ["telegramId"], }, } as const; diff --git a/src/auth/access-projections.ts b/src/auth/access-projections.ts index c729723..545b985 100644 --- a/src/auth/access-projections.ts +++ b/src/auth/access-projections.ts @@ -1,10 +1,13 @@ import { env } from "../env"; -/** Reviewed projection policy. Client IDs are chosen during deployment, not by the caller. */ +/** + * Reviewed projection policy. Client IDs are chosen during deployment, not by the caller. + * The backend receives every permission except the identity provider's own (`idp:*`), so a + * permission created in the dashboard reaches it without a code change. + */ export const ACCESS_PROJECTIONS = { backend: { clients: env.OAUTH_BACKEND_CLIENT_ID ? [env.OAUTH_BACKEND_CLIENT_ID] : [], - prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:"], fields: ["telegramId"], }, } as const; diff --git a/src/auth/access-snapshot.test.ts b/src/auth/access-snapshot.test.ts index d50364b..8088222 100644 --- a/src/auth/access-snapshot.test.ts +++ b/src/auth/access-snapshot.test.ts @@ -155,4 +155,22 @@ describe("backend access projection", () => { expect(subjects.map(({ telegramId }) => telegramId)).toEqual([null, null, null]); expect(subjects[0]?.permissions["tg:read"]).toEqual({ validUntil: null }); }); + + it("carries permissions created in the dashboard but never the identity provider's own", () => { + const subjects = projectBackendSubjects({ + users: ["signer"], + manualRoles: [{ userId: "signer", roleKey: "secretary" }], + proofs: [], + catalog: { + permissions: [permission("esign:sign"), permission("idp:roles:write")], + roles: [role("secretary", ["esign:sign", "idp:roles:write"])], + }, + groups: [], + observations: [], + now, + }); + expect(subjects).toEqual([ + { sub: "signer", telegramId: null, permissions: { "esign:sign": { validUntil: null } } }, + ]); + }); }); diff --git a/src/auth/access-snapshot.ts b/src/auth/access-snapshot.ts index c732f09..bfc9873 100644 --- a/src/auth/access-snapshot.ts +++ b/src/auth/access-snapshot.ts @@ -13,8 +13,7 @@ import { import { authorizationMutationLock } from "../db/security-lock"; import { env } from "../env"; import { listEntraGroupMembers } from "./membership"; -import { ACCESS_PROJECTIONS } from "./access-projections"; -import { effectiveRolePermissions, type RbacCatalog } from "./rbac"; +import { effectiveRolePermissions, isManagedPermissionKey, type RbacCatalog } from "./rbac"; import { readCatalog } from "./rbac-store"; const ENTRA_GRACE_MS = 3_600_000; @@ -120,7 +119,10 @@ function refreshObservations(groups: Group[]): Promise> { return refreshing; } -/** Builds only the backend's reviewed projection; every path carries its own expiry. */ +/** + * Builds only the backend's reviewed projection: every permission except the identity provider's + * own. Every path carries its own expiry. + */ export function projectBackendSubjects(input: { users: string[]; manualRoles: { userId: string; roleKey: string }[]; @@ -140,8 +142,8 @@ export function projectBackendSubjects(input: { if (!target || (validUntil && validUntil <= now)) return; let keys = rolePermissions.get(roleKey); if (!keys) { - keys = effectiveRolePermissions(catalog, roleKey).filter((key) => - ACCESS_PROJECTIONS.backend.prefixes.some((prefix) => key.startsWith(prefix)), + keys = effectiveRolePermissions(catalog, roleKey).filter( + (key) => !isManagedPermissionKey(key), ); rolePermissions.set(roleKey, keys); }