Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/idp-telegram-integration-rfc-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,14 @@ 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
// auth: access projection registry (illustrative)
export const ACCESS_PROJECTIONS = {
backend: {
clients: ["backend"],
prefixes: ["admin:", "tg:", "wa:", "groups:", "web:", "azure:"],
fields: ["telegramId"],
},
} as const;
Expand Down
7 changes: 5 additions & 2 deletions src/auth/access-projections.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/auth/access-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
]);
});
});
12 changes: 7 additions & 5 deletions src/auth/access-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,7 +119,10 @@ function refreshObservations(groups: Group[]): Promise<Set<string>> {
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 }[];
Expand All @@ -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);
}
Expand Down
Loading