From a8297d8e2c5436a794395d904b7a490c9f1fcaa9 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 11:26:42 +0200 Subject: [PATCH 1/2] fix(access): include esign permissions in the backend projection The backend projection only carried the admin:, tg:, wa:, groups:, web: and azure: prefixes, so granted esign:sign and esign:requests:manage never reached the backend and the dashboard hid the Signatures section. --- docs/idp-telegram-integration-rfc-v3.md | 2 +- src/auth/access-projections.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/idp-telegram-integration-rfc-v3.md b/docs/idp-telegram-integration-rfc-v3.md index fea5fa6..0aa8161 100644 --- a/docs/idp-telegram-integration-rfc-v3.md +++ b/docs/idp-telegram-integration-rfc-v3.md @@ -244,7 +244,7 @@ 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:"], + prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:", "esign:"], fields: ["telegramId"], }, } as const; diff --git a/src/auth/access-projections.ts b/src/auth/access-projections.ts index c729723..87538d3 100644 --- a/src/auth/access-projections.ts +++ b/src/auth/access-projections.ts @@ -4,7 +4,7 @@ import { env } from "../env"; export const ACCESS_PROJECTIONS = { backend: { clients: env.OAUTH_BACKEND_CLIENT_ID ? [env.OAUTH_BACKEND_CLIENT_ID] : [], - prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:"], + prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:", "esign:"], fields: ["telegramId"], }, } as const; From b950d784b555c509edd98f60c11afef1b47d95b3 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 11:37:12 +0200 Subject: [PATCH 2/2] fix(access): send the backend every permission except the identity provider's own Replaces the prefix allowlist with excluding the managed idp:* permissions, so a permission created in the dashboard reaches the backend without a code change. --- docs/idp-telegram-integration-rfc-v3.md | 3 +-- src/auth/access-projections.ts | 7 +++++-- src/auth/access-snapshot.test.ts | 18 ++++++++++++++++++ src/auth/access-snapshot.ts | 12 +++++++----- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/idp-telegram-integration-rfc-v3.md b/docs/idp-telegram-integration-rfc-v3.md index 0aa8161..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:", "esign:"], fields: ["telegramId"], }, } as const; diff --git a/src/auth/access-projections.ts b/src/auth/access-projections.ts index 87538d3..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:", "esign:"], 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); }