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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,12 @@ Dynamic registration is open to AI agents only, for the [MCP server](#mcp-server

### 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 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.
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, linked Telegram IDs and Microsoft 365 object 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.

Each subject's `entraObjectId` is the Entra object ID (`oid`) of the PoliNetwork Microsoft 365 account it signed in with: the `externalId` of its `pn-entra` proof, the same ID that proves `socio` and `direttivo`. The backend uses it to find the person's own register entry. Like `telegramId`, it is `null` when the account has no Microsoft 365 sign-in, when it is linked to several Microsoft 365 accounts, or when one Microsoft 365 account is linked to several accounts here. It stays while the linked account exists, whatever the group membership, so it identifies former soci too, but only subjects holding a backend permission appear in the snapshot.

Database triggers enqueue changes to the snapshot's inputs in `access_outbox`. Rewrites that leave the projected columns unchanged, such as sign-ins, are ignored, and at most one undelivered row is kept per projection, so the table stays small while no receiver is configured. Once the backend receiver exists, set `OAUTH_BACKEND_EVENTS_URL` to its `/internal/events` endpoint. The production server then claims the queued rows, sends one IdP-signed security-event token for them and retries with backoff until the receiver replies `202`; the backend must still poll the snapshot because push delivery is best-effort. Leave the URL unset until the receiver is deployed. JWT signing keys rotate every seven days and public keys overlap for thirty days. Audit rows record Telegram links, client changes and student verification without storing client secrets.

Master Admin registers the four Phase 1 clients on `/applications/new`, each with a private key (its public Ed25519 or P-256 JWKS) and the grants and scopes reviewed in [RFC §9](docs/idp-telegram-integration-rfc-v3.md): Telegram Bot and Website call APIs as themselves with their `backend:tg:*` and `backend:public:read` scopes, Backend snapshot pull with `idp:access:read`, and Admin Dashboard signs people in with `openid profile email offline_access` and calls `backend:admin` for them, with sign-out ending the PoliNetwork session. The resources those scopes belong to are linked on save. The application page edits the JWKS with overlap during rotation: add the new public key, deploy the matching private key to the owning service, then remove the old public key after the overlap. Other application writers cannot edit or delete clients with API access. Record the generated client IDs in deployment configuration; the backend snapshot-pull ID must match `OAUTH_BACKEND_CLIENT_ID`. Private keys belong in the owning service's Key Vault secret and must never be pasted into this IdP.
Expand Down
2 changes: 1 addition & 1 deletion src/auth/access-projections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { env } from "../env";
export const ACCESS_PROJECTIONS = {
backend: {
clients: env.OAUTH_BACKEND_CLIENT_ID ? [env.OAUTH_BACKEND_CLIENT_ID] : [],
fields: ["telegramId"],
fields: ["telegramId", "entraObjectId"],
},
} as const;

Expand Down
1 change: 1 addition & 0 deletions src/auth/access-snapshot.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)(
{
sub: "snapshot-integration-user",
telegramId: "987654321",
entraObjectId: null,
roles: { "snapshot-integration-role": { validUntil: null } },
permissions: { "tg:snapshot:test": { validUntil: null } },
},
Expand Down
101 changes: 101 additions & 0 deletions src/auth/access-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ describe("backend access projection", () => {
{
sub: "entra-user",
telegramId: null,
entraObjectId: "e",
roles: { socio: { validUntil: "2026-10-08T10:30:00.000Z" } },
permissions: { "tg:read": { validUntil: "2026-10-08T10:30:00.000Z" } },
},
{
sub: "manual-user",
telegramId: "123456",
entraObjectId: "m",
roles: {
manual: { validUntil: null },
socio: { validUntil: "2026-10-08T10:30:00.000Z" },
Expand All @@ -129,6 +131,7 @@ describe("backend access projection", () => {
{
sub: "root",
telegramId: null,
entraObjectId: null,
roles: { "master-admin": { validUntil: null } },
permissions: {
"admin:access": { validUntil: null },
Expand All @@ -139,6 +142,7 @@ describe("backend access projection", () => {
{
sub: "student-user",
telegramId: null,
entraObjectId: null,
roles: { student: { validUntil: "2026-10-08T10:30:00.000Z" } },
permissions: {
"tg:moderate": { validUntil: "2026-10-08T10:30:00.000Z" },
Expand Down Expand Up @@ -169,6 +173,102 @@ describe("backend access projection", () => {
expect(subjects[0]?.permissions["tg:read"]).toEqual({ validUntil: null });
});

it("carries each account's Microsoft 365 object ID unless the link is ambiguous", () => {
const subjects = projectBackendSubjects({
users: ["single", "relinked", "twice", "shared-a", "shared-b", "foreign", "none"],
manualRoles: ["single", "relinked", "twice", "shared-a", "shared-b", "foreign", "none"].map(
(userId) => ({ userId, roleKey: "manual" }),
),
proofs: [
pnProof("single", "oid-single"),
// The same Microsoft 365 account signed in twice is still one link.
pnProof("relinked", "oid-relinked"),
pnProof("relinked", "oid-relinked"),
pnProof("twice", "oid-twice-1"),
pnProof("twice", "oid-twice-2"),
pnProof("shared-a", "oid-shared"),
pnProof("shared-b", "oid-shared"),
{
...pnProof("foreign", "oid-foreign"),
issuer: "https://login.microsoftonline.com/other/v2.0",
},
tgProof("none", "77"),
],
catalog,
groups: [],
observations: [],
now,
});
expect(
Object.fromEntries(subjects.map(({ sub, entraObjectId }) => [sub, entraObjectId])),
).toEqual({
foreign: null,
none: null,
relinked: "oid-relinked",
"shared-a": null,
"shared-b": null,
single: "oid-single",
twice: null,
});
});

it("logs only changes to ambiguous links, with separate user and ID counts", () => {
const project = (proofs: Parameters<typeof projectBackendSubjects>[0]["proofs"]) =>
projectBackendSubjects({
users: [],
manualRoles: [],
proofs,
catalog,
groups: [],
observations: [],
now,
});
project([]);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const proofs = [
tgProof("a", "42"),
tgProof("b", "42"),
tgProof("c", "99"),
tgProof("c", "100"),
];
project(proofs);
expect(error).toHaveBeenLastCalledWith("Access snapshot excluded ambiguous Telegram links.", {
userCount: 1,
idCount: 1,
});
project([...proofs].reverse().concat(proofs[0]!, tgProof("unique", "unique")));
expect(error).toHaveBeenCalledTimes(1);
// Changing an ID or an owner matters even when the counts stay the same.
const changedId = proofs.map((proof) =>
proof.telegramId === "42" ? { ...proof, telegramId: "43" } : proof,
);
project(changedId);
expect(error).toHaveBeenCalledTimes(2);
const changedUser = changedId.map((proof) =>
proof.userId === "a" ? { ...proof, userId: "d" } : proof,
);
project(changedUser);
expect(error).toHaveBeenCalledTimes(3);
project(
changedUser.map((proof) => ({
...pnProof(proof.userId, proof.telegramId!),
telegramId: null,
})),
);
expect(error).toHaveBeenLastCalledWith(
"Access snapshot excluded ambiguous Microsoft 365 links.",
{ userCount: 1, idCount: 1 },
);
project([]);
project(proofs);
expect(error).toHaveBeenCalledTimes(5);
} finally {
project([]);
error.mockRestore();
}
});

it("carries permissions created in the dashboard but never the identity provider's own", () => {
const subjects = projectBackendSubjects({
users: ["signer"],
Expand All @@ -186,6 +286,7 @@ describe("backend access projection", () => {
{
sub: "signer",
telegramId: null,
entraObjectId: null,
roles: { secretary: { validUntil: null } },
permissions: { "esign:sign": { validUntil: null } },
},
Expand Down
92 changes: 68 additions & 24 deletions src/auth/access-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type AccessSource = {
export type AccessSubject = {
sub: string;
telegramId: string | null;
/** Microsoft 365 (Entra) object ID of the linked PoliNetwork account; null when none or ambiguous. */
entraObjectId: string | null;
/** Effective roles: those granted plus every role they inherit from. */
roles: Record<string, { validUntil: string | null }>;
permissions: Record<string, { validUntil: string | null }>;
Expand Down Expand Up @@ -141,6 +143,58 @@ function byKey(held: Map<string, string | null>): Record<string, { validUntil: s
);
}

// Remember only the latest ambiguity per provider in this process.
const ambiguityFingerprints = new Map<string, string>();

/**
* Each account's single linked external ID. Someone linked to several IDs, or an ID linked to
* several accounts, gets none: the backend must never act on the wrong person.
*/
function uniqueLinks(
proofs: Proof[],
idOf: (proof: Proof) => string | null,
label: string,
): Map<string, string> {
const byUser = new Map<string, Set<string>>();
for (const proof of proofs) {
const id = idOf(proof);
if (!id) continue;
const ids = byUser.get(proof.userId) ?? new Set<string>();
ids.add(id);
byUser.set(proof.userId, ids);
}
const owners = new Map<string, number>();
for (const ids of byUser.values())
for (const id of ids) owners.set(id, (owners.get(id) ?? 0) + 1);
const ambiguousUsers = [...byUser.values()].filter((ids) => ids.size > 1).length;
const ambiguousIds = [...owners.values()].filter((count) => count > 1).length;
if (ambiguousUsers || ambiguousIds) {
const links = [...byUser.entries()]
.map(
([userId, ids]) =>
[userId, [...ids].filter((id) => ids.size > 1 || owners.get(id)! > 1).sort()] as const,
)
.filter(([, ids]) => ids.length > 0)
.sort(([a], [b]) => a.localeCompare(b));
const fingerprint = createHash("sha256").update(JSON.stringify(links)).digest("hex");
if (ambiguityFingerprints.get(label) !== fingerprint) {
console.error(`Access snapshot excluded ambiguous ${label} links.`, {
userCount: ambiguousUsers,
idCount: ambiguousIds,
});
ambiguityFingerprints.set(label, fingerprint);
}
} else {
ambiguityFingerprints.delete(label);
}
const unique = new Map<string, string>();
for (const [userId, ids] of byUser) {
const [id] = ids;
if (ids.size === 1 && owners.get(id!) === 1) unique.set(userId, id!);
}
return unique;
}

/** 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 }[]) =>
Expand Down Expand Up @@ -216,38 +270,28 @@ export function projectBackendSubjects(input: {
}
}

const telegramIds = new Map<string, Set<string>>();
for (const proof of proofs) {
if (
proof.providerId !== "telegram" ||
proof.issuer !== "https://oauth.telegram.org" ||
!proof.telegramId
)
continue;
const ids = telegramIds.get(proof.userId) ?? new Set<string>();
ids.add(proof.telegramId);
telegramIds.set(proof.userId, ids);
}
const owners = new Map<string, number>();
for (const ids of telegramIds.values())
for (const id of ids) owners.set(id, (owners.get(id) ?? 0) + 1);
const ambiguous =
[...owners.values()].filter((count) => count > 1).length +
[...telegramIds.values()].filter((ids) => ids.size > 1).length;
if (ambiguous)
console.error("Access snapshot excluded ambiguous Telegram links.", { count: ambiguous });
const telegramIds = uniqueLinks(
proofs.filter(
(proof) => proof.providerId === "telegram" && proof.issuer === "https://oauth.telegram.org",
),
(proof) => proof.telegramId,
"Telegram",
);
const entraObjectIds = uniqueLinks(
proofs.filter((proof) => proof.providerId === "pn-entra" && proof.issuer === entraIssuer),
(proof) => proof.externalId,
"Microsoft 365",
);

return users
.flatMap((sub): AccessSubject[] => {
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;
return [
{
sub,
telegramId,
telegramId: telegramIds.get(sub) ?? null,
entraObjectId: entraObjectIds.get(sub) ?? null,
roles: byKey(access.roles),
permissions: byKey(access.permissions),
},
Expand Down
Loading