From d3e466d9e56952dc423e6d3468557503d6ae918b Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Wed, 16 Sep 2026 13:46:52 +0200
Subject: [PATCH 01/15] fix: harden RBAC permission boundaries
---
README.md | 27 ++---
drizzle/0006_volatile_pandemic.sql | 7 +-
src/auth/api-guard.ts | 35 ++++--
src/auth/identity.integration.test.ts | 63 +++++++++--
src/auth/membership.test.ts | 14 +++
src/auth/membership.ts | 12 +--
src/auth/rbac-store.ts | 3 +-
src/auth/rbac.test.ts | 14 +++
src/auth/rbac.ts | 14 ++-
src/components/oidc/client-form.tsx | 25 +++--
src/components/rbac/require-permission.tsx | 8 +-
.../access/permissions/$permissionId.tsx | 77 +++++++------
src/routes/access/permissions/index.tsx | 13 ++-
src/routes/api/rbac/catalog.ts | 11 +-
src/routes/applications/$clientId.tsx | 102 ++++++++++--------
src/routes/applications/index.tsx | 31 +++---
src/routes/applications/new.tsx | 15 ++-
src/routes/applications/route.tsx | 6 +-
18 files changed, 325 insertions(+), 152 deletions(-)
diff --git a/README.md b/README.md
index 7da8508..c7ef0a7 100644
--- a/README.md
+++ b/README.md
@@ -62,12 +62,12 @@ inherit from each other or two permissions grant each other.
Four roles always exist and are never created, deleted, or handed out by an administrator.
Their membership is conferred by the identity provider itself:
-| Role | Key | Granted by |
-| -------------- | -------------- | -------------------------------------------------------------------------- |
-| `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS` or the configured Entra administrators group |
-| `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID |
-| `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID |
-| `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address |
+| Role | Key | Granted by |
+| -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, or any PN Entra account when no group is set |
+| `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID |
+| `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID |
+| `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address |
**Master Admin holds every permission that exists**, including ones created after it was
last looked at, because it is a wildcard rather than a stored list. It therefore has no
@@ -75,8 +75,10 @@ grant list of its own to edit, and no role may inherit from it: that would laund
wildcard nobody can be given into a role an administrator could hand to anyone.
Unlike the other three it is not proven by identity evidence and never appears among the
`states`: it comes from the deployment's own configuration, which is what keeps the service
-from being locked out of its own administration. Set `IDP_ADMIN_USER_IDS`, or
-`PN_ENTRA_OIDC_ADMIN_GROUP_ID` to a Microsoft Entra group, to decide who holds it.
+from being locked out of its own administration. `IDP_ADMIN_USER_IDS` is always honored.
+Set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to limit everyone else to that Microsoft Entra group.
+If the group is unset, every linked PN Entra account holds Master Admin, preserving the
+service's previous OIDC administration rule.
What the other three grant is still yours to choose: give them permissions, rename them,
describe them, and place them in the hierarchy like any other role. Only their key, their
@@ -110,7 +112,7 @@ keys; which roles carry them is entirely up to you.
| Permission | Covers |
| ------------------------ | ---------------------------------------------------------- |
| `idp:people:read` | Searching the people registered here |
-| `idp:permissions:read` | Seeing permissions, and the `/access` section at all |
+| `idp:permissions:read` | Seeing permissions in the `/access` section |
| `idp:permissions:write` | Creating, changing, and deleting permissions |
| `idp:roles:read` | Seeing roles, what they grant, and who holds them |
| `idp:roles:write` | Creating and changing roles, and giving them to people |
@@ -128,9 +130,10 @@ the navigation only offers what you hold. Because Master Admin is a wildcard ove
permission, whoever the deployment configures as an administrator holds all of these, which
is the bootstrap and break-glass path: there is no second kind of check beside RBAC.
-Granting these is real delegation. Someone with `idp:roles:write` can give themselves any
-other role, and so effectively holds everything short of Master Admin. Treat it as you
-would root.
+Granting either write permission is real delegation. Someone with `idp:roles:write` can
+give themselves any other role. Someone with `idp:permissions:write` can make a permission
+they already hold imply another managed permission. Either can therefore acquire every
+stored capability short of Master Admin's wildcard. Treat both as you would root.
### Assigning a role
diff --git a/drizzle/0006_volatile_pandemic.sql b/drizzle/0006_volatile_pandemic.sql
index 2188569..18a6059 100644
--- a/drizzle/0006_volatile_pandemic.sql
+++ b/drizzle/0006_volatile_pandemic.sql
@@ -1,9 +1,10 @@
ALTER TABLE "permission" ADD COLUMN "managed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Master Admin holds every permission that exists, as a wildcard rather than a stored
-- grant list, so it keeps covering permissions created later. Its membership comes from
--- IDP_ADMIN_USER_IDS or the configured Entra administrators group rather than from
--- identity evidence, which is why it has no source_state: that is the bootstrap path that
--- keeps the service from being locked out of its own administration.
+-- IDP_ADMIN_USER_IDS or the Entra administration policy rather than from identity evidence.
+-- When no administrators group is configured, every linked PN Entra account holds it,
+-- preserving the previous client-administration policy. It has no source_state because this
+-- deployment configuration is the bootstrap path that prevents an administrative lockout.
INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES
('static-role-master-admin', 'master-admin', 'Master Admin', 'Complete control of this identity provider.', true, NULL)
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
diff --git a/src/auth/api-guard.ts b/src/auth/api-guard.ts
index d1b6b43..870ad72 100644
--- a/src/auth/api-guard.ts
+++ b/src/auth/api-guard.ts
@@ -1,5 +1,5 @@
import { auth } from "./index";
-import { hasIdpPermission } from "./idp-access";
+import { idpPermissions } from "./idp-access";
import type { ManagedPermissionKey } from "./rbac";
import { env } from "../env";
@@ -9,7 +9,22 @@ export function apiError(status: number, error: string, fields?: Record {
+ if (options.write && request.headers.get("origin") !== new URL(env.BETTER_AUTH_URL).origin)
+ return { response: apiError(403, "Invalid origin.") };
+ const session = await auth.api.getSession({ headers: request.headers });
+ if (!session) return { response: apiError(401, "Unauthorized.") };
+ const permissions = await idpPermissions(session.user.id);
+ if (!required.some((permission) => permissions.includes(permission)))
+ return { response: apiError(403, "You do not have permission to do that.") };
+ return { session: { userId: session.user.id, permissions } };
+}
/**
* Shared entry check for the administration endpoints: a same-origin request when it
@@ -20,11 +35,13 @@ export async function requireIdpPermission(
permission: ManagedPermissionKey,
options: { write?: boolean } = {},
): Promise {
- if (options.write && request.headers.get("origin") !== new URL(env.BETTER_AUTH_URL).origin)
- return { response: Response.json({ error: "Invalid origin." }, { status: 403 }) };
- const session = await auth.api.getSession({ headers: request.headers });
- if (!session) return { response: apiError(401, "Unauthorized.") };
- if (!(await hasIdpPermission(session.user.id, permission)))
- return { response: apiError(403, "You do not have permission to do that.") };
- return { session: { userId: session.user.id } };
+ return requirePermission(request, [permission], options);
+}
+
+export async function requireAnyIdpPermission(
+ request: Request,
+ permissions: readonly ManagedPermissionKey[],
+ options: { write?: boolean } = {},
+): Promise {
+ return requirePermission(request, permissions, options);
}
diff --git a/src/auth/identity.integration.test.ts b/src/auth/identity.integration.test.ts
index 3b8c952..42e7e7b 100644
--- a/src/auth/identity.integration.test.ts
+++ b/src/auth/identity.integration.test.ts
@@ -6,10 +6,8 @@ const baseURL = process.env.IDENTITY_TEST_URL;
const databaseURL = process.env.IDENTITY_TEST_DATABASE_URL;
const secret = process.env.IDENTITY_TEST_SECRET;
-describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration", () => {
- const pool = new Pool({ connectionString: databaseURL });
- const token = "identity-integration-session";
- const headers = {
+function sessionHeaders(token: string) {
+ return {
Cookie: `better-auth.session_token=${encodeURIComponent(
`${token}.${createHmac("sha256", secret ?? "unused")
.update(token)
@@ -18,12 +16,23 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
Origin: baseURL ?? "http://localhost",
"Content-Type": "application/json",
};
+}
+
+describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration", () => {
+ const pool = new Pool({ connectionString: databaseURL });
+ const token = "identity-integration-session";
+ const headers = sessionHeaders(token);
+ const permissionReaderHeaders = sessionHeaders("permission-reader-session");
beforeAll(async () => {
await pool.query(
- `INSERT INTO "user" (id, name, email) VALUES ('integration-user', 'Test', 'test@identity.invalid')`,
+ `INSERT INTO "user" (id, name, email) VALUES
+ ('integration-user', 'Test', 'test@identity.invalid'),
+ ('permission-reader', 'Permission Reader', 'permission-reader@identity.invalid')`,
);
await pool.query(
- `INSERT INTO session (id, token, user_id, expires_at, updated_at) VALUES ('integration-session', $1, 'integration-user', NOW() + interval '1 hour', NOW())`,
+ `INSERT INTO session (id, token, user_id, expires_at, updated_at) VALUES
+ ('integration-session', $1, 'integration-user', NOW() + interval '1 hour', NOW()),
+ ('permission-reader-session', 'permission-reader-session', 'permission-reader', NOW() + interval '1 hour', NOW())`,
[token],
);
for (const [id, provider, subject] of [
@@ -37,11 +46,28 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
);
}
await pool.query(
- `INSERT INTO identity_evidence (issuer, subject, provider_id, state, valid_until, telegram_id) VALUES ('pn-entra', 'pn-subject', 'pn-entra', 'socio', NOW() + interval '1 hour', NULL), ('telegram', 'tg-subject', 'telegram', NULL, NOW() + interval '1 hour', '123456')`,
+ `INSERT INTO identity_evidence (issuer, subject, provider_id, states, valid_until, telegram_id) VALUES
+ ('pn-entra', 'pn-subject', 'pn-entra', ARRAY['socio']::text[], NOW() + interval '1 hour', NULL),
+ ('telegram', 'tg-subject', 'telegram', ARRAY[]::text[], NOW() + interval '1 hour', '123456')`,
+ );
+ await pool.query(
+ `INSERT INTO role (id, key, name) VALUES
+ ('integration-permission-reader-role', 'integration-permission-reader', 'Permission Reader')`,
+ );
+ await pool.query(
+ `INSERT INTO role_permission (role_id, permission_id)
+ SELECT 'integration-permission-reader-role', id
+ FROM permission
+ WHERE key = 'idp:permissions:read'`,
+ );
+ await pool.query(
+ `INSERT INTO user_role (user_id, role_id)
+ VALUES ('permission-reader', 'integration-permission-reader-role')`,
);
});
afterAll(async () => {
- await pool.query(`DELETE FROM "user" WHERE id = 'integration-user'`);
+ await pool.query(`DELETE FROM "user" WHERE id IN ('integration-user', 'permission-reader')`);
+ await pool.query(`DELETE FROM role WHERE id = 'integration-permission-reader-role'`);
await pool.query(
`DELETE FROM identity_evidence WHERE issuer IN ('pn-entra', 'telegram', 'https://mail.polimi.it')`,
);
@@ -111,10 +137,29 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
states: ["socio"],
+ roles: ["socio"],
permissions: ["membership:read"],
telegramId: "123456",
});
});
+ it("does not disclose the role graph to a permissions-only reader", async () => {
+ const response = await fetch(`${baseURL}/api/rbac/catalog`, {
+ headers: permissionReaderHeaders,
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ roles: [],
+ permissions: expect.arrayContaining([
+ expect.objectContaining({ key: "idp:permissions:read" }),
+ ]),
+ });
+
+ const members = await fetch(
+ `${baseURL}/api/rbac/role-members?role_id=integration-permission-reader-role`,
+ { headers: permissionReaderHeaders },
+ );
+ expect(members.status).toBe(403);
+ });
it("denies client registration to ordinary users", async () => {
const response = await fetch(`${baseURL}/api/auth/oauth2/create-client`, {
method: "POST",
@@ -148,6 +193,7 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
expect(response.status).toBe(200);
expect(await (await fetch(`${baseURL}/api/identity`, { headers })).json()).toEqual({
states: ["socio", "student"],
+ roles: ["socio", "student"],
permissions: ["membership:read", "student:verified"],
telegramId: "123456",
});
@@ -162,6 +208,7 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
expect((await unlink("integration-pn")).status).toBe(200);
expect(await (await fetch(`${baseURL}/api/identity`, { headers })).json()).toEqual({
states: ["student"],
+ roles: ["student"],
permissions: ["student:verified"],
telegramId: "123456",
});
diff --git a/src/auth/membership.test.ts b/src/auth/membership.test.ts
index 75f5845..d8b0ffc 100644
--- a/src/auth/membership.test.ts
+++ b/src/auth/membership.test.ts
@@ -77,6 +77,20 @@ describe("PN membership verification", () => {
expect(await checkPnGroupStates("member")).toEqual(["socio"]);
});
+ it("starts both configured group checks together", async () => {
+ const resolve: ((page: { value: { id: string }[] }) => void)[] = [];
+ mocks.get.mockImplementation(
+ () =>
+ new Promise<{ value: { id: string }[] }>((done) => {
+ resolve.push(done);
+ }),
+ );
+ const result = checkPnGroupStates("member");
+ expect(mocks.get).toHaveBeenCalledTimes(2);
+ for (const done of resolve) done({ value: [{ id: "member" }] });
+ await expect(result).resolves.toEqual(["socio", "direttivo"]);
+ });
+
it("does not cache failed checks for a full membership interval", () => {
const now = new Date("2026-09-07T00:00:00Z");
expect(membershipEvidence(null, now)).toEqual({ states: [], validUntil: now });
diff --git a/src/auth/membership.ts b/src/auth/membership.ts
index eb087b6..2dfdc34 100644
--- a/src/auth/membership.ts
+++ b/src/auth/membership.ts
@@ -74,13 +74,11 @@ export async function checkPnGroupStates(objectId: string): Promise Boolean(group.groupId));
- const states: string[] = [];
- for (const group of groups) {
- const member = await checkEntraGroupMember(group.groupId, objectId);
- if (member === null) return null;
- if (member) states.push(group.state);
- }
- return states;
+ const memberships = await Promise.all(
+ groups.map((group) => checkEntraGroupMember(group.groupId, objectId)),
+ );
+ if (memberships.some((member) => member === null)) return null;
+ return groups.flatMap((group, index) => (memberships[index] ? [group.state] : []));
}
/**
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index cea4971..54b2574 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -389,8 +389,7 @@ export async function listRoleMembers(roleId: string): Promise {
.from(userRole)
.innerJoin(user, eq(user.id, userRole.userId))
.where(eq(userRole.roleId, roleId))
- .orderBy(desc(userRole.assignedAt))
- .limit(500);
+ .orderBy(desc(userRole.assignedAt));
return rows.map((row) => ({ ...row, assignedAt: row.assignedAt?.toISOString() ?? null }));
}
diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts
index bec0b00..35bcfee 100644
--- a/src/auth/rbac.test.ts
+++ b/src/auth/rbac.test.ts
@@ -6,6 +6,7 @@ import {
MANAGED_PERMISSIONS,
MASTER_ADMIN_ROLE_KEY,
STATIC_ROLES,
+ catalogForIdpPermissions,
effectiveRolePermissions,
expandPermissionKeys,
expandRoleKeys,
@@ -318,3 +319,16 @@ describe("permissions the identity provider defines itself", () => {
).toBeTruthy();
});
});
+
+describe("administration catalog visibility", () => {
+ it("does not disclose roles to someone who may only read permissions", () => {
+ const visible = catalogForIdpPermissions(catalog, ["idp:permissions:read"]);
+ expect(visible.permissions).toBe(catalog.permissions);
+ expect(visible.roles).toEqual([]);
+ });
+
+ it("keeps the permission graph available when explaining roles", () => {
+ const visible = catalogForIdpPermissions(catalog, ["idp:roles:read"]);
+ expect(visible).toEqual(catalog);
+ });
+});
diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts
index 6e88691..e8e6ddf 100644
--- a/src/auth/rbac.ts
+++ b/src/auth/rbac.ts
@@ -18,7 +18,7 @@ export const STATIC_ROLES = [
name: "Master Admin",
description: "Complete control of this identity provider.",
evidence:
- "Configured outside the database, through IDP_ADMIN_USER_IDS or the PoliNetwork Entra administrators group, so the service can never be locked out of its own administration.",
+ "Configured outside the database through IDP_ADMIN_USER_IDS or the PoliNetwork Entra administrators group. When no group is configured, every linked PoliNetwork Entra account holds it, preserving the previous administration policy.",
},
{
key: "socio",
@@ -175,6 +175,18 @@ export type RbacCatalog = { roles: RoleSummary[]; permissions: PermissionSummary
export const emptyCatalog: RbacCatalog = { roles: [], permissions: [] };
+/**
+ * Removes role metadata when the caller may inspect permissions but not roles. Role readers
+ * still need the permission graph to understand what each role grants, even when an
+ * administrator removes the default `idp:roles:read -> idp:permissions:read` implication.
+ */
+export function catalogForIdpPermissions(
+ catalog: RbacCatalog,
+ permissions: readonly string[],
+): RbacCatalog {
+ return permissions.includes("idp:roles:read") ? catalog : { ...catalog, roles: [] };
+}
+
type CatalogIndex = {
roles: Map;
permissions: Map;
diff --git a/src/components/oidc/client-form.tsx b/src/components/oidc/client-form.tsx
index 6bda1e5..63f1d53 100644
--- a/src/components/oidc/client-form.tsx
+++ b/src/components/oidc/client-form.tsx
@@ -31,6 +31,8 @@ type ClientFormProps = {
/** Confidential clients receive a secret. Only selectable when creating. */
confidential?: boolean;
onConfidentialChange?: (confidential: boolean) => void;
+ /** Shows the registered settings without offering controls that will be rejected. */
+ readOnly?: boolean;
busy: boolean;
serverErrors?: OidcClientDraftErrors;
submitLabel: string;
@@ -206,6 +208,7 @@ export function ClientForm({
initial,
confidential = true,
onConfidentialChange,
+ readOnly = false,
busy,
serverErrors,
submitLabel,
@@ -509,17 +512,19 @@ export function ClientForm({
)}
-
- {onCancel && (
-
- Cancel
+ {!readOnly && (
+
+ {onCancel && (
+
+ Cancel
+
+ )}
+
+ {busy && }
+ {submitLabel}
- )}
-
- {busy && }
- {submitLabel}
-
-
+
+ )}
);
}
diff --git a/src/components/rbac/require-permission.tsx b/src/components/rbac/require-permission.tsx
index 87bcee2..d2ba08f 100644
--- a/src/components/rbac/require-permission.tsx
+++ b/src/components/rbac/require-permission.tsx
@@ -12,9 +12,13 @@ import { Button } from "@/components/ui/button";
export function RequirePermission({
permission,
children,
+ backTo = "/access",
+ backLabel = "Back to access administration",
}: {
permission: ManagedPermissionKey;
children: ReactNode;
+ backTo?: "/access" | "/applications";
+ backLabel?: string;
}) {
const { can } = useIdpAccessContext();
if (can(permission)) return children;
@@ -30,9 +34,9 @@ export function RequirePermission({
-
+
- Back to access administration
+ {backLabel}
diff --git a/src/routes/access/permissions/$permissionId.tsx b/src/routes/access/permissions/$permissionId.tsx
index 52284eb..0c8509c 100644
--- a/src/routes/access/permissions/$permissionId.tsx
+++ b/src/routes/access/permissions/$permissionId.tsx
@@ -11,12 +11,13 @@ import {
} from "@/components/rbac/api";
import { KeyChip } from "@/components/rbac/fields";
import { PermissionForm } from "@/components/rbac/permission-form";
+import { RequirePermission } from "@/components/rbac/require-permission";
import { useCatalog } from "@/components/rbac/use-catalog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const Route = createFileRoute("/access/permissions/$permissionId")({
- component: PermissionDetail,
+ component: GuardedPermissionDetail,
});
function PermissionDetail() {
@@ -148,38 +149,40 @@ function PermissionDetail() {
-
-
- Roles that grant it
-
- Change these from each role's page. Roles inheriting from one of these grant it too.
-
-
-
- {grantedBy.length === 0 ? (
-
- No role grants this permission yet, so nobody holds it.{" "}
- {permission.managed &&
- "Whoever the deployment configures as an administrator holds it anyway, through Master Admin."}
-
- ) : (
-
- {grantedBy.map((role) => (
-
-
- {role.name}
- {role.key}
-
-
- ))}
-
- )}
-
-
+ {can("idp:roles:read") && (
+
+
+ Roles that grant it
+
+ Change these from each role's page. Roles inheriting from one of these grant it too.
+
+
+
+ {grantedBy.length === 0 ? (
+
+ No role grants this permission yet, so nobody holds it.{" "}
+ {permission.managed &&
+ "Whoever the deployment configures as an administrator holds it anyway, through Master Admin."}
+
+ ) : (
+
+ {grantedBy.map((role) => (
+
+
+ {role.name}
+ {role.key}
+
+
+ ))}
+
+ )}
+
+
+ )}
{!permission.managed && canWrite && (
@@ -201,3 +204,11 @@ function PermissionDetail() {
);
}
+
+function GuardedPermissionDetail() {
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/access/permissions/index.tsx b/src/routes/access/permissions/index.tsx
index c31fcb3..26fcebe 100644
--- a/src/routes/access/permissions/index.tsx
+++ b/src/routes/access/permissions/index.tsx
@@ -3,12 +3,15 @@ import { ChevronRight, KeyRound, Plus, ShieldCheck, Sparkles } from "lucide-reac
import { type PermissionSummary, expandPermissionKeys } from "@/auth/rbac";
import { useIdpAccessContext } from "@/components/idp-access";
import { KeyChip } from "@/components/rbac/fields";
+import { RequirePermission } from "@/components/rbac/require-permission";
import { useCatalog } from "@/components/rbac/use-catalog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-export const Route = createFileRoute("/access/permissions/")({ component: PermissionsIndex });
+export const Route = createFileRoute("/access/permissions/")({
+ component: GuardedPermissionsIndex,
+});
function PermissionRow({
permission,
@@ -170,3 +173,11 @@ function PermissionsIndex() {
);
}
+
+function GuardedPermissionsIndex() {
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/api/rbac/catalog.ts b/src/routes/api/rbac/catalog.ts
index d52e24b..b428556 100644
--- a/src/routes/api/rbac/catalog.ts
+++ b/src/routes/api/rbac/catalog.ts
@@ -1,14 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
-import { noStore, requireIdpPermission } from "@/auth/api-guard";
+import { noStore, requireAnyIdpPermission } from "@/auth/api-guard";
+import { catalogForIdpPermissions } from "@/auth/rbac";
import { loadCatalog } from "@/auth/rbac-store";
export const Route = createFileRoute("/api/rbac/catalog")({
server: {
handlers: {
GET: async ({ request }) => {
- const guard = await requireIdpPermission(request, "idp:permissions:read");
+ const guard = await requireAnyIdpPermission(request, [
+ "idp:permissions:read",
+ "idp:roles:read",
+ ]);
if ("response" in guard) return guard.response;
- return Response.json(await loadCatalog(), { headers: noStore });
+ const catalog = catalogForIdpPermissions(await loadCatalog(), guard.session.permissions);
+ return Response.json(catalog, { headers: noStore });
},
},
},
diff --git a/src/routes/applications/$clientId.tsx b/src/routes/applications/$clientId.tsx
index b11dfd4..86c4273 100644
--- a/src/routes/applications/$clientId.tsx
+++ b/src/routes/applications/$clientId.tsx
@@ -19,6 +19,7 @@ import {
type OidcClientSummary,
} from "@/auth/oidc-clients";
import { CopyButton } from "@/components/copy-button";
+import { useIdpAccessContext } from "@/components/idp-access";
import { ApiError, errorMessage, fetchOidcClients, saveOidcClient } from "@/components/oidc/api";
import { AppLogo } from "@/components/oidc/app-logo";
import { ClientForm } from "@/components/oidc/client-form";
@@ -78,6 +79,8 @@ function ToggleRow({
function ApplicationDetail() {
const { clientId } = Route.useParams();
const navigate = useNavigate();
+ const { can } = useIdpAccessContext();
+ const canWrite = can("idp:applications:write");
const [client, setClient] = useState(null);
const [notFound, setNotFound] = useState(false);
const [loadError, setLoadError] = useState("");
@@ -309,44 +312,49 @@ function ApplicationDetail() {
- void save(draft)}
- />
+
+ void save(draft)}
+ />
+
-
-
-
-
- Danger zone
-
-
- Deleting removes the client, every consent, and all of its tokens. People who used
- it will have to be set up again in a new application.
-
-
-
-
- Prefer a pause? Disable the application instead; it can be re-enabled anytime.
-
- setDeleteOpen(true)}
- >
-
- Delete application
-
-
-
+ {canWrite && (
+
+
+
+
+ Danger zone
+
+
+ Deleting removes the client, every consent, and all of its tokens. People who used
+ it will have to be set up again in a new application.
+
+
+
+
+ Prefer a pause? Disable the application instead; it can be re-enabled anytime.
+
+ setDeleteOpen(true)}
+ >
+
+ Delete application
+
+
+
+ )}
@@ -380,15 +388,17 @@ function ApplicationDetail() {
••••••••••••••••
- setRotateOpen(true)}
- >
-
- Rotate
-
+ {canWrite && (
+ setRotateOpen(true)}
+ >
+
+ Rotate
+
+ )}
Secrets are stored hashed and cannot be shown again. Rotating issues a new one
@@ -421,7 +431,7 @@ function ApplicationDetail() {
: "People can sign in through this application."
}
checked={!client.disabled}
- disabled={busy !== null}
+ disabled={!canWrite || busy !== null}
onCheckedChange={(checked) => void toggle({ disabled: !checked })}
/>
void toggle({ skipConsent: checked })}
/>
diff --git a/src/routes/applications/index.tsx b/src/routes/applications/index.tsx
index f3aefab..7512ae6 100644
--- a/src/routes/applications/index.tsx
+++ b/src/routes/applications/index.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { AppWindow, ChevronRight, Plus, Users } from "lucide-react";
import type { OidcClientSummary } from "@/auth/oidc-clients";
import { CopyButton } from "@/components/copy-button";
+import { useIdpAccessContext } from "@/components/idp-access";
import { errorMessage, fetchOidcClients } from "@/components/oidc/api";
import { AppLogo } from "@/components/oidc/app-logo";
import { Badge } from "@/components/ui/badge";
@@ -72,6 +73,8 @@ function IntegrationCard() {
}
function ApplicationsIndex() {
+ const { can } = useIdpAccessContext();
+ const canWrite = can("idp:applications:write");
const [clients, setClients] = useState(null);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
@@ -97,12 +100,14 @@ function ApplicationsIndex() {
Services that sign people in with PoliNetwork Identity through OpenID Connect.
-
-
-
- New application
-
-
+ {canWrite && (
+
+
+
+ New application
+
+
+ )}
{error && (
@@ -138,12 +143,14 @@ function ApplicationsIndex() {
get a client ID and, for confidential apps, a secret.
-
-
-
- New application
-
-
+ {canWrite && (
+
+
+
+ New application
+
+
+ )}
) : clients ? (
diff --git a/src/routes/applications/new.tsx b/src/routes/applications/new.tsx
index 7825c8d..e0c4f56 100644
--- a/src/routes/applications/new.tsx
+++ b/src/routes/applications/new.tsx
@@ -11,12 +11,13 @@ import {
import { errorMessage } from "@/components/oidc/api";
import { ClientForm } from "@/components/oidc/client-form";
import { CredentialsReveal } from "@/components/oidc/secret-reveal";
+import { RequirePermission } from "@/components/rbac/require-permission";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export const Route = createFileRoute("/applications/new")({
head: () => ({ meta: [{ title: "New application · PoliNetwork Auth" }] }),
- component: NewApplication,
+ component: GuardedNewApplication,
});
type Created = { clientId: string; clientSecret: string | null; name: string };
@@ -149,3 +150,15 @@ function NewApplication() {
);
}
+
+function GuardedNewApplication() {
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/applications/route.tsx b/src/routes/applications/route.tsx
index 147345c..d239ff7 100644
--- a/src/routes/applications/route.tsx
+++ b/src/routes/applications/route.tsx
@@ -4,7 +4,7 @@ import type { OidcAdminPolicy } from "@/auth/oidc-admin";
import { authClient } from "@/auth/client";
import { AppHeader } from "@/components/app-header";
import { LoginLayout, LoginPage } from "@/components/login-page";
-import { useIdpAccess } from "@/components/idp-access";
+import { IdpAccessProvider, useIdpAccess } from "@/components/idp-access";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/applications")({
@@ -68,7 +68,9 @@ function ApplicationsLayout() {
{access.status === "ready" && access.can("idp:applications:read") ? (
-
+
+
+
) : access.status === "ready" ? (
) : access.status === "error" ? (
From 36505b06de3cce68dd94608daa2b8b4c7e2eeeb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 13:01:41 +0000
Subject: [PATCH 02/15] fix: deny wildcard bootstrap without explicit admin
configuration
---
.env.example | 6 ++---
README.md | 18 ++++++-------
scripts/security-config.d.mts | 3 +++
scripts/security-config.mjs | 46 ++++++++++++++++++++++++++++++++
scripts/start.mjs | 4 +++
src/auth/oidc-admin.test.ts | 4 +--
src/auth/oidc-admin.ts | 10 +++----
src/auth/rbac.ts | 2 +-
src/auth/security-config.test.ts | 39 +++++++++++++++++++++++++++
src/env.ts | 6 ++++-
src/routes/access/route.tsx | 2 +-
11 files changed, 118 insertions(+), 22 deletions(-)
create mode 100644 scripts/security-config.d.mts
create mode 100644 scripts/security-config.mjs
create mode 100644 src/auth/security-config.test.ts
diff --git a/.env.example b/.env.example
index 72d9a06..e2e0682 100644
--- a/.env.example
+++ b/.env.example
@@ -39,9 +39,9 @@ PN_ENTRA_MEMBER_REFRESH_HOURS=24
AZURE_EMAIL_SENDER=noreply@polinetwork.org
STUDENT_VERIFICATION_TTL_DAYS=365
-# Who holds the built-in Master Admin role, which carries every permission. By default
-# every signed-in PN Entra account does. Set this to a stricter Microsoft Entra group
-# (object ID) to limit it to that group's direct members; the PN_ENTRA app checks it
+# Who holds the built-in Master Admin role, which carries every permission. Configure
+# this Microsoft Entra administrators group (object ID) or a nonempty allowlist below.
+# Missing both stops startup. Group membership is verified by the PN_ENTRA app
# through Graph. Everyone else is administered through roles at /access.
# PN_ENTRA_OIDC_ADMIN_GROUP_ID=
# Comma-separated local user IDs that are always Master Admin (break-glass).
diff --git a/README.md b/README.md
index c7ef0a7..108e78f 100644
--- a/README.md
+++ b/README.md
@@ -62,12 +62,12 @@ inherit from each other or two permissions grant each other.
Four roles always exist and are never created, deleted, or handed out by an administrator.
Their membership is conferred by the identity provider itself:
-| Role | Key | Granted by |
-| -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- |
-| `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, or any PN Entra account when no group is set |
-| `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID |
-| `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID |
-| `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address |
+| Role | Key | Granted by |
+| -------------- | -------------- | --------------------------------------------------------------------------------------------------- |
+| `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, never an unconfigured fallback |
+| `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID |
+| `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID |
+| `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address |
**Master Admin holds every permission that exists**, including ones created after it was
last looked at, because it is a wildcard rather than a stored list. It therefore has no
@@ -77,8 +77,8 @@ Unlike the other three it is not proven by identity evidence and never appears a
`states`: it comes from the deployment's own configuration, which is what keeps the service
from being locked out of its own administration. `IDP_ADMIN_USER_IDS` is always honored.
Set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to limit everyone else to that Microsoft Entra group.
-If the group is unset, every linked PN Entra account holds Master Admin, preserving the
-service's previous OIDC administration rule.
+If the group is unset, only the explicit allowlist can confer Master Admin. Startup fails
+without either an admin group plus complete PN Entra credentials or a nonempty allowlist.
What the other three grant is still yours to choose: give them permissions, rename them,
describe them, and place them in the hierarchy like any other role. Only their key, their
@@ -179,7 +179,7 @@ spaces between values and are empty strings when no values apply, as is
`polinetwork_telegram_id` when no Telegram account is linked. The `/api/identity` response
keeps the object format shown inside the URL-named claim.
-Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it, and by default anyone signed in with a PoliNetwork Entra account (the `pn-entra` provider, verified against `PN_ENTRA_TENANT_ID`) is a Master Admin. To restrict that to a stricter Microsoft 365 group than Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for 15 minutes per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself.
+Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for 15 minutes per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself.
Dynamic registration and client-credentials grants are disabled. Administrators manage clients at `/applications`: create web or native apps as confidential (secret shown once) or public (PKCE only) clients, edit redirect URIs and allowed scopes, rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules: web apps need `https` on a public host, native apps may use `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme. Custom routes under `/api/oidc/` back the pages; creation, deletion, and secret rotation go through the Better Auth client endpoints, which enforce the same administrator check.
diff --git a/scripts/security-config.d.mts b/scripts/security-config.d.mts
new file mode 100644
index 0000000..d18314e
--- /dev/null
+++ b/scripts/security-config.d.mts
@@ -0,0 +1,3 @@
+export function validateSecurityConfiguration(
+ environment: Record,
+): void;
diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs
new file mode 100644
index 0000000..a36f40e
--- /dev/null
+++ b/scripts/security-config.mjs
@@ -0,0 +1,46 @@
+import { z } from "zod";
+
+const optional = (schema) =>
+ z.preprocess((value) => (value === "" ? undefined : value), schema.optional());
+const schema = z
+ .object({
+ PN_ENTRA_TENANT_ID: optional(z.uuid()),
+ PN_ENTRA_CLIENT_ID: optional(z.string().trim().min(1)),
+ PN_ENTRA_CLIENT_SECRET: optional(z.string().trim().min(1)),
+ PN_ENTRA_OIDC_ADMIN_GROUP_ID: optional(z.uuid()),
+ IDP_ADMIN_USER_IDS: z
+ .string()
+ .default("")
+ .transform((value) => (value.trim() === "" ? [] : value.split(",").map((id) => id.trim())))
+ .pipe(z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/))),
+ })
+ .superRefine((config, context) => {
+ const credentials = [
+ config.PN_ENTRA_TENANT_ID,
+ config.PN_ENTRA_CLIENT_ID,
+ config.PN_ENTRA_CLIENT_SECRET,
+ ];
+ if (
+ (credentials.some(Boolean) || config.PN_ENTRA_OIDC_ADMIN_GROUP_ID) &&
+ !credentials.every(Boolean)
+ )
+ context.addIssue({
+ code: "custom",
+ message: "PN Entra requires tenant, client ID and client secret together.",
+ });
+ if (!config.PN_ENTRA_OIDC_ADMIN_GROUP_ID && config.IDP_ADMIN_USER_IDS.length === 0)
+ context.addIssue({
+ code: "custom",
+ message:
+ "Configure PN_ENTRA_OIDC_ADMIN_GROUP_ID or a nonempty IDP_ADMIN_USER_IDS break-glass allowlist.",
+ });
+ });
+
+/** Validate before migrations or serving requests; never include configuration values in errors. */
+export function validateSecurityConfiguration(environment) {
+ const result = schema.safeParse(environment);
+ if (!result.success)
+ throw new Error(
+ `Invalid security configuration: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
+ );
+}
diff --git a/scripts/start.mjs b/scripts/start.mjs
index d376a08..101daa6 100644
--- a/scripts/start.mjs
+++ b/scripts/start.mjs
@@ -1,5 +1,9 @@
import { fileURLToPath } from "node:url";
+import { validateSecurityConfiguration } from "./security-config.mjs";
+
+validateSecurityConfiguration(process.env);
+
import { migrateDatabase } from "./migrate.mjs";
function requiredEnvironmentVariable(name) {
diff --git a/src/auth/oidc-admin.test.ts b/src/auth/oidc-admin.test.ts
index 644150b..553c245 100644
--- a/src/auth/oidc-admin.test.ts
+++ b/src/auth/oidc-admin.test.ts
@@ -7,7 +7,7 @@ vi.mock("./membership", () => ({ checkEntraGroupMember: vi.fn() }));
import { createGroupMembershipCache, decideOidcAdmin } from "./oidc-admin";
describe("OIDC administrator policy", () => {
- it("lets any PN Entra account manage clients until a stricter group is configured", () => {
+ it("denies a linked PN Entra account when no administrator group is configured", () => {
expect(
decideOidcAdmin({
allowlisted: false,
@@ -15,7 +15,7 @@ describe("OIDC administrator policy", () => {
groupConfigured: false,
groupMember: false,
}),
- ).toBe(true);
+ ).toBe(false);
});
it("requires membership of the stricter group once configured", () => {
diff --git a/src/auth/oidc-admin.ts b/src/auth/oidc-admin.ts
index 6db2ac7..4bdcf8d 100644
--- a/src/auth/oidc-admin.ts
+++ b/src/auth/oidc-admin.ts
@@ -7,19 +7,19 @@ import { checkEntraGroupMember } from "./membership";
/**
* Who holds the built-in Master Admin role, and through it every permission.
- * - `pn-entra`: anyone who signed in with a PoliNetwork Entra account (the initial rule).
+ * - `allowlist`: only explicitly configured local user IDs.
* - `entra-group`: only direct members of `PN_ENTRA_OIDC_ADMIN_GROUP_ID`, a stricter group
- * than Soci. Set that variable to switch without code changes.
+ * than Soci. Missing group configuration never grants membership.
* `IDP_ADMIN_USER_IDS` remains a break-glass allowlist in both modes.
*
* This is the one decision that is deliberately made outside the database, so a mistake in
* the roles cannot lock the service out of its own administration. Every other
* administrative right is an ordinary permission resolved through RBAC.
*/
-export type OidcAdminPolicy = "pn-entra" | "entra-group";
+export type OidcAdminPolicy = "allowlist" | "entra-group";
export function oidcAdminPolicy(): OidcAdminPolicy {
- return env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? "entra-group" : "pn-entra";
+ return env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? "entra-group" : "allowlist";
}
export type OidcAdminDecisionInput = {
@@ -32,7 +32,7 @@ export type OidcAdminDecisionInput = {
export function decideOidcAdmin(input: OidcAdminDecisionInput): boolean {
if (input.allowlisted) return true;
if (!input.pnEntraAccount) return false;
- if (!input.groupConfigured) return true;
+ if (!input.groupConfigured) return false;
return input.groupMember;
}
diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts
index e8e6ddf..76a5bd2 100644
--- a/src/auth/rbac.ts
+++ b/src/auth/rbac.ts
@@ -18,7 +18,7 @@ export const STATIC_ROLES = [
name: "Master Admin",
description: "Complete control of this identity provider.",
evidence:
- "Configured outside the database through IDP_ADMIN_USER_IDS or the PoliNetwork Entra administrators group. When no group is configured, every linked PoliNetwork Entra account holds it, preserving the previous administration policy.",
+ "Configured outside the database through IDP_ADMIN_USER_IDS or the PoliNetwork Entra administrators group. Missing group configuration never grants access.",
},
{
key: "socio",
diff --git a/src/auth/security-config.test.ts b/src/auth/security-config.test.ts
new file mode 100644
index 0000000..f35c404
--- /dev/null
+++ b/src/auth/security-config.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vite-plus/test";
+import { validateSecurityConfiguration } from "../../scripts/security-config.mjs";
+
+describe("security configuration startup validation", () => {
+ it.each([undefined, "", " ", ",", "root,", "root,,other", "*", "root user"])(
+ "refuses absent or malformed bootstrap allowlist %j",
+ (value) => {
+ expect(() => validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: value })).toThrow();
+ },
+ );
+ it("denies bootstrap for a configured tenant without an admin group", () => {
+ expect(() =>
+ validateSecurityConfiguration({
+ PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111",
+ PN_ENTRA_CLIENT_ID: "app",
+ PN_ENTRA_CLIENT_SECRET: "secret",
+ }),
+ ).toThrow();
+ });
+ it("refuses malformed groups and incomplete Graph credentials even with a break-glass user", () => {
+ expect(() =>
+ validateSecurityConfiguration({
+ IDP_ADMIN_USER_IDS: "root",
+ PN_ENTRA_OIDC_ADMIN_GROUP_ID: "bad",
+ }),
+ ).toThrow();
+ expect(() =>
+ validateSecurityConfiguration({
+ IDP_ADMIN_USER_IDS: "root",
+ PN_ENTRA_OIDC_ADMIN_GROUP_ID: "11111111-1111-4111-8111-111111111111",
+ }),
+ ).toThrow();
+ });
+ it("accepts explicit break-glass configuration", () => {
+ expect(() =>
+ validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root, another-user" }),
+ ).not.toThrow();
+ });
+});
diff --git a/src/env.ts b/src/env.ts
index 99aec28..43de37d 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -1,6 +1,10 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
+import { validateSecurityConfiguration } from "../scripts/security-config.mjs";
+
+validateSecurityConfiguration(process.env);
+
export const env = createEnv({
server: {
DB_HOST: z.string().min(1).default("localhost"),
@@ -22,7 +26,7 @@ export const env = createEnv({
PN_ENTRA_MEMBER_REFRESH_HOURS: z.coerce.number().int().positive().default(24),
// Optional stricter Entra group whose direct members hold the built-in Master Admin
// role, and through it every permission.
- // Unset: every linked PN Entra account is a Master Admin.
+ // Unset: only the explicit break-glass allowlist can hold Master Admin.
PN_ENTRA_OIDC_ADMIN_GROUP_ID: z.uuid().optional(),
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
diff --git a/src/routes/access/route.tsx b/src/routes/access/route.tsx
index 8c73e23..28005db 100644
--- a/src/routes/access/route.tsx
+++ b/src/routes/access/route.tsx
@@ -26,7 +26,7 @@ function NoAccess({ policy }: { policy: OidcAdminPolicy | null }) {
{policy === "entra-group"
? "Editing roles and permissions is limited to members of the PoliNetwork Entra administrators group. Ask an administrator to add your PoliNetwork Microsoft account."
- : "Editing roles and permissions requires a PoliNetwork Microsoft account. Link your PoliNetwork APS account from your account page, then come back here."}
+ : "Access requires explicitly delegated permissions. Ask an administrator to grant the appropriate role."}
From 8e7c7b6f1af893df19aad4bf6c8b997806c77a35 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 13:02:28 +0000
Subject: [PATCH 03/15] fix: reject legacy inherited Master Admin wildcards
during resolution
---
src/auth/rbac.test.ts | 7 +++----
src/auth/rbac.ts | 8 +++++---
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts
index 35bcfee..e7d7267 100644
--- a/src/auth/rbac.test.ts
+++ b/src/auth/rbac.test.ts
@@ -234,14 +234,13 @@ describe("Master Admin", () => {
).toBeTruthy();
});
- it("still honours an inheriting edge left in the database by an older version", () => {
+ it("denies a wildcard inherited through an edge left by an older version", () => {
const deputy: RbacCatalog = {
...withMaster,
roles: [...withMaster.roles, role("deputy", [], [MASTER_ADMIN_ROLE_KEY])],
};
- expect(resolveAccess(deputy, ["deputy"]).permissions).toEqual(
- resolveAccess(withMaster, [MASTER_ADMIN_ROLE_KEY]).permissions,
- );
+ expect(resolveAccess(deputy, ["deputy"]).permissions).toEqual([]);
+ expect(resolveAccess(deputy, ["deputy"]).roles).not.toContain(MASTER_ADMIN_ROLE_KEY);
});
it("leaves ordinary parents alone", () => {
diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts
index 76a5bd2..fbd2a7a 100644
--- a/src/auth/rbac.ts
+++ b/src/auth/rbac.ts
@@ -219,7 +219,9 @@ function closure(start: Iterable, edges: (key: string) => readonly strin
/** The given roles plus every role they inherit from, transitively. */
export function expandRoleKeys(catalog: RbacCatalog, roleKeys: Iterable): string[] {
const index = indexCatalog(catalog);
- const reachable = closure(roleKeys, (key) => index.roles.get(key)?.parents ?? []);
+ const reachable = closure(roleKeys, (key) =>
+ (index.roles.get(key)?.parents ?? []).filter((parent) => parent !== MASTER_ADMIN_ROLE_KEY),
+ );
return [...reachable].filter((key) => index.roles.has(key)).sort();
}
@@ -247,8 +249,8 @@ export function grantsAllPermissions(roleKeys: readonly string[]) {
*
* Master Admin is a wildcard rather than a stored list, so it keeps covering permissions
* created after it was last edited. Nothing may inherit from it (see `validateRoleDraft`),
- * but the wildcard is still honoured through the expanded role set so that an edge left in
- * the database by an older version cannot quietly grant less than it appears to.
+ * and resolution ignores legacy inheritance edges to it as well. Only a directly conferred
+ * Master Admin role activates the wildcard.
*/
export function resolveAccess(catalog: RbacCatalog, roleKeys: Iterable): ResolvedAccess {
const index = indexCatalog(catalog);
From 6f5bc6b480478265fc298cbe2cfb5968b60210de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 13:11:04 +0000
Subject: [PATCH 04/15] fix: resolve fresh tenant-bound evidence and atomic
RBAC snapshots
---
README.md | 14 ++--
src/auth/identity-subject.test.ts | 51 ++++++++++++
src/auth/identity-subject.ts | 66 +++++++++++++++
src/auth/identity.ts | 72 +---------------
src/auth/oidc-admin.test.ts | 49 +++++++++++
src/auth/oidc-admin.ts | 32 ++++---
src/auth/rbac-store.ts | 133 ++++++------------------------
src/routes/applications/route.tsx | 2 +-
8 files changed, 222 insertions(+), 197 deletions(-)
create mode 100644 src/auth/identity-subject.test.ts
create mode 100644 src/auth/identity-subject.ts
diff --git a/README.md b/README.md
index 108e78f..26565c6 100644
--- a/README.md
+++ b/README.md
@@ -88,9 +88,9 @@ grants `membership:read` and `student` grants `student:verified`.
`PN_ENTRA_DIRETTIVO_GROUP_ID` is optional and has no default. Until you set it to the
board's Entra group object ID, nobody is inferred as Direttivo. Both group checks reuse the
-`PN_ENTRA_*` Graph credentials, run together on sign-in, and are rechecked after
-`PN_ENTRA_MEMBER_REFRESH_HOURS`. If either check fails, the evidence expires immediately
-rather than being cached as a confirmed loss of membership, and the next request retries.
+`PN_ENTRA_*` Graph credentials. Authorization rechecks membership with a fixed 60-second
+cache measured from lookup start. Stored sign-in evidence and `PN_ENTRA_MEMBER_REFRESH_HOURS`
+do not extend authorization. Failed or overlong checks grant nothing.
Membership of the built-in roles is not a role assignment: nothing is written to
`user_role` for them, and evidence contributes only when joined to an account owned by the
@@ -143,8 +143,10 @@ Deleting a role removes it from everyone who held it and from every role that in
Changes take effect on the next token. Already-issued OIDC tokens expire after five
minutes, so consumers must account for that revocation delay; `/api/identity` and UserInfo
-compute current access on each request. Each replica caches the role graph for 15 seconds,
-so a change made on one replica reaches the others within that window.
+compute current access on each request. The role graph and assignments are read from one committed snapshot without a catalog
+cache. Requests starting after a database revocation commits see it. Group removal takes
+at most 60 seconds to affect new authorization decisions (subject to Graph propagation);
+a token issued just before expiry can remain valid for another five minutes.
A linked Telegram identity grants no role and no permission. Existing backend Telegram
roles and group assignments remain authoritative and are not copied or queried here.
@@ -179,7 +181,7 @@ spaces between values and are empty strings when no values apply, as is
`polinetwork_telegram_id` when no Telegram account is linked. The `/api/identity` response
keeps the object format shown inside the URL-named claim.
-Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for 15 minutes per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself.
+Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for at most 60 seconds from lookup start per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself.
Dynamic registration and client-credentials grants are disabled. Administrators manage clients at `/applications`: create web or native apps as confidential (secret shown once) or public (PKCE only) clients, edit redirect URIs and allowed scopes, rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules: web apps need `https` on a public host, native apps may use `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme. Custom routes under `/api/oidc/` back the pages; creation, deletion, and secret rotation go through the Better Auth client endpoints, which enforce the same administrator check.
diff --git a/src/auth/identity-subject.test.ts b/src/auth/identity-subject.test.ts
new file mode 100644
index 0000000..036dce1
--- /dev/null
+++ b/src/auth/identity-subject.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+const mocks = vi.hoisted(() => ({ check: vi.fn() }));
+vi.mock("../env", () => ({
+ env: { PN_ENTRA_TENANT_ID: "tenant", PN_ENTRA_MEMBER_GROUP_ID: "soci" },
+}));
+vi.mock("../db/index", () => ({ db: {} }));
+vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.check }));
+vi.mock("./oidc-admin", () => ({
+ canAdministerIdp: async () => false,
+ createGroupMembershipCache: (check: unknown) => check,
+}));
+import { readIdentitySubject, type IdentityReader } from "./identity-subject";
+
+function reader(proofs: unknown[], exists = true): IdentityReader {
+ return {
+ select: vi
+ .fn()
+ .mockReturnValueOnce({
+ from: () => ({ where: async () => (exists ? [{ id: "user" }] : []) }),
+ })
+ .mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: async () => proofs }) }) }),
+ } as unknown as IdentityReader;
+}
+const proof = {
+ issuer: "https://login.microsoftonline.com/tenant/v2.0",
+ providerId: "pn-entra",
+ externalId: "member",
+ states: ["socio"],
+ validUntil: new Date(Date.now() + 86_400_000),
+ telegramId: null,
+};
+
+describe("authorization evidence trust and freshness", () => {
+ it.each([false, null])(
+ "denies stored unexpired membership when live verification returns %s",
+ async (result) => {
+ mocks.check.mockResolvedValue(result);
+ expect((await readIdentitySubject("user", reader([proof]))).roleKeys).toEqual([]);
+ },
+ );
+ it("denies another tenant's evidence even if membership checks would pass", async () => {
+ mocks.check.mockResolvedValue(true);
+ expect(
+ (await readIdentitySubject("user", reader([{ ...proof, issuer: "https://foreign.invalid" }])))
+ .roleKeys,
+ ).toEqual([]);
+ });
+ it("denies missing subjects before considering any evidence", async () => {
+ await expect(readIdentitySubject("deleted-user", reader([proof], false))).rejects.toThrow();
+ });
+});
diff --git a/src/auth/identity-subject.ts b/src/auth/identity-subject.ts
new file mode 100644
index 0000000..381e893
--- /dev/null
+++ b/src/auth/identity-subject.ts
@@ -0,0 +1,66 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "../db/index";
+import { account, identityEvidence, user } from "../db/schema";
+import { env } from "../env";
+import { identityStates } from "./policy";
+import { checkEntraGroupMember } from "./membership";
+import { canAdministerIdp, createGroupMembershipCache } from "./oidc-admin";
+import { MASTER_ADMIN_ROLE_KEY, staticRolesForStates } from "./rbac";
+
+export type IdentityReader = Pick;
+export const MEMBERSHIP_CACHE_MS = 60_000;
+const member = createGroupMembershipCache(checkEntraGroupMember, MEMBERSHIP_CACHE_MS);
+
+/** The subject is always a persisted user; evidence must match the configured issuer. */
+export async function readIdentitySubject(userId: string, reader: IdentityReader) {
+ const [subject] = await reader.select({ id: user.id }).from(user).where(eq(user.id, userId));
+ if (!subject) throw new Error("Unknown identity subject.");
+ const proofs = await reader
+ .select({
+ issuer: account.issuer,
+ providerId: identityEvidence.providerId,
+ externalId: identityEvidence.externalId,
+ states: identityEvidence.states,
+ validUntil: identityEvidence.validUntil,
+ telegramId: identityEvidence.telegramId,
+ })
+ .from(account)
+ .innerJoin(
+ identityEvidence,
+ and(
+ eq(account.issuer, identityEvidence.issuer),
+ eq(account.accountId, identityEvidence.subject),
+ eq(account.providerId, identityEvidence.providerId),
+ ),
+ )
+ .where(eq(account.userId, userId));
+ const entraIssuer = env.PN_ENTRA_TENANT_ID
+ ? `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`
+ : undefined;
+ const trusted = proofs.filter(
+ (proof) =>
+ (proof.providerId === "polimi-email" && proof.issuer === "https://mail.polimi.it") ||
+ (proof.providerId === "telegram" && proof.issuer === "https://oauth.telegram.org"),
+ );
+ const { states, telegramId } = identityStates(trusted);
+ const verifiedStates = new Set(states);
+ // Persisted group evidence is display/history data, never an authorization cache.
+ // Recheck all group-backed rights with the same short bound, including downstream rights.
+ for (const proof of proofs) {
+ if (proof.providerId !== "pn-entra" || proof.issuer !== entraIssuer || !proof.externalId)
+ continue;
+ const groups = [
+ ["socio", env.PN_ENTRA_MEMBER_GROUP_ID],
+ ["direttivo", env.PN_ENTRA_DIRETTIVO_GROUP_ID],
+ ] as const;
+ for (const [state, groupId] of groups)
+ if (groupId && (await member(groupId, proof.externalId))) verifiedStates.add(state);
+ }
+ const currentStates = [...verifiedStates].sort();
+ const master = await canAdministerIdp(userId, reader);
+ return {
+ states: currentStates,
+ telegramId,
+ roleKeys: [...staticRolesForStates(currentStates), ...(master ? [MASTER_ADMIN_ROLE_KEY] : [])],
+ };
+}
diff --git a/src/auth/identity.ts b/src/auth/identity.ts
index 6a2daf3..1dd0554 100644
--- a/src/auth/identity.ts
+++ b/src/auth/identity.ts
@@ -1,74 +1,8 @@
-import { and, eq } from "drizzle-orm";
-import { identityEvidence } from "../db/evidence";
-import { db } from "../db/index";
-import { account } from "../db/schema";
import { env } from "../env";
-import { type IdentityClaims, identityStates, oidcIdentityClaims } from "./policy";
-import { checkPnGroupStates, membershipEvidence } from "./membership";
-import { MASTER_ADMIN_ROLE_KEY } from "./rbac";
-import { resolveUserAccess } from "./rbac-store";
-import { canAdministerIdp } from "./oidc-admin";
+import { oidcIdentityClaims } from "./policy";
+import { resolveUserIdentity } from "./rbac-store";
-async function refreshExpiredMembership(userId: string) {
- const now = new Date();
- const stale = await db
- .select({
- issuer: identityEvidence.issuer,
- subject: identityEvidence.subject,
- externalId: identityEvidence.externalId,
- validUntil: identityEvidence.validUntil,
- })
- .from(account)
- .innerJoin(
- identityEvidence,
- and(
- eq(account.issuer, identityEvidence.issuer),
- eq(account.accountId, identityEvidence.subject),
- ),
- )
- .where(eq(account.userId, userId));
-
- for (const proof of stale) {
- if (proof.issuer !== `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`)
- continue;
- if (proof.validUntil > now || !proof.externalId) continue;
- const states = await checkPnGroupStates(proof.externalId);
- if (states === null) continue;
- await db
- .update(identityEvidence)
- .set(membershipEvidence(states))
- .where(
- and(eq(identityEvidence.issuer, proof.issuer), eq(identityEvidence.subject, proof.subject)),
- );
- }
-}
-
-export async function getIdentity(userId: string): Promise {
- await refreshExpiredMembership(userId);
- const proofs = await db
- .select({
- providerId: identityEvidence.providerId,
- externalId: identityEvidence.externalId,
- states: identityEvidence.states,
- validUntil: identityEvidence.validUntil,
- telegramId: identityEvidence.telegramId,
- })
- .from(account)
- .innerJoin(
- identityEvidence,
- and(
- eq(account.issuer, identityEvidence.issuer),
- eq(account.accountId, identityEvidence.subject),
- ),
- )
- .where(eq(account.userId, userId));
- const { states, telegramId } = identityStates(proofs);
- // Master Admin is configured outside the database, so it is conferred here rather than
- // proven by evidence, and never appears among the states.
- const conferred = (await canAdministerIdp(userId)) ? [MASTER_ADMIN_ROLE_KEY] : [];
- const access = await resolveUserAccess(userId, states, conferred);
- return { states, roles: access.roles, permissions: access.permissions, telegramId };
-}
+export const getIdentity = resolveUserIdentity;
export async function getOidcClaims(userId: string, scopes: string[]) {
if (!scopes.includes("polinetwork:identity")) return {};
diff --git a/src/auth/oidc-admin.test.ts b/src/auth/oidc-admin.test.ts
index 553c245..a87a8f9 100644
--- a/src/auth/oidc-admin.test.ts
+++ b/src/auth/oidc-admin.test.ts
@@ -77,3 +77,52 @@ describe("group membership cache", () => {
expect(check).toHaveBeenCalledTimes(2);
});
});
+
+describe("bounded administrative revocation", () => {
+ it("denies a removed member at the cache deadline and on lookup failure", async () => {
+ let time = 0;
+ const check = vi
+ .fn()
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(null);
+ const member = createGroupMembershipCache(check, 60_000, () => time);
+ expect(await member("admin-group", "removed-user")).toBe(true);
+ time = 60_000;
+ expect(await member("admin-group", "removed-user")).toBe(false);
+ time = 120_000;
+ expect(await member("admin-group", "removed-user")).toBe(false);
+ });
+ it("denies a positive response whose lookup outlives its authorization window", async () => {
+ let time = 0;
+ const member = createGroupMembershipCache(
+ async () => {
+ time = 60_001;
+ return true;
+ },
+ 60_000,
+ () => time,
+ );
+ expect(await member("group", "user")).toBe(false);
+ });
+ it("does not let a late positive overwrite a newer denial", async () => {
+ let time = 0;
+ let finish!: (value: boolean) => void;
+ const check = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ )
+ .mockResolvedValue(false);
+ const member = createGroupMembershipCache(check, 60_000, () => time);
+ const old = member("group", "user");
+ time = 10;
+ expect(await member("group", "user")).toBe(false);
+ finish(true);
+ expect(await old).toBe(false);
+ expect(await member("group", "user")).toBe(false);
+ });
+});
diff --git a/src/auth/oidc-admin.ts b/src/auth/oidc-admin.ts
index 4bdcf8d..ce1a0d5 100644
--- a/src/auth/oidc-admin.ts
+++ b/src/auth/oidc-admin.ts
@@ -3,6 +3,7 @@ import { identityEvidence } from "../db/evidence";
import { db } from "../db/index";
import { account } from "../db/schema";
import { env } from "../env";
+import type { IdentityReader } from "./identity-subject";
import { checkEntraGroupMember } from "./membership";
/**
@@ -43,29 +44,33 @@ type GroupCheck = (groupId: string, objectId: string) => Promise
* through the group. Failed checks are never cached and grant nothing.
*/
export function createGroupMembershipCache(check: GroupCheck, ttlMs: number, now = Date.now) {
- const cache = new Map();
+ let version = 0;
+ const cache = new Map();
return async (groupId: string, objectId: string): Promise => {
const key = `${groupId} ${objectId}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.member;
- const member = await check(groupId, objectId);
- if (member === null) return false;
- if (cache.size >= 1000) {
- for (const [entryKey, entry] of cache) if (entry.expiresAt <= now()) cache.delete(entryKey);
- }
- cache.set(key, { member, expiresAt: now() + ttlMs });
+ const startedAt = now();
+ const requestVersion = ++version;
+ if (cache.size >= 1000) cache.delete(cache.keys().next().value!);
+ cache.set(key, { member: false, expiresAt: 0, version: requestVersion });
+ const member = await check(groupId, objectId).catch(() => null);
+ // A slow positive must never replace or outlive a more recent verification.
+ if (member === null || now() >= startedAt + ttlMs || cache.get(key)?.version !== requestVersion)
+ return false;
+ cache.set(key, { member, expiresAt: startedAt + ttlMs, version: requestVersion });
return member;
};
}
-const ADMIN_GROUP_CACHE_MS = 15 * 60_000;
+const ADMIN_GROUP_CACHE_MS = 60_000;
const adminGroupMember = createGroupMembershipCache(checkEntraGroupMember, ADMIN_GROUP_CACHE_MS);
// Entra object IDs of the user's verified PoliNetwork tenant accounts.
-async function pnEntraObjectIds(userId: string): Promise {
+async function pnEntraObjectIds(userId: string, reader: IdentityReader): Promise {
if (!env.PN_ENTRA_TENANT_ID) return [];
const issuer = `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`;
- const rows = await db
+ const rows = await reader
.select({ externalId: identityEvidence.externalId })
.from(account)
.innerJoin(
@@ -86,9 +91,12 @@ async function pnEntraObjectIds(userId: string): Promise {
return rows.flatMap((row) => (row.externalId ? [row.externalId] : []));
}
-export async function canAdministerIdp(userId: string): Promise {
+export async function canAdministerIdp(
+ userId: string,
+ reader: IdentityReader = db,
+): Promise {
if (env.IDP_ADMIN_USER_IDS.includes(userId)) return true;
- const objectIds = await pnEntraObjectIds(userId);
+ const objectIds = await pnEntraObjectIds(userId, reader);
const groupId = env.PN_ENTRA_OIDC_ADMIN_GROUP_ID;
let groupMember = false;
if (groupId) {
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index 54b2574..8d2df7b 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -1,3 +1,5 @@
+import { readIdentitySubject } from "./identity-subject";
+import type { IdentityClaims } from "./policy";
import { randomUUID } from "node:crypto";
import { and, count, desc, eq, ilike, or, sql } from "drizzle-orm";
import { db } from "../db/index";
@@ -14,19 +16,15 @@ import {
type PermissionDraft,
type PermissionSummary,
type RbacCatalog,
- type ResolvedAccess,
type RoleDraft,
type RoleMember,
type RoleSummary,
type UserSearchResult,
- MANAGED_PERMISSIONS,
MASTER_ADMIN_ROLE_KEY,
hasDraftErrors,
normalizePermissionDraft,
normalizeRoleDraft,
resolveAccess,
- STATIC_ROLES,
- staticRolesForStates,
validatePermissionDraft,
validateRoleDraft,
} from "./rbac";
@@ -41,79 +39,6 @@ export class RbacError extends Error {
}
}
-/** Managed rows keep a derived, stable id so the seed is idempotent across replicas. */
-function staticRoleId(key: string) {
- return `static-role-${key}`;
-}
-
-function managedPermissionId(key: string) {
- return `managed-permission-${key.replace(/:/g, "-")}`;
-}
-
-let managedRecordsReady: Promise | undefined;
-
-/**
- * Makes sure the roles and permissions the identity provider defines itself exist. The
- * checked-in migrations seed them; this is the safety net for a database restored from an
- * older dump. Existing rows are left alone so administrator edits to their name,
- * description, and the roles that carry them survive a restart.
- */
-export function ensureManagedRecords(): Promise {
- managedRecordsReady ??= db
- .transaction(async (transaction) => {
- await transaction
- .insert(role)
- .values(
- STATIC_ROLES.map((entry) => ({
- id: staticRoleId(entry.key),
- key: entry.key,
- name: entry.name,
- description: entry.description,
- managed: true,
- sourceState: entry.state,
- })),
- )
- .onConflictDoNothing({ target: role.key });
- await transaction
- .insert(permission)
- .values(
- MANAGED_PERMISSIONS.map((entry) => ({
- id: managedPermissionId(entry.key),
- key: entry.key,
- name: entry.name,
- description: entry.description,
- managed: true,
- })),
- )
- .onConflictDoNothing({ target: permission.key });
- const implications = MANAGED_PERMISSIONS.flatMap((entry) =>
- entry.implies.map((implied) => ({
- permissionId: managedPermissionId(entry.key),
- impliedPermissionId: managedPermissionId(implied),
- })),
- );
- if (implications.length)
- await transaction.insert(permissionImplication).values(implications).onConflictDoNothing();
- })
- .then(() => undefined)
- .catch((cause: unknown) => {
- managedRecordsReady = undefined;
- throw cause;
- });
- return managedRecordsReady;
-}
-
-const CATALOG_TTL_MS = 15_000;
-let cached: { readAt: number; catalog: RbacCatalog } | undefined;
-
-/**
- * Drops the memoized catalog. Every writer calls this so an administrator always sees the
- * result of their own change; other replicas catch up within `CATALOG_TTL_MS`.
- */
-export function invalidateCatalog() {
- cached = undefined;
-}
-
/** Anything that can run the catalog queries: the pool, or an open transaction. */
type CatalogReader = Pick;
@@ -201,11 +126,10 @@ async function readCatalog(db: CatalogReader): Promise {
}
export async function loadCatalog(): Promise {
- if (cached && Date.now() - cached.readAt < CATALOG_TTL_MS) return cached.catalog;
- await ensureManagedRecords();
- const catalog = await readCatalog(db);
- cached = { readAt: Date.now(), catalog };
- return catalog;
+ return db.transaction((transaction) => readCatalog(transaction), {
+ isolationLevel: "repeatable read",
+ accessMode: "read only",
+ });
}
// Follows the convention the migration bootstrap uses for its own lock.
@@ -223,7 +147,6 @@ const hierarchyLock = sql`select pg_advisory_xact_lock(hashtext('polinetwork-aut
async function withRbacWriteLock(
change: (transaction: Transaction, catalog: RbacCatalog) => Promise,
): Promise {
- await ensureManagedRecords();
return db.transaction(async (transaction) => {
await transaction.execute(hierarchyLock);
return change(transaction, await readCatalog(transaction));
@@ -297,7 +220,6 @@ export async function savePermission(
);
return id;
});
- invalidateCatalog();
const saved = (await loadCatalog()).permissions.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The permission could not be read back.");
return saved;
@@ -313,7 +235,6 @@ export async function deletePermission(permissionId: string) {
);
await transaction.delete(permission).where(eq(permission.id, permissionId));
});
- invalidateCatalog();
}
export async function saveRole(input: RoleDraft, roleId?: string): Promise {
@@ -358,7 +279,6 @@ export async function saveRole(input: RoleDraft, roleId?: string): Promise ({ roleId: id, parentRoleId })));
return id;
});
- invalidateCatalog();
const saved = (await loadCatalog()).roles.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The role could not be read back.");
return saved;
@@ -371,7 +291,6 @@ export async function deleteRole(roleId: string) {
throw new RbacError(400, "Roles defined by the identity provider cannot be deleted.");
await transaction.delete(role).where(eq(role.id, roleId));
});
- invalidateCatalog();
}
export async function listRoleMembers(roleId: string): Promise {
@@ -405,12 +324,10 @@ export async function assignRole(roleId: string, userId: string, assignedBy: str
const [found] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1);
if (!found) throw new RbacError(404, "That person was not found.");
await db.insert(userRole).values({ roleId, userId, assignedBy }).onConflictDoNothing();
- invalidateCatalog();
}
export async function unassignRole(roleId: string, userId: string) {
await db.delete(userRole).where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId)));
- invalidateCatalog();
}
/** People an administrator can pick when assigning a role. */
@@ -425,8 +342,12 @@ export async function searchUsers(query: string): Promise {
}
/** The role keys a person has been given by hand, ignoring anything managed. */
-export async function assignedRoleKeys(userId: string, catalog: RbacCatalog): Promise {
- const rows = await db
+async function assignedRoleKeys(
+ userId: string,
+ catalog: RbacCatalog,
+ reader: CatalogReader,
+): Promise {
+ const rows = await reader
.select({ roleId: userRole.roleId })
.from(userRole)
.where(eq(userRole.userId, userId));
@@ -439,22 +360,16 @@ export async function assignedRoleKeys(userId: string, catalog: RbacCatalog): Pr
});
}
-/**
- * The roles and permissions a person currently holds: the roles assigned to them, the
- * managed roles their identity states prove, and any role conferred some other way (Master
- * Admin comes from the configured allowlist, not from evidence), expanded through both
- * hierarchies.
- */
-export async function resolveUserAccess(
- userId: string,
- states: readonly string[],
- conferredRoleKeys: readonly string[] = [],
-): Promise {
- const catalog = await loadCatalog();
- const held = [
- ...(await assignedRoleKeys(userId, catalog)),
- ...staticRolesForStates(states),
- ...conferredRoleKeys,
- ];
- return resolveAccess(catalog, held);
+/** Read identity, assignments and graph from one committed database snapshot. */
+export async function resolveUserIdentity(userId: string): Promise {
+ return db.transaction(
+ async (transaction) => {
+ const subject = await readIdentitySubject(userId, transaction);
+ const catalog = await readCatalog(transaction);
+ const held = [...(await assignedRoleKeys(userId, catalog, transaction)), ...subject.roleKeys];
+ const access = resolveAccess(catalog, held);
+ return { states: subject.states, telegramId: subject.telegramId, ...access };
+ },
+ { isolationLevel: "repeatable read", accessMode: "read only" },
+ );
}
diff --git a/src/routes/applications/route.tsx b/src/routes/applications/route.tsx
index d239ff7..3e3ee8e 100644
--- a/src/routes/applications/route.tsx
+++ b/src/routes/applications/route.tsx
@@ -24,7 +24,7 @@ function NoAccess({ policy }: { policy: OidcAdminPolicy | null }) {
{policy === "entra-group"
? "Managing sign-in applications is limited to members of the PoliNetwork Entra administrators group. Ask an administrator to add your PoliNetwork Microsoft account."
- : "Managing sign-in applications requires a PoliNetwork Microsoft account. Link your PoliNetwork APS account from your account page, then come back here."}
+ : "Access requires explicitly delegated application permissions. Ask an administrator to grant the appropriate role."}
From 1899b8dfdf687c5e765e27a89554827063232a4b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 13:12:46 +0000
Subject: [PATCH 05/15] fix: serialize and reauthorize RBAC mutations with
append-only audit history
---
drizzle/0007_mushy_the_fury.sql | 51 +
drizzle/meta/0007_snapshot.json | 2303 +++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/auth/api-guard.ts | 18 +-
src/auth/denial-log.test.ts | 16 +
src/auth/denial-log.ts | 8 +
src/auth/rbac-security.integration.test.mjs | 315 +++
src/auth/rbac-store.ts | 300 ++-
src/db/rbac.ts | 12 +
src/routes/api/rbac/permission-save.ts | 3 +-
src/routes/api/rbac/role-members.ts | 4 +-
src/routes/api/rbac/role-save.ts | 3 +-
12 files changed, 2932 insertions(+), 108 deletions(-)
create mode 100644 drizzle/0007_mushy_the_fury.sql
create mode 100644 drizzle/meta/0007_snapshot.json
create mode 100644 src/auth/denial-log.test.ts
create mode 100644 src/auth/denial-log.ts
create mode 100644 src/auth/rbac-security.integration.test.mjs
diff --git a/drizzle/0007_mushy_the_fury.sql b/drizzle/0007_mushy_the_fury.sql
new file mode 100644
index 0000000..f271cc0
--- /dev/null
+++ b/drizzle/0007_mushy_the_fury.sql
@@ -0,0 +1,51 @@
+CREATE TABLE "rbac_audit_event" (
+ "id" text PRIMARY KEY NOT NULL,
+ "actor_id" text NOT NULL,
+ "operation" text NOT NULL,
+ "target_id" text NOT NULL,
+ "before" jsonb NOT NULL,
+ "after" jsonb NOT NULL,
+ "createdAt" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE FUNCTION reject_rbac_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ RAISE EXCEPTION 'RBAC audit events are append-only';
+END;
+$$;
+--> statement-breakpoint
+CREATE TRIGGER rbac_audit_append_only BEFORE UPDATE OR DELETE OR TRUNCATE ON rbac_audit_event
+FOR EACH STATEMENT EXECUTE FUNCTION reject_rbac_audit_mutation();
+--> statement-breakpoint
+CREATE FUNCTION guard_managed_role_links() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_TABLE_NAME = 'user_role' THEN
+ IF EXISTS (SELECT 1 FROM role WHERE id = NEW.role_id AND managed) THEN
+ RAISE EXCEPTION 'Managed roles cannot be assigned';
+ END IF;
+ ELSE
+ IF EXISTS (SELECT 1 FROM role WHERE id = NEW.parent_role_id AND key = 'master-admin') THEN
+ RAISE EXCEPTION 'Master Admin cannot be inherited';
+ END IF;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+--> statement-breakpoint
+CREATE TRIGGER user_role_unmanaged_only BEFORE INSERT OR UPDATE ON user_role
+FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links();
+--> statement-breakpoint
+CREATE TRIGGER role_parent_no_master BEFORE INSERT OR UPDATE ON role_parent
+FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links();
+--> statement-breakpoint
+-- Existing unsafe edges/assignments are quarantined in the audit record before removal.
+INSERT INTO rbac_audit_event (id, actor_id, operation, target_id, before, after)
+SELECT 'migration-0007-unsafe-links', 'system:migration:0007', 'quarantine', 'managed-role-links',
+jsonb_build_object(
+ 'parents', (SELECT coalesce(jsonb_agg(p), '[]') FROM role_parent p JOIN role r ON r.id = p.parent_role_id WHERE r.key = 'master-admin'),
+ 'assignments', (SELECT coalesce(jsonb_agg(a), '[]') FROM user_role a JOIN role r ON r.id = a.role_id WHERE r.managed)
+), '{}'::jsonb;
+--> statement-breakpoint
+DELETE FROM role_parent WHERE parent_role_id IN (SELECT id FROM role WHERE key = 'master-admin');
+--> statement-breakpoint
+DELETE FROM user_role WHERE role_id IN (SELECT id FROM role WHERE managed);
diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json
new file mode 100644
index 0000000..a5d8838
--- /dev/null
+++ b/drizzle/meta/0007_snapshot.json
@@ -0,0 +1,2303 @@
+{
+ "id": "163aaf0e-49be-401c-9cee-918d5f96c037",
+ "prevId": "219ad6aa-b78e-444a-8d4e-cddd7b31830a",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_issuer_subject_uidx": {
+ "name": "account_issuer_subject_uidx",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.jwks": {
+ "name": "jwks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key": {
+ "name": "private_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "alg": {
+ "name": "alg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "crv": {
+ "name": "crv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_access_token": {
+ "name": "oauth_access_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authorization_code_id": {
+ "name": "authorization_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_id": {
+ "name": "refresh_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation": {
+ "name": "confirmation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthAccessToken_clientId_idx": {
+ "name": "oauthAccessToken_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_sessionId_idx": {
+ "name": "oauthAccessToken_sessionId_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_userId_idx": {
+ "name": "oauthAccessToken_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_authorizationCodeId_idx": {
+ "name": "oauthAccessToken_authorizationCodeId_idx",
+ "columns": [
+ {
+ "expression": "authorization_code_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_refreshId_idx": {
+ "name": "oauthAccessToken_refreshId_idx",
+ "columns": [
+ {
+ "expression": "refresh_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_access_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_access_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_session_id_session_id_fk": {
+ "name": "oauth_access_token_session_id_session_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "session",
+ "columnsFrom": ["session_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_user_id_user_id_fk": {
+ "name": "oauth_access_token_user_id_user_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": {
+ "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_refresh_token",
+ "columnsFrom": ["refresh_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_access_token_token_unique": {
+ "name": "oauth_access_token_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client": {
+ "name": "oauth_client",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_discovery_id": {
+ "name": "client_discovery_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled": {
+ "name": "disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "skip_consent": {
+ "name": "skip_consent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enable_end_session": {
+ "name": "enable_end_session",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_credentials_scopes": {
+ "name": "client_credentials_scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uri": {
+ "name": "uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contacts": {
+ "name": "contacts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tos": {
+ "name": "tos",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_id": {
+ "name": "software_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_version": {
+ "name": "software_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_statement": {
+ "name": "software_statement",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "post_logout_redirect_uris": {
+ "name": "post_logout_redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backchannel_logout_uri": {
+ "name": "backchannel_logout_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backchannel_logout_session_required": {
+ "name": "backchannel_logout_session_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_endpoint_auth_method": {
+ "name": "token_endpoint_auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_type": {
+ "name": "application_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "jwks": {
+ "name": "jwks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "jwks_uri": {
+ "name": "jwks_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grant_types": {
+ "name": "grant_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_types": {
+ "name": "response_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_pkce": {
+ "name": "require_pkce",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dpop_bound_access_tokens": {
+ "name": "dpop_bound_access_tokens",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "oauthClient_userId_idx": {
+ "name": "oauthClient_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_client_user_id_user_id_fk": {
+ "name": "oauth_client_user_id_user_id_fk",
+ "tableFrom": "oauth_client",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_client_client_id_unique": {
+ "name": "oauth_client_client_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["client_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client_assertion": {
+ "name": "oauth_client_assertion",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client_resource": {
+ "name": "oauth_client_resource",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "oauthClientResource_clientId_resourceId_uidx": {
+ "name": "oauthClientResource_clientId_resourceId_uidx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthClientResource_clientId_idx": {
+ "name": "oauthClientResource_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthClientResource_resourceId_idx": {
+ "name": "oauthClientResource_resourceId_idx",
+ "columns": [
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_client_resource_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_client_resource_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_client_resource",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_client_resource_resource_id_oauth_resource_identifier_fk": {
+ "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk",
+ "tableFrom": "oauth_client_resource",
+ "tableTo": "oauth_resource",
+ "columnsFrom": ["resource_id"],
+ "columnsTo": ["identifier"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_consent": {
+ "name": "oauth_consent",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthConsent_clientId_idx": {
+ "name": "oauthConsent_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthConsent_userId_idx": {
+ "name": "oauthConsent_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_consent_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_consent_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_consent_user_id_user_id_fk": {
+ "name": "oauth_consent_user_id_user_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_refresh_token": {
+ "name": "oauth_refresh_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authorization_code_id": {
+ "name": "authorization_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotation_replay_response": {
+ "name": "rotation_replay_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotation_replay_expires_at": {
+ "name": "rotation_replay_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_time": {
+ "name": "auth_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation": {
+ "name": "confirmation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthRefreshToken_clientId_idx": {
+ "name": "oauthRefreshToken_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_sessionId_idx": {
+ "name": "oauthRefreshToken_sessionId_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_userId_idx": {
+ "name": "oauthRefreshToken_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_authorizationCodeId_idx": {
+ "name": "oauthRefreshToken_authorizationCodeId_idx",
+ "columns": [
+ {
+ "expression": "authorization_code_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_refresh_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_session_id_session_id_fk": {
+ "name": "oauth_refresh_token_session_id_session_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "session",
+ "columnsFrom": ["session_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_user_id_user_id_fk": {
+ "name": "oauth_refresh_token_user_id_user_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_refresh_token_token_unique": {
+ "name": "oauth_refresh_token_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_resource": {
+ "name": "oauth_resource",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token_ttl": {
+ "name": "access_token_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_ttl": {
+ "name": "refresh_token_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signing_algorithm": {
+ "name": "signing_algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signing_key_id": {
+ "name": "signing_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowed_scopes": {
+ "name": "allowed_scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_claims": {
+ "name": "custom_claims",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dpop_bound_access_tokens_required": {
+ "name": "dpop_bound_access_tokens_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "disabled": {
+ "name": "disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy_version": {
+ "name": "policy_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 1
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_resource_identifier_unique": {
+ "name": "oauth_resource_identifier_unique",
+ "nullsNotDistinct": false,
+ "columns": ["identifier"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.passkey": {
+ "name": "passkey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "passkey_userId_idx": {
+ "name": "passkey_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "passkey_credential_id_unique": {
+ "name": "passkey_credential_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["credential_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_limit": {
+ "name": "rate_limit",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.identity_evidence": {
+ "name": "identity_evidence",
+ "schema": "",
+ "columns": {
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "states": {
+ "name": "states",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "valid_until": {
+ "name": "valid_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "telegram_id": {
+ "name": "telegram_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "identity_evidence_issuer_subject_pk": {
+ "name": "identity_evidence_issuer_subject_pk",
+ "columns": ["issuer", "subject"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.student_verification_challenge": {
+ "name": "student_verification_challenge",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_hash": {
+ "name": "code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "studentVerificationChallenge_email_uidx": {
+ "name": "studentVerificationChallenge_email_uidx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "studentVerificationChallenge_expiresAt_idx": {
+ "name": "studentVerificationChallenge_expiresAt_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "student_verification_challenge_user_id_user_id_fk": {
+ "name": "student_verification_challenge_user_id_user_id_fk",
+ "tableFrom": "student_verification_challenge",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permission": {
+ "name": "permission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "permission_key_uidx": {
+ "name": "permission_key_uidx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permission_implication": {
+ "name": "permission_implication",
+ "schema": "",
+ "columns": {
+ "permission_id": {
+ "name": "permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "implied_permission_id": {
+ "name": "implied_permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "permissionImplication_implied_idx": {
+ "name": "permissionImplication_implied_idx",
+ "columns": [
+ {
+ "expression": "implied_permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "permission_implication_permission_id_permission_id_fk": {
+ "name": "permission_implication_permission_id_permission_id_fk",
+ "tableFrom": "permission_implication",
+ "tableTo": "permission",
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "permission_implication_implied_permission_id_permission_id_fk": {
+ "name": "permission_implication_implied_permission_id_permission_id_fk",
+ "tableFrom": "permission_implication",
+ "tableTo": "permission",
+ "columnsFrom": ["implied_permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "permission_implication_permission_id_implied_permission_id_pk": {
+ "name": "permission_implication_permission_id_implied_permission_id_pk",
+ "columns": ["permission_id", "implied_permission_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rbac_audit_event": {
+ "name": "rbac_audit_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "before": {
+ "name": "before",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "after": {
+ "name": "after",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "source_state": {
+ "name": "source_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "role_key_uidx": {
+ "name": "role_key_uidx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role_parent": {
+ "name": "role_parent",
+ "schema": "",
+ "columns": {
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_role_id": {
+ "name": "parent_role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "roleParent_parent_idx": {
+ "name": "roleParent_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "role_parent_role_id_role_id_fk": {
+ "name": "role_parent_role_id_role_id_fk",
+ "tableFrom": "role_parent",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "role_parent_parent_role_id_role_id_fk": {
+ "name": "role_parent_parent_role_id_role_id_fk",
+ "tableFrom": "role_parent",
+ "tableTo": "role",
+ "columnsFrom": ["parent_role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "role_parent_role_id_parent_role_id_pk": {
+ "name": "role_parent_role_id_parent_role_id_pk",
+ "columns": ["role_id", "parent_role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role_permission": {
+ "name": "role_permission",
+ "schema": "",
+ "columns": {
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_id": {
+ "name": "permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "rolePermission_permission_idx": {
+ "name": "rolePermission_permission_idx",
+ "columns": [
+ {
+ "expression": "permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "role_permission_role_id_role_id_fk": {
+ "name": "role_permission_role_id_role_id_fk",
+ "tableFrom": "role_permission",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "role_permission_permission_id_permission_id_fk": {
+ "name": "role_permission_permission_id_permission_id_fk",
+ "tableFrom": "role_permission",
+ "tableTo": "permission",
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "role_permission_role_id_permission_id_pk": {
+ "name": "role_permission_role_id_permission_id_pk",
+ "columns": ["role_id", "permission_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_by": {
+ "name": "assigned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignedAt": {
+ "name": "assignedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "userRole_role_idx": {
+ "name": "userRole_role_idx",
+ "columns": [
+ {
+ "expression": "role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_role_user_id_user_id_fk": {
+ "name": "user_role_user_id_user_id_fk",
+ "tableFrom": "user_role",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_role_role_id_role_id_fk": {
+ "name": "user_role_role_id_role_id_fk",
+ "tableFrom": "user_role",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_role_user_id_role_id_pk": {
+ "name": "user_role_user_id_role_id_pk",
+ "columns": ["user_id", "role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 43ca22e..c99d0e9 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
"when": 1789444594426,
"tag": "0006_volatile_pandemic",
"breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "7",
+ "when": 1789650344269,
+ "tag": "0007_mushy_the_fury",
+ "breakpoints": true
}
]
}
diff --git a/src/auth/api-guard.ts b/src/auth/api-guard.ts
index 870ad72..73fcb77 100644
--- a/src/auth/api-guard.ts
+++ b/src/auth/api-guard.ts
@@ -1,3 +1,4 @@
+import { logAuthorizationDenial } from "./denial-log";
import { auth } from "./index";
import { idpPermissions } from "./idp-access";
import type { ManagedPermissionKey } from "./rbac";
@@ -16,13 +17,22 @@ async function requirePermission(
required: readonly ManagedPermissionKey[],
options: { write?: boolean },
): Promise {
+ const deny = (status: number, message: string, actorId: string | null = null): Guard => {
+ logAuthorizationDenial(actorId, new URL(request.url).pathname, required);
+ return { response: apiError(status, message) };
+ };
if (options.write && request.headers.get("origin") !== new URL(env.BETTER_AUTH_URL).origin)
- return { response: apiError(403, "Invalid origin.") };
+ return deny(403, "Invalid origin.");
const session = await auth.api.getSession({ headers: request.headers });
- if (!session) return { response: apiError(401, "Unauthorized.") };
- const permissions = await idpPermissions(session.user.id);
+ if (!session?.user?.id) return deny(401, "Unauthorized.");
+ let permissions: string[];
+ try {
+ permissions = await idpPermissions(session.user.id);
+ } catch {
+ return deny(503, "Authorization unavailable.", session.user.id);
+ }
if (!required.some((permission) => permissions.includes(permission)))
- return { response: apiError(403, "You do not have permission to do that.") };
+ return deny(403, "You do not have permission to do that.", session.user.id);
return { session: { userId: session.user.id, permissions } };
}
diff --git a/src/auth/denial-log.test.ts b/src/auth/denial-log.test.ts
new file mode 100644
index 0000000..17fbc01
--- /dev/null
+++ b/src/auth/denial-log.test.ts
@@ -0,0 +1,16 @@
+import { expect, it, vi } from "vite-plus/test";
+import { logAuthorizationDenial } from "./denial-log";
+it("logs a denial using only actor, endpoint and required permission", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ logAuthorizationDenial("user-id", "/api/rbac/role-save", ["idp:roles:write"]);
+ expect(JSON.parse(warn.mock.calls[0][0])).toEqual({
+ event: "authorization_denied",
+ actorId: "user-id",
+ endpoint: "/api/rbac/role-save",
+ required: ["idp:roles:write"],
+ });
+ } finally {
+ warn.mockRestore();
+ }
+});
diff --git a/src/auth/denial-log.ts b/src/auth/denial-log.ts
new file mode 100644
index 0000000..3b7b01b
--- /dev/null
+++ b/src/auth/denial-log.ts
@@ -0,0 +1,8 @@
+/** Deliberately exclude query strings, request bodies, cookies and provider error details. */
+export function logAuthorizationDenial(
+ actorId: string | null,
+ endpoint: string,
+ required: readonly string[],
+) {
+ console.warn(JSON.stringify({ event: "authorization_denied", actorId, endpoint, required }));
+}
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
new file mode 100644
index 0000000..959689e
--- /dev/null
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -0,0 +1,315 @@
+import { randomUUID } from "node:crypto";
+import { Pool } from "pg";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({ graph: vi.fn().mockResolvedValue(false) }));
+vi.mock("../env", () => {
+ const url = new URL(
+ process.env.RBAC_TEST_DATABASE_URL ??
+ "postgresql://postgres:test@localhost:55439/auth_security",
+ );
+ return {
+ env: {
+ DB_HOST: url.hostname,
+ DB_PORT: Number(url.port),
+ DB_USER: url.username,
+ DB_PASS: url.password,
+ DB_NAME: url.pathname.slice(1),
+ BETTER_AUTH_URL: "http://localhost:35439",
+ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ IDP_ADMIN_USER_IDS: ["security-root"],
+ PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111",
+ PN_ENTRA_MEMBER_GROUP_ID: "soci",
+ PN_ENTRA_OIDC_ADMIN_GROUP_ID: "admins",
+ },
+ };
+});
+vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.graph }));
+// Only session authentication is substituted; routes, authorization, evidence, SQL and
+// transactions are real. The separate identity HTTP suite exercises signed session cookies.
+vi.mock("./index", () => ({
+ auth: {
+ api: {
+ getSession: async ({ headers }) => {
+ const id = headers.get("x-test-user");
+ return id ? { user: { id } } : null;
+ },
+ },
+ },
+}));
+
+import { db } from "../db/index";
+import { getIdentity } from "./identity";
+import {
+ assignRole,
+ deletePermission,
+ deleteRole,
+ loadCatalog,
+ savePermission,
+ saveRole,
+ unassignRole,
+} from "./rbac-store";
+import { Route as roleSave } from "../routes/api/rbac/role-save";
+import { Route as permissionSave } from "../routes/api/rbac/permission-save";
+import { Route as members } from "../routes/api/rbac/role-members";
+import { Route as clientUpdate } from "../routes/api/oidc/client-update";
+
+const root = "security-root";
+const ordinary = "security-ordinary";
+const writer = "security-writer";
+const draftRole = (key, permissions = [], parents = []) => ({
+ key,
+ name: key,
+ description: "",
+ permissions,
+ parents,
+});
+const draftPermission = (key, implies = []) => ({ key, name: key, description: "", implies });
+const unique = (name) => `security-${name}-${randomUUID().slice(0, 8)}`;
+
+function post(route, actor, body, origin = "http://localhost:35439") {
+ return route.options.server.handlers.POST({
+ request: new Request(`http://localhost:35439${route.id ?? "/api/test"}`, {
+ method: "POST",
+ headers: { "x-test-user": actor, Origin: origin, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ });
+}
+
+describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with PostgreSQL", () => {
+ const pool = new Pool({ connectionString: process.env.RBAC_TEST_DATABASE_URL });
+ beforeAll(async () => {
+ await pool.query(
+ `INSERT INTO "user" (id, name, email) SELECT id, id, id || '@identity.invalid' FROM unnest($1::text[]) AS id`,
+ [[root, ordinary, writer]],
+ );
+ });
+ afterAll(async () => {
+ await pool.query(`DELETE FROM "user" WHERE id LIKE 'security-%'`);
+ await pool.query(`DELETE FROM role WHERE key LIKE 'security-%'`);
+ await pool.query(`DELETE FROM permission WHERE key LIKE 'security-%'`);
+ await pool.query(`DELETE FROM identity_evidence WHERE subject LIKE 'security-%'`);
+ await pool.query(`DELETE FROM oauth_client WHERE client_id LIKE 'security-%'`);
+ await pool.end();
+ await db.$client.end();
+ });
+
+ it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => {
+ const role = await saveRole(root, draftRole(unique("target")));
+ const permission = await savePermission(root, draftPermission(unique("permission")));
+ for (const action of [
+ () => saveRole(ordinary, draftRole(unique("bad"))),
+ () => deleteRole(ordinary, role.id),
+ () => savePermission(ordinary, draftPermission(unique("bad"))),
+ () => deletePermission(ordinary, permission.id),
+ () => assignRole(ordinary, role.id, ordinary),
+ () => unassignRole(ordinary, role.id, root),
+ ])
+ await expect(action()).rejects.toMatchObject({ status: 403 });
+ expect(
+ (await post(roleSave, ordinary, { action: "create", draft: draftRole(unique("http")) }))
+ .status,
+ ).toBe(403);
+ expect(
+ (await post(permissionSave, ordinary, { action: "delete", permissionId: permission.id }))
+ .status,
+ ).toBe(403);
+ expect(
+ (await post(members, ordinary, { action: "assign", roleId: role.id, userId: ordinary }))
+ .status,
+ ).toBe(403);
+ expect(
+ (
+ await post(
+ roleSave,
+ root,
+ { action: "delete", roleId: role.id },
+ "https://attacker.invalid",
+ )
+ ).status,
+ ).toBe(403);
+ });
+
+ it("denies missing/deleted subjects even if the identifier is allowlisted", async () => {
+ await expect(getIdentity("not-a-user")).rejects.toThrow("Unknown identity subject");
+ await expect(saveRole("not-a-user", draftRole(unique("bad")))).rejects.toThrow();
+ });
+
+ it("does not revive removed implications when the catalog is reloaded", async () => {
+ const catalog = await loadCatalog();
+ const managed = catalog.permissions.find((entry) => entry.key === "idp:applications:write");
+ await savePermission(root, draftPermission(managed.key), managed.id);
+ const role = await saveRole(root, draftRole(unique("appwriter"), [managed.key]));
+ await assignRole(root, role.id, ordinary);
+ expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
+ await loadCatalog();
+ expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
+ await unassignRole(root, role.id, ordinary);
+ await savePermission(root, draftPermission(managed.key, ["idp:applications:read"]), managed.id);
+ });
+
+ it("denies access immediately after a committed graph or assignment revocation", async () => {
+ const permission = await savePermission(root, draftPermission(unique("revocation")));
+ const role = await saveRole(root, draftRole(unique("revocation"), [permission.key]));
+ await assignRole(root, role.id, ordinary);
+ expect((await getIdentity(ordinary)).permissions).toContain(permission.key);
+ await saveRole(root, draftRole(role.key), role.id);
+ expect((await getIdentity(ordinary)).permissions).not.toContain(permission.key);
+ await saveRole(root, draftRole(role.key, [permission.key]), role.id);
+ await unassignRole(root, role.id, ordinary);
+ expect((await getIdentity(ordinary)).permissions).not.toContain(permission.key);
+ });
+
+ it("denies stored 24-hour membership after Graph removal or lookup failure", async () => {
+ const issuer = "https://login.microsoftonline.com/11111111-1111-4111-8111-111111111111/v2.0";
+ const subject = unique("stale");
+ await pool.query(
+ `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'pn-entra', $2, $3, now())`,
+ [subject, issuer, ordinary],
+ );
+ await pool.query(
+ `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until) VALUES ($1, $2, 'pn-entra', $2, ARRAY['socio'], now() + interval '24 hours')`,
+ [issuer, subject],
+ );
+ mocks.graph.mockResolvedValue(false);
+ expect((await getIdentity(ordinary)).roles).not.toContain("socio");
+ expect((await getIdentity(ordinary)).roles).not.toContain("master-admin");
+ mocks.graph.mockResolvedValue(null);
+ await pool.query(
+ `UPDATE identity_evidence SET external_id = external_id || '-outage' WHERE subject = $1`,
+ [subject],
+ );
+ expect((await getIdentity(ordinary)).permissions).not.toContain("membership:read");
+ await pool.query(`DELETE FROM account WHERE id = $1`, [subject]);
+ mocks.graph.mockResolvedValue(false);
+ });
+
+ it("denies evidence from another tenant even with a matching provider label", async () => {
+ const subject = unique("wrong-tenant");
+ await pool.query(
+ `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'pn-entra', 'https://foreign.invalid', $2, now())`,
+ [subject, ordinary],
+ );
+ await pool.query(
+ `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until) VALUES ('https://foreign.invalid', $1, 'pn-entra', $1, ARRAY['socio'], now() + interval '24 hours')`,
+ [subject],
+ );
+ mocks.graph.mockResolvedValue(true);
+ expect((await getIdentity(ordinary)).roles).not.toContain("socio");
+ expect((await getIdentity(ordinary)).roles).not.toContain("master-admin");
+ await pool.query(`DELETE FROM account WHERE id = $1`, [subject]);
+ mocks.graph.mockResolvedValue(false);
+ });
+
+ it("rejects managed assignments and root inheritance at the database boundary", async () => {
+ await expect(
+ pool.query(
+ `INSERT INTO user_role (user_id, role_id) VALUES ($1, 'static-role-master-admin')`,
+ [ordinary],
+ ),
+ ).rejects.toThrow("Managed roles cannot be assigned");
+ const role = await saveRole(root, draftRole(unique("legacy")));
+ await expect(
+ pool.query(
+ `INSERT INTO role_parent (role_id, parent_role_id) VALUES ($1, 'static-role-master-admin')`,
+ [role.id],
+ ),
+ ).rejects.toThrow("Master Admin cannot be inherited");
+ });
+
+ it("keeps durable actor and before/after history and refuses erasure", async () => {
+ const role = await saveRole(root, draftRole(unique("audited")));
+ await assignRole(root, role.id, ordinary);
+ await unassignRole(root, role.id, ordinary);
+ await deleteRole(root, role.id);
+ const { rows } = await pool.query(
+ `SELECT * FROM rbac_audit_event WHERE target_id = $1 ORDER BY "createdAt"`,
+ [role.id],
+ );
+ expect(rows.map((row) => row.operation)).toEqual([
+ "role.save",
+ "role.assign",
+ "role.unassign",
+ "role.delete",
+ ]);
+ expect(rows.every((row) => row.actor_id === root)).toBe(true);
+ expect(rows[2].before.assignments[0].userId).toBe(ordinary);
+ expect(rows[2].after.assignments).toEqual([]);
+ await expect(
+ pool.query(`DELETE FROM rbac_audit_event WHERE target_id = $1`, [role.id]),
+ ).rejects.toThrow("append-only");
+ await expect(
+ pool.query(`UPDATE rbac_audit_event SET actor_id = 'erased' WHERE target_id = $1`, [role.id]),
+ ).rejects.toThrow("append-only");
+ await expect(pool.query(`TRUNCATE rbac_audit_event`)).rejects.toThrow("append-only");
+ });
+
+ it("rolls back a mutation if its audit event cannot be stored", async () => {
+ await pool.query(
+ `CREATE FUNCTION security_fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.actor_id = 'security-root' THEN RAISE EXCEPTION 'audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER security_fail_audit BEFORE INSERT ON rbac_audit_event FOR EACH ROW EXECUTE FUNCTION security_fail_audit()`,
+ );
+ const key = unique("rollback");
+ try {
+ await expect(saveRole(root, draftRole(key))).rejects.toThrow();
+ expect((await pool.query(`SELECT * FROM role WHERE key = $1`, [key])).rows).toEqual([]);
+ } finally {
+ await pool.query(
+ `DROP TRIGGER security_fail_audit ON rbac_audit_event; DROP FUNCTION security_fail_audit()`,
+ );
+ }
+ });
+
+ it("serializes concurrent opposite graph edges and refuses the cycle", async () => {
+ const a = await saveRole(root, draftRole(unique("a")));
+ const b = await saveRole(root, draftRole(unique("b")));
+ const results = await Promise.allSettled([
+ saveRole(root, draftRole(a.key, [], [b.key]), a.id),
+ saveRole(root, draftRole(b.key, [], [a.key]), b.id),
+ ]);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
+ });
+
+ it("denies an in-flight writer after a queued revoke commits", async () => {
+ const authority = await saveRole(root, draftRole(unique("writer"), ["idp:roles:write"]));
+ const target = await saveRole(root, draftRole(unique("victim")));
+ await assignRole(root, authority.id, writer);
+ const connection = await pool.connect();
+ await connection.query("BEGIN");
+ await connection.query(
+ "SELECT pg_advisory_xact_lock(hashtext('polinetwork-auth'), hashtext('rbac-hierarchy'))",
+ );
+ try {
+ await connection.query("DELETE FROM user_role WHERE user_id = $1", [writer]);
+ const grant = assignRole(writer, target.id, ordinary);
+ // The writer cannot enter its authorization/mutation transaction until commit.
+ await connection.query("COMMIT");
+ await expect(grant).rejects.toMatchObject({ status: 403 });
+ expect(
+ (
+ await pool.query("SELECT * FROM user_role WHERE user_id = $1 AND role_id = $2", [
+ ordinary,
+ target.id,
+ ])
+ ).rows,
+ ).toEqual([]);
+ } finally {
+ await connection.query("ROLLBACK");
+ connection.release();
+ }
+ });
+
+ it("refuses cross-pool client updates even by an application administrator", async () => {
+ const client = unique("foreign-client");
+ await pool.query(
+ `INSERT INTO oauth_client (id, client_id, redirect_uris, reference_id) VALUES ($1, $1, ARRAY['https://example.com/callback'], 'another-pool')`,
+ [client],
+ );
+ expect((await post(clientUpdate, root, { clientId: client, disabled: true })).status).toBe(404);
+ expect(
+ (await pool.query("SELECT disabled FROM oauth_client WHERE client_id = $1", [client])).rows[0]
+ .disabled,
+ ).toBe(false);
+ });
+});
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index 8d2df7b..7d92ddd 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -1,9 +1,11 @@
+import { logAuthorizationDenial } from "./denial-log";
import { readIdentitySubject } from "./identity-subject";
import type { IdentityClaims } from "./policy";
import { randomUUID } from "node:crypto";
import { and, count, desc, eq, ilike, or, sql } from "drizzle-orm";
import { db } from "../db/index";
import {
+ rbacAuditEvent,
permission,
permissionImplication,
role,
@@ -13,6 +15,8 @@ import {
userRole,
} from "../db/schema";
import {
+ type ManagedPermissionKey,
+ type ResolvedAccess,
type PermissionDraft,
type PermissionSummary,
type RbacCatalog,
@@ -144,13 +148,82 @@ const hierarchyLock = sql`select pg_advisory_xact_lock(hashtext('polinetwork-aut
* lock serializes these writes across every replica, and the catalog is then re-read
* inside the transaction so the checks see the other writer's committed work.
*/
+export async function withAuthorizedRbacWrite(
+ actorId: string,
+ required: ManagedPermissionKey,
+ change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
+): Promise {
+ return db.transaction(
+ async (transaction) => {
+ await transaction.execute(hierarchyLock);
+ const subject = await readIdentitySubject(actorId, transaction);
+ const catalog = await readCatalog(transaction);
+ const access = resolveAccess(catalog, [
+ ...(await assignedRoleKeys(actorId, catalog, transaction)),
+ ...subject.roleKeys,
+ ]);
+ if (!access.permissions.includes(required)) {
+ logAuthorizationDenial(actorId, "rbac-store", [required]);
+ throw new RbacError(403, "You do not have permission to do that.");
+ }
+ return change(transaction, catalog, access);
+ },
+ { isolationLevel: "read committed" },
+ );
+}
+
+async function auditSnapshot(
+ transaction: Transaction,
+ catalog: RbacCatalog,
+ operation: string,
+ targetId: string,
+) {
+ if (operation.startsWith("permission.")) {
+ const target = catalog.permissions.find((entry) => entry.id === targetId);
+ return {
+ target: target ?? null,
+ roles: catalog.roles
+ .filter((entry) => target && entry.permissions.includes(target.key))
+ .map((entry) => ({ id: entry.id, permissions: entry.permissions })),
+ incoming: catalog.permissions
+ .filter((entry) => target && entry.implies.includes(target.key))
+ .map((entry) => ({ id: entry.id, implies: entry.implies })),
+ };
+ }
+ const target = catalog.roles.find((entry) => entry.id === targetId);
+ return {
+ target: target ?? null,
+ children: catalog.roles
+ .filter((entry) => target && entry.parents.includes(target.key))
+ .map((entry) => ({ id: entry.id, parents: entry.parents })),
+ assignments: await transaction.select().from(userRole).where(eq(userRole.roleId, targetId)),
+ };
+}
+
async function withRbacWriteLock(
- change: (transaction: Transaction, catalog: RbacCatalog) => Promise,
+ actorId: string,
+ operation: string,
+ targetId: string,
+ change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
): Promise {
- return db.transaction(async (transaction) => {
- await transaction.execute(hierarchyLock);
- return change(transaction, await readCatalog(transaction));
- });
+ return withAuthorizedRbacWrite(
+ actorId,
+ operation.startsWith("permission.") ? "idp:permissions:write" : "idp:roles:write",
+ async (transaction, catalog, access) => {
+ const before = await auditSnapshot(transaction, catalog, operation, targetId);
+ const result = await change(transaction, catalog, access);
+ const after = await auditSnapshot(
+ transaction,
+ await readCatalog(transaction),
+ operation,
+ targetId,
+ );
+ await transaction
+ .insert(rbacAuditEvent)
+ .values({ id: randomUUID(), actorId, operation, targetId, before, after });
+ return result;
+ },
+ );
}
function requireRole(catalog: RbacCatalog, roleId: string) {
@@ -187,105 +260,127 @@ function checked(errors: Record) {
}
export async function savePermission(
+ actorId: string,
input: PermissionDraft,
permissionId?: string,
): Promise {
const draft = normalizePermissionDraft(input);
- const id = await withRbacWriteLock(async (transaction, catalog) => {
- const current = permissionId ? requirePermission(catalog, permissionId) : undefined;
- // A managed permission's key is what the identity provider's own checks look for.
- if (current?.managed && draft.key !== current.key)
- throw new RbacError(400, "The key of a built-in permission cannot be changed.", {
- key: "This permission is defined by the identity provider.",
- });
- checked(validatePermissionDraft(draft, { catalog, currentKey: current?.key }));
- const id = current?.id ?? randomUUID();
- const impliedIds = idsForPermissionKeys(catalog, draft.implies);
- const values = {
- key: draft.key,
- name: draft.name,
- description: draft.description || null,
- updatedAt: new Date(),
- };
- if (current) await transaction.update(permission).set(values).where(eq(permission.id, id));
- else await transaction.insert(permission).values({ id, ...values });
- await transaction
- .delete(permissionImplication)
- .where(eq(permissionImplication.permissionId, id));
- if (impliedIds.length)
+ const targetId = permissionId ?? randomUUID();
+ const id = await withRbacWriteLock(
+ actorId,
+ "permission.save",
+ targetId,
+ async (transaction, catalog) => {
+ const current = permissionId ? requirePermission(catalog, permissionId) : undefined;
+ // A managed permission's key is what the identity provider's own checks look for.
+ if (current?.managed && draft.key !== current.key)
+ throw new RbacError(400, "The key of a built-in permission cannot be changed.", {
+ key: "This permission is defined by the identity provider.",
+ });
+ checked(validatePermissionDraft(draft, { catalog, currentKey: current?.key }));
+ const id = targetId;
+ const impliedIds = idsForPermissionKeys(catalog, draft.implies);
+ const values = {
+ key: draft.key,
+ name: draft.name,
+ description: draft.description || null,
+ updatedAt: new Date(),
+ };
+ if (current) await transaction.update(permission).set(values).where(eq(permission.id, id));
+ else await transaction.insert(permission).values({ id, ...values });
await transaction
- .insert(permissionImplication)
- .values(
- impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })),
- );
- return id;
- });
+ .delete(permissionImplication)
+ .where(eq(permissionImplication.permissionId, id));
+ if (impliedIds.length)
+ await transaction
+ .insert(permissionImplication)
+ .values(
+ impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })),
+ );
+ return id;
+ },
+ );
const saved = (await loadCatalog()).permissions.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The permission could not be read back.");
return saved;
}
-export async function deletePermission(permissionId: string) {
- await withRbacWriteLock(async (transaction, catalog) => {
- const current = requirePermission(catalog, permissionId);
- if (current.managed)
- throw new RbacError(
- 400,
- "Permissions defined by the identity provider cannot be deleted. Remove it from the roles that carry it instead.",
- );
- await transaction.delete(permission).where(eq(permission.id, permissionId));
- });
+export async function deletePermission(actorId: string, permissionId: string) {
+ await withRbacWriteLock(
+ actorId,
+ "permission.delete",
+ permissionId,
+ async (transaction, catalog) => {
+ const current = requirePermission(catalog, permissionId);
+ if (current.managed)
+ throw new RbacError(
+ 400,
+ "Permissions defined by the identity provider cannot be deleted. Remove it from the roles that carry it instead.",
+ );
+ await transaction.delete(permission).where(eq(permission.id, permissionId));
+ },
+ );
}
-export async function saveRole(input: RoleDraft, roleId?: string): Promise {
+export async function saveRole(
+ actorId: string,
+ input: RoleDraft,
+ roleId?: string,
+): Promise {
const draft = normalizeRoleDraft(input);
- const id = await withRbacWriteLock(async (transaction, catalog) => {
- const current = roleId ? requireRole(catalog, roleId) : undefined;
- // A managed role's key is what ties it to the evidence that grants it.
- if (current?.managed && draft.key !== current.key)
- throw new RbacError(400, "The key of a built-in role cannot be changed.", {
- key: "This role is defined by the identity provider.",
- });
- // Master Admin already holds everything, so a stored grant list would only mislead.
- if (
- current?.key === MASTER_ADMIN_ROLE_KEY &&
- (draft.permissions.length > 0 || draft.parents.length > 0)
- )
- throw new RbacError(
- 400,
- `${current.name} already holds every permission, so it needs no grants of its own.`,
- );
- checked(validateRoleDraft(draft, { catalog, currentKey: current?.key }));
- const id = current?.id ?? randomUUID();
- const permissionIds = idsForPermissionKeys(catalog, draft.permissions);
- const parentIds = idsForRoleKeys(catalog, draft.parents);
- const values = {
- key: draft.key,
- name: draft.name,
- description: draft.description || null,
- updatedAt: new Date(),
- };
- if (current) await transaction.update(role).set(values).where(eq(role.id, id));
- else await transaction.insert(role).values({ id, managed: false, ...values });
- await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id));
- if (permissionIds.length)
- await transaction
- .insert(rolePermission)
- .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId })));
- await transaction.delete(roleParent).where(eq(roleParent.roleId, id));
- if (parentIds.length)
- await transaction
- .insert(roleParent)
- .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId })));
- return id;
- });
+ const targetId = roleId ?? randomUUID();
+ const id = await withRbacWriteLock(
+ actorId,
+ "role.save",
+ targetId,
+ async (transaction, catalog) => {
+ const current = roleId ? requireRole(catalog, roleId) : undefined;
+ // A managed role's key is what ties it to the evidence that grants it.
+ if (current?.managed && draft.key !== current.key)
+ throw new RbacError(400, "The key of a built-in role cannot be changed.", {
+ key: "This role is defined by the identity provider.",
+ });
+ // Master Admin already holds everything, so a stored grant list would only mislead.
+ if (
+ current?.key === MASTER_ADMIN_ROLE_KEY &&
+ (draft.permissions.length > 0 || draft.parents.length > 0)
+ )
+ throw new RbacError(
+ 400,
+ `${current.name} already holds every permission, so it needs no grants of its own.`,
+ );
+ checked(validateRoleDraft(draft, { catalog, currentKey: current?.key }));
+ const id = targetId;
+ const permissionIds = idsForPermissionKeys(catalog, draft.permissions);
+ const parentIds = idsForRoleKeys(catalog, draft.parents);
+ const values = {
+ key: draft.key,
+ name: draft.name,
+ description: draft.description || null,
+ updatedAt: new Date(),
+ };
+ if (current) await transaction.update(role).set(values).where(eq(role.id, id));
+ else await transaction.insert(role).values({ id, managed: false, ...values });
+ await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id));
+ if (permissionIds.length)
+ await transaction
+ .insert(rolePermission)
+ .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId })));
+ await transaction.delete(roleParent).where(eq(roleParent.roleId, id));
+ if (parentIds.length)
+ await transaction
+ .insert(roleParent)
+ .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId })));
+ return id;
+ },
+ );
const saved = (await loadCatalog()).roles.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The role could not be read back.");
return saved;
}
-export async function deleteRole(roleId: string) {
- await withRbacWriteLock(async (transaction, catalog) => {
+export async function deleteRole(actorId: string, roleId: string) {
+ await withRbacWriteLock(actorId, "role.delete", roleId, async (transaction, catalog) => {
const current = requireRole(catalog, roleId);
if (current.managed)
throw new RbacError(400, "Roles defined by the identity provider cannot be deleted.");
@@ -312,22 +407,27 @@ export async function listRoleMembers(roleId: string): Promise {
return rows.map((row) => ({ ...row, assignedAt: row.assignedAt?.toISOString() ?? null }));
}
-export async function assignRole(roleId: string, userId: string, assignedBy: string) {
- const catalog = await loadCatalog();
- const target = requireRole(catalog, roleId);
- // Membership of a managed role follows the evidence, never an administrator's decision.
- if (target.managed)
- throw new RbacError(
- 400,
- `${target.name} is granted automatically and cannot be assigned by hand.`,
- );
- const [found] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1);
- if (!found) throw new RbacError(404, "That person was not found.");
- await db.insert(userRole).values({ roleId, userId, assignedBy }).onConflictDoNothing();
+export async function assignRole(actorId: string, roleId: string, userId: string) {
+ await withRbacWriteLock(actorId, "role.assign", roleId, async (transaction, catalog) => {
+ const target = requireRole(catalog, roleId);
+ if (target.managed) throw new RbacError(400, "Managed roles cannot be assigned by hand.");
+ const [found] = await transaction.select({ id: user.id }).from(user).where(eq(user.id, userId));
+ if (!found) throw new RbacError(404, "That person was not found.");
+ await transaction
+ .insert(userRole)
+ .values({ roleId, userId, assignedBy: actorId })
+ .onConflictDoNothing();
+ });
}
-export async function unassignRole(roleId: string, userId: string) {
- await db.delete(userRole).where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId)));
+export async function unassignRole(actorId: string, roleId: string, userId: string) {
+ await withRbacWriteLock(actorId, "role.unassign", roleId, async (transaction, catalog) => {
+ const target = requireRole(catalog, roleId);
+ if (target.managed) throw new RbacError(400, "Managed roles cannot be revoked by hand.");
+ await transaction
+ .delete(userRole)
+ .where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId)));
+ });
}
/** People an administrator can pick when assigning a role. */
diff --git a/src/db/rbac.ts b/src/db/rbac.ts
index 1394c1a..38411af 100644
--- a/src/db/rbac.ts
+++ b/src/db/rbac.ts
@@ -1,6 +1,7 @@
import {
boolean,
index,
+ jsonb,
pgTable,
primaryKey,
text,
@@ -125,3 +126,14 @@ export const userRole = pgTable(
index("userRole_role_idx").on(table.roleId),
],
);
+
+/** Append-only security history; actor identifiers survive user deletion. */
+export const rbacAuditEvent = pgTable("rbac_audit_event", {
+ id: text().primaryKey(),
+ actorId: text("actor_id").notNull(),
+ operation: text().notNull(),
+ targetId: text("target_id").notNull(),
+ before: jsonb().notNull(),
+ after: jsonb().notNull(),
+ createdAt: now(),
+});
diff --git a/src/routes/api/rbac/permission-save.ts b/src/routes/api/rbac/permission-save.ts
index cb3e024..8a57f3c 100644
--- a/src/routes/api/rbac/permission-save.ts
+++ b/src/routes/api/rbac/permission-save.ts
@@ -27,10 +27,11 @@ export const Route = createFileRoute("/api/rbac/permission-save")({
const input = parsed.data;
try {
if (input.action === "delete") {
- await deletePermission(input.permissionId);
+ await deletePermission(guard.session.userId, input.permissionId);
return Response.json({ deleted: true }, { headers: noStore });
}
const saved = await savePermission(
+ guard.session.userId,
input.draft,
input.action === "update" ? input.permissionId : undefined,
);
diff --git a/src/routes/api/rbac/role-members.ts b/src/routes/api/rbac/role-members.ts
index 01c6453..447c577 100644
--- a/src/routes/api/rbac/role-members.ts
+++ b/src/routes/api/rbac/role-members.ts
@@ -31,8 +31,8 @@ export const Route = createFileRoute("/api/rbac/role-members")({
if (!parsed.success) return apiError(400, "Invalid request.");
const { action, roleId, userId } = parsed.data;
try {
- if (action === "assign") await assignRole(roleId, userId, guard.session.userId);
- else await unassignRole(roleId, userId);
+ if (action === "assign") await assignRole(guard.session.userId, roleId, userId);
+ else await unassignRole(guard.session.userId, roleId, userId);
return Response.json(await listRoleMembers(roleId), { headers: noStore });
} catch (cause) {
if (cause instanceof RbacError) return apiError(cause.status, cause.message);
diff --git a/src/routes/api/rbac/role-save.ts b/src/routes/api/rbac/role-save.ts
index 0598ca5..d77b5da 100644
--- a/src/routes/api/rbac/role-save.ts
+++ b/src/routes/api/rbac/role-save.ts
@@ -28,10 +28,11 @@ export const Route = createFileRoute("/api/rbac/role-save")({
const input = parsed.data;
try {
if (input.action === "delete") {
- await deleteRole(input.roleId);
+ await deleteRole(guard.session.userId, input.roleId);
return Response.json({ deleted: true }, { headers: noStore });
}
const saved = await saveRole(
+ guard.session.userId,
input.draft,
input.action === "update" ? input.roleId : undefined,
);
From c4b13d6c9f679553e05780ccc98300564636b5ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 13:14:42 +0000
Subject: [PATCH 06/15] fix: prevent writers from delegating or editing
authority above their own
---
README.md | 15 +-
src/auth/rbac-delegation.ts | 65 +++++++++
src/auth/rbac-security.integration.test.mjs | 149 ++++++++++++++++++++
src/auth/rbac-store.ts | 16 ++-
4 files changed, 235 insertions(+), 10 deletions(-)
create mode 100644 src/auth/rbac-delegation.ts
diff --git a/README.md b/README.md
index 26565c6..1619d78 100644
--- a/README.md
+++ b/README.md
@@ -130,10 +130,17 @@ the navigation only offers what you hold. Because Master Admin is a wildcard ove
permission, whoever the deployment configures as an administrator holds all of these, which
is the bootstrap and break-glass path: there is no second kind of check beside RBAC.
-Granting either write permission is real delegation. Someone with `idp:roles:write` can
-give themselves any other role. Someone with `idp:permissions:write` can make a permission
-they already hold imply another managed permission. Either can therefore acquire every
-stored capability short of Master Admin's wildcard. Treat both as you would root.
+Write permissions authorize bounded delegation. Only Master Admin can edit managed roles
+or permissions, including through custom ancestors or implications. Other writers can
+change, assign, revoke or delete only access within their current effective permissions;
+neither writer permission permits self-escalation. A new permission definition confers
+nothing: Master Admin must first grant it before others can delegate it. All checks use
+current authority inside the same serialized transaction as the mutation.
+
+Every RBAC mutation records its actor, operation, target and before/after state in
+`rbac_audit_event`. These events commit atomically with the change and reject updates,
+deletes and truncation. Database owners remain trusted and can disable triggers; export
+audit events to separately controlled storage if protection from database owners is needed.
### Assigning a role
diff --git a/src/auth/rbac-delegation.ts b/src/auth/rbac-delegation.ts
new file mode 100644
index 0000000..dc470dd
--- /dev/null
+++ b/src/auth/rbac-delegation.ts
@@ -0,0 +1,65 @@
+import {
+ type RbacCatalog,
+ type ResolvedAccess,
+ MASTER_ADMIN_ROLE_KEY,
+ effectiveRolePermissions,
+ expandPermissionKeys,
+ resolveAccess,
+} from "./rbac";
+
+/** Compare both committed and proposed graphs. Called under the mutation lock; a false
+ * result rolls back the entire transaction, including edge replacements and assignments. */
+export function mayDelegateMutation(
+ before: RbacCatalog,
+ after: RbacCatalog,
+ access: ResolvedAccess,
+ operation: string,
+ targetId: string,
+): boolean {
+ if (access.roles.includes(MASTER_ADMIN_ROLE_KEY)) return true;
+ const held = new Set(access.permissions);
+ const subset = (permissions: readonly string[]) => permissions.every((key) => held.has(key));
+ const same = (a: readonly string[], b: readonly string[]) =>
+ a.length === b.length && a.every((key, index) => key === b[index]);
+ if (operation.startsWith("permission.")) {
+ const old = before.permissions.find((entry) => entry.id === targetId);
+ const next = after.permissions.find((entry) => entry.id === targetId);
+ if (old?.managed || next?.managed) return false;
+ if (old && !subset(expandPermissionKeys(before, [old.key]))) return false;
+ // Defining a new capability does not confer it. Activating it requires a grant by
+ // someone who already holds it (initially Master Admin).
+ if (
+ next &&
+ !subset(expandPermissionKeys(after, [next.key]).filter((key) => old || key !== next.key))
+ )
+ return false;
+ } else {
+ const old = before.roles.find((entry) => entry.id === targetId);
+ const next = after.roles.find((entry) => entry.id === targetId);
+ if (old?.managed || next?.managed) return false;
+ if (old && !subset(effectiveRolePermissions(before, old.key))) return false;
+ if (next && !subset(effectiveRolePermissions(after, next.key))) return false;
+ }
+ if (!subset(resolveAccess(after, access.roles).permissions)) return false;
+ // Custom ancestors/implications cannot act as a backdoor for editing managed or
+ // more privileged roles, including roles the writer does not themselves hold.
+ for (const old of before.roles) {
+ // Catalog growth always extends the deployment-conferred wildcard; it does not
+ // confer new authority on the writer or alter Master Admin membership.
+ if (old.key === MASTER_ADMIN_ROLE_KEY) continue;
+ const next = after.roles.find((entry) => entry.id === old.id);
+ const previous = effectiveRolePermissions(before, old.key);
+ const proposed = next ? effectiveRolePermissions(after, next.key) : [];
+ if (!same(previous, proposed) && (old.managed || !subset(previous) || !subset(proposed)))
+ return false;
+ }
+ for (const old of before.permissions.filter((entry) => entry.managed)) {
+ const next = after.permissions.find((entry) => entry.id === old.id);
+ if (
+ !next ||
+ !same(expandPermissionKeys(before, [old.key]), expandPermissionKeys(after, [next.key]))
+ )
+ return false;
+ }
+ return true;
+}
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
index 959689e..1fbf6c4 100644
--- a/src/auth/rbac-security.integration.test.mjs
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -95,6 +95,155 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
await db.$client.end();
});
+ async function delegate(permissions) {
+ const id = unique("delegate");
+ await pool.query(
+ `INSERT INTO "user" (id, name, email) VALUES ($1, $1, $1 || '@identity.invalid')`,
+ [id],
+ );
+ const role = await saveRole(root, draftRole(unique("delegated"), permissions));
+ await assignRole(root, role.id, id);
+ return { id, role };
+ }
+
+ it("denies creating a privileged role, self-assigning it, and editing one's own role", async () => {
+ const actor = await delegate(["idp:roles:write"]);
+ const high = await saveRole(root, draftRole(unique("high"), ["idp:applications:write"]));
+ const key = unique("escalation");
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "create",
+ actorId: root,
+ draft: draftRole(key, ["idp:applications:write"]),
+ })
+ ).status,
+ ).toBe(403);
+ expect(
+ (await post(members, actor.id, { action: "assign", roleId: high.id, userId: actor.id }))
+ .status,
+ ).toBe(403);
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "update",
+ roleId: actor.role.id,
+ draft: draftRole(actor.role.key, ["idp:roles:write", "idp:applications:write"]),
+ })
+ ).status,
+ ).toBe(403);
+ expect((await getIdentity(actor.id)).permissions).not.toContain("idp:applications:write");
+ expect((await pool.query("SELECT id FROM role WHERE key = $1", [key])).rows).toEqual([]);
+ });
+
+ it("denies transitive role grants, revocation and deletion above one's authority", async () => {
+ const actor = await delegate(["idp:roles:write"]);
+ const high = await saveRole(root, draftRole(unique("high"), ["idp:applications:write"]));
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "create",
+ draft: draftRole(unique("inherited"), [], [high.key]),
+ })
+ ).status,
+ ).toBe(403);
+ expect((await post(roleSave, actor.id, { action: "delete", roleId: high.id })).status).toBe(
+ 403,
+ );
+ expect(
+ (await post(members, actor.id, { action: "unassign", roleId: high.id, userId: root })).status,
+ ).toBe(403);
+ });
+
+ it("denies escalation through managed and custom permission implications", async () => {
+ const own = await savePermission(root, draftPermission(unique("own")));
+ const actor = await delegate(["idp:permissions:write", own.key]);
+ const managed = (await loadCatalog()).permissions.find(
+ (entry) => entry.key === "idp:permissions:write",
+ );
+ for (const target of [managed, own]) {
+ expect(
+ (
+ await post(permissionSave, actor.id, {
+ action: "update",
+ permissionId: target.id,
+ draft: draftPermission(target.key, ["idp:applications:write"]),
+ })
+ ).status,
+ ).toBe(403);
+ }
+ expect(
+ (
+ await post(permissionSave, actor.id, {
+ action: "update",
+ permissionId: own.id,
+ draft: draftPermission(unique("renamed-authority")),
+ })
+ ).status,
+ ).toBe(403);
+ expect((await getIdentity(actor.id)).permissions).not.toContain("idp:applications:write");
+ });
+
+ it("denies bootstrapping new authority with both writer permissions", async () => {
+ const actor = await delegate(["idp:roles:write", "idp:permissions:write"]);
+ const permission = await savePermission(actor.id, draftPermission(unique("new-capability")));
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "create",
+ draft: draftRole(unique("new-capability"), [permission.key]),
+ })
+ ).status,
+ ).toBe(403);
+ expect((await getIdentity(actor.id)).permissions).not.toContain(permission.key);
+ expect(
+ (
+ await post(permissionSave, actor.id, {
+ action: "create",
+ draft: draftPermission(unique("laundered"), ["idp:applications:write"]),
+ })
+ ).status,
+ ).toBe(403);
+ });
+
+ it("preserves bounded delegation while refusing direct and indirect managed-role edits", async () => {
+ const own = await savePermission(root, draftPermission(unique("own")));
+ const actor = await delegate(["idp:roles:write", own.key]);
+ const low = await saveRole(actor.id, draftRole(unique("low"), [own.key]));
+ await assignRole(actor.id, low.id, ordinary);
+ expect((await getIdentity(ordinary)).permissions).toContain(own.key);
+ await unassignRole(actor.id, low.id, ordinary);
+ const managed = (await loadCatalog()).roles.find((entry) => entry.key === "socio");
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "update",
+ roleId: managed.id,
+ draft: draftRole(managed.key, [own.key]),
+ })
+ ).status,
+ ).toBe(403);
+ const parent = await saveRole(root, draftRole(unique("managed-parent")));
+ await saveRole(root, draftRole(managed.key, managed.permissions, [parent.key]), managed.id);
+ try {
+ expect(
+ (
+ await post(roleSave, actor.id, {
+ action: "update",
+ roleId: parent.id,
+ draft: draftRole(parent.key, [own.key]),
+ })
+ ).status,
+ ).toBe(403);
+ } finally {
+ await saveRole(
+ root,
+ draftRole(managed.key, managed.permissions, managed.parents),
+ managed.id,
+ );
+ }
+ });
+
it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => {
const role = await saveRole(root, draftRole(unique("target")));
const permission = await savePermission(root, draftPermission(unique("permission")));
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index 7d92ddd..72aec6c 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -1,3 +1,4 @@
+import { mayDelegateMutation } from "./rbac-delegation";
import { logAuthorizationDenial } from "./denial-log";
import { readIdentitySubject } from "./identity-subject";
import type { IdentityClaims } from "./policy";
@@ -212,12 +213,15 @@ async function withRbacWriteLock(
async (transaction, catalog, access) => {
const before = await auditSnapshot(transaction, catalog, operation, targetId);
const result = await change(transaction, catalog, access);
- const after = await auditSnapshot(
- transaction,
- await readCatalog(transaction),
- operation,
- targetId,
- );
+ const next = await readCatalog(transaction);
+ if (!mayDelegateMutation(catalog, next, access, operation, targetId)) {
+ logAuthorizationDenial(actorId, "rbac-store", ["bounded-delegation"]);
+ throw new RbacError(
+ 403,
+ "This change exceeds your delegated authority. Ask a Master Admin.",
+ );
+ }
+ const after = await auditSnapshot(transaction, next, operation, targetId);
await transaction
.insert(rbacAuditEvent)
.values({ id: randomUUID(), actorId, operation, targetId, before, after });
From eeb13943af286a9924e40b96688186881afa5d1e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 17:36:25 +0000
Subject: [PATCH 07/15] fix: serialize evidence changes to prevent verification
replay and lost attempt counts
---
src/auth/accounts.ts | 35 +--
src/auth/rbac-security.integration.test.mjs | 89 +++++++-
src/auth/rbac-store.ts | 8 +-
src/auth/student-verification.ts | 240 ++++++++++----------
src/db/security-lock.ts | 5 +
5 files changed, 242 insertions(+), 135 deletions(-)
create mode 100644 src/db/security-lock.ts
diff --git a/src/auth/accounts.ts b/src/auth/accounts.ts
index ea9c6c1..6cf09b6 100644
--- a/src/auth/accounts.ts
+++ b/src/auth/accounts.ts
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { account } from "../db/auth-schema";
import { db } from "../db/index";
+import { authorizationMutationLock } from "../db/security-lock";
import { isLoginProvider } from "./policy";
export class AccountError extends Error {
@@ -13,17 +14,25 @@ export class AccountError extends Error {
}
export async function disconnectAccount(userId: string, accountId: string) {
- const accounts = await db
- .select({ id: account.id, providerId: account.providerId })
- .from(account)
- .where(eq(account.userId, userId));
- const selected = accounts.find((candidate) => candidate.id === accountId);
- if (!selected) throw new AccountError(404, "Connected account not found.");
- if (
- isLoginProvider(selected.providerId) &&
- accounts.filter((candidate) => isLoginProvider(candidate.providerId)).length <= 1
- ) {
- throw new AccountError(400, "Connect another login method before disconnecting this one.");
- }
- await db.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId)));
+ await db.transaction(
+ async (transaction) => {
+ await transaction.execute(authorizationMutationLock);
+ const accounts = await transaction
+ .select({ id: account.id, providerId: account.providerId })
+ .from(account)
+ .where(eq(account.userId, userId));
+ const selected = accounts.find((candidate) => candidate.id === accountId);
+ if (!selected) throw new AccountError(404, "Connected account not found.");
+ if (
+ isLoginProvider(selected.providerId) &&
+ accounts.filter((candidate) => isLoginProvider(candidate.providerId)).length <= 1
+ ) {
+ throw new AccountError(400, "Connect another login method before disconnecting this one.");
+ }
+ await transaction
+ .delete(account)
+ .where(and(eq(account.id, accountId), eq(account.userId, userId)));
+ },
+ { isolationLevel: "read committed" },
+ );
}
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
index 1fbf6c4..3492cbb 100644
--- a/src/auth/rbac-security.integration.test.mjs
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -1,8 +1,8 @@
-import { randomUUID } from "node:crypto";
+import { createHmac, randomUUID } from "node:crypto";
import { Pool } from "pg";
import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test";
-const mocks = vi.hoisted(() => ({ graph: vi.fn().mockResolvedValue(false) }));
+const mocks = vi.hoisted(() => ({ graph: vi.fn().mockResolvedValue(false), sendEmail: vi.fn() }));
vi.mock("../env", () => {
const url = new URL(
process.env.RBAC_TEST_DATABASE_URL ??
@@ -17,6 +17,7 @@ vi.mock("../env", () => {
DB_NAME: url.pathname.slice(1),
BETTER_AUTH_URL: "http://localhost:35439",
BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ STUDENT_VERIFICATION_TTL_DAYS: 365,
IDP_ADMIN_USER_IDS: ["security-root"],
PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111",
PN_ENTRA_MEMBER_GROUP_ID: "soci",
@@ -38,7 +39,14 @@ vi.mock("./index", () => ({
},
}));
+vi.mock("./email", () => ({
+ studentVerificationEmailConfigured: true,
+ sendStudentVerificationEmail: mocks.sendEmail,
+}));
+
import { db } from "../db/index";
+import { confirmStudentVerification, requestStudentVerification } from "./student-verification";
+import { disconnectAccount } from "./accounts";
import { getIdentity } from "./identity";
import {
assignRole,
@@ -244,6 +252,83 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
}
});
+ async function challenge(actor) {
+ const email = `${unique("student")}@mail.polimi.it`;
+ const code = "123456";
+ const hash = createHmac("sha256", "test-only-secret-with-at-least-32-characters")
+ .update(`${actor}:${email}:${code}`)
+ .digest("hex");
+ await pool.query(
+ `INSERT INTO student_verification_challenge (user_id, email, code_hash, expires_at, last_sent_at) VALUES ($1, $2, $3, now() + interval '10 minutes', now())`,
+ [actor, email, hash],
+ );
+ return { email, code };
+ }
+
+ it("denies a correct student code after five concurrent wrong attempts", async () => {
+ const actor = await delegate([]);
+ const input = await challenge(actor.id);
+ const guesses = await Promise.allSettled(
+ Array.from({ length: 5 }, () =>
+ confirmStudentVerification(actor.id, { ...input, code: "000000" }),
+ ),
+ );
+ expect(guesses.every((entry) => entry.status === "rejected")).toBe(true);
+ await expect(confirmStudentVerification(actor.id, input)).rejects.toMatchObject({
+ status: 400,
+ });
+ expect((await getIdentity(actor.id)).roles).not.toContain("student");
+ });
+
+ it("denies concurrent code replay and prevents cross-user code consumption", async () => {
+ const actor = await delegate([]);
+ const input = await challenge(actor.id);
+ await expect(confirmStudentVerification(ordinary, input)).rejects.toMatchObject({
+ status: 400,
+ });
+ const results = await Promise.allSettled([
+ confirmStudentVerification(actor.id, input),
+ confirmStudentVerification(actor.id, input),
+ ]);
+ expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((entry) => entry.status === "rejected")).toHaveLength(1);
+ expect((await getIdentity(ordinary)).roles).not.toContain("student");
+ });
+
+ it("denies concurrent resend attempts inside the cooldown", async () => {
+ const actor = await delegate([]);
+ const email = `${unique("resend")}@mail.polimi.it`;
+ const results = await Promise.allSettled(
+ Array.from({ length: 3 }, () => requestStudentVerification(actor.id, email)),
+ );
+ expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1);
+ expect(
+ results
+ .filter((entry) => entry.status === "rejected")
+ .every((entry) => entry.reason.status === 429),
+ ).toBe(true);
+ });
+
+ it("denies cross-user unlink and concurrent removal of the last login account", async () => {
+ const actor = await delegate([]);
+ const first = unique("google");
+ const second = unique("entra");
+ await pool.query(
+ `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'google', 'google', $3, now()), ($2, $2, 'pn-entra', 'pn-entra', $3, now())`,
+ [first, second, actor.id],
+ );
+ await expect(disconnectAccount(ordinary, first)).rejects.toMatchObject({ status: 404 });
+ const results = await Promise.allSettled([
+ disconnectAccount(actor.id, first),
+ disconnectAccount(actor.id, second),
+ ]);
+ expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((entry) => entry.status === "rejected")).toHaveLength(1);
+ expect(
+ (await pool.query(`SELECT id FROM account WHERE user_id = $1`, [actor.id])).rows,
+ ).toHaveLength(1);
+ });
+
it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => {
const role = await saveRole(root, draftRole(unique("target")));
const permission = await savePermission(root, draftPermission(unique("permission")));
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index 72aec6c..d1d6084 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -3,8 +3,9 @@ import { logAuthorizationDenial } from "./denial-log";
import { readIdentitySubject } from "./identity-subject";
import type { IdentityClaims } from "./policy";
import { randomUUID } from "node:crypto";
-import { and, count, desc, eq, ilike, or, sql } from "drizzle-orm";
+import { and, count, desc, eq, ilike, or } from "drizzle-orm";
import { db } from "../db/index";
+import { authorizationMutationLock } from "../db/security-lock";
import {
rbacAuditEvent,
permission,
@@ -137,9 +138,6 @@ export async function loadCatalog(): Promise {
});
}
-// Follows the convention the migration bootstrap uses for its own lock.
-const hierarchyLock = sql`select pg_advisory_xact_lock(hashtext('polinetwork-auth'), hashtext('rbac-hierarchy'))`;
-
/**
* Runs a change to the role or permission graph against the graph as it actually is.
*
@@ -156,7 +154,7 @@ export async function withAuthorizedRbacWrite(
): Promise {
return db.transaction(
async (transaction) => {
- await transaction.execute(hierarchyLock);
+ await transaction.execute(authorizationMutationLock);
const subject = await readIdentitySubject(actorId, transaction);
const catalog = await readCatalog(transaction);
const access = resolveAccess(catalog, [
diff --git a/src/auth/student-verification.ts b/src/auth/student-verification.ts
index c239ff7..b4a85be 100644
--- a/src/auth/student-verification.ts
+++ b/src/auth/student-verification.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
import { account } from "../db/auth-schema";
import { identityEvidence, studentVerificationChallenge } from "../db/evidence";
import { db } from "../db/index";
+import { authorizationMutationLock } from "../db/security-lock";
import { env } from "../env";
import { sendStudentVerificationEmail, studentVerificationEmailConfigured } from "./email";
import { hasPolimiStudentDomain } from "./policy";
@@ -47,41 +48,43 @@ export async function requestStudentVerification(userId: string, input: unknown)
throw new StudentVerificationError(503, "Student email verification is not configured.");
}
const email = parsePolimiStudentEmail(input);
- const now = new Date();
- const [recent] = await db
- .select({ lastSentAt: studentVerificationChallenge.lastSentAt })
- .from(studentVerificationChallenge)
- .where(
- or(
- eq(studentVerificationChallenge.userId, userId),
- eq(studentVerificationChallenge.email, email),
- ),
- )
- .orderBy(desc(studentVerificationChallenge.lastSentAt))
- .limit(1);
- if (recent && now.getTime() - recent.lastSentAt.getTime() < RESEND_DELAY_MS) {
- throw new StudentVerificationError(429, "Wait one minute before requesting another code.");
- }
-
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
const codeHash = hashCode(userId, email, code);
- await db.transaction(async (transaction) => {
- await transaction
- .delete(studentVerificationChallenge)
- .where(
- or(
- eq(studentVerificationChallenge.userId, userId),
- eq(studentVerificationChallenge.email, email),
- ),
- );
- await transaction.insert(studentVerificationChallenge).values({
- userId,
- email,
- codeHash,
- expiresAt: new Date(now.getTime() + CODE_LIFETIME_MS),
- lastSentAt: now,
- });
- });
+ await db.transaction(
+ async (transaction) => {
+ await transaction.execute(authorizationMutationLock);
+ const now = new Date();
+ const [recent] = await transaction
+ .select({ lastSentAt: studentVerificationChallenge.lastSentAt })
+ .from(studentVerificationChallenge)
+ .where(
+ or(
+ eq(studentVerificationChallenge.userId, userId),
+ eq(studentVerificationChallenge.email, email),
+ ),
+ )
+ .orderBy(desc(studentVerificationChallenge.lastSentAt))
+ .limit(1);
+ if (recent && now.getTime() - recent.lastSentAt.getTime() < RESEND_DELAY_MS)
+ throw new StudentVerificationError(429, "Wait one minute before requesting another code.");
+ await transaction
+ .delete(studentVerificationChallenge)
+ .where(
+ or(
+ eq(studentVerificationChallenge.userId, userId),
+ eq(studentVerificationChallenge.email, email),
+ ),
+ );
+ await transaction.insert(studentVerificationChallenge).values({
+ userId,
+ email,
+ codeHash,
+ expiresAt: new Date(now.getTime() + CODE_LIFETIME_MS),
+ lastSentAt: now,
+ });
+ },
+ { isolationLevel: "read committed" },
+ );
try {
await sendStudentVerificationEmail(email, code);
@@ -110,90 +113,97 @@ export async function confirmStudentVerification(
.safeParse(input.code);
if (!code.success) throw new StudentVerificationError(400, "Enter the six-digit code.");
- const [challenge] = await db
- .select()
- .from(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId))
- .limit(1);
- if (!challenge || challenge.email !== email || challenge.expiresAt <= new Date()) {
- if (challenge) {
- await db
- .delete(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId));
- }
- throw new StudentVerificationError(400, "The code is invalid or expired.");
- }
+ const result = await db.transaction(
+ async (transaction) => {
+ await transaction.execute(authorizationMutationLock);
+ const [challenge] = await transaction
+ .select()
+ .from(studentVerificationChallenge)
+ .where(eq(studentVerificationChallenge.userId, userId))
+ .limit(1);
+ if (!challenge || challenge.email !== email || challenge.expiresAt <= new Date()) {
+ if (challenge) {
+ await transaction
+ .delete(studentVerificationChallenge)
+ .where(eq(studentVerificationChallenge.userId, userId));
+ }
+ return new StudentVerificationError(400, "The code is invalid or expired.");
+ }
- if (!codeMatches(challenge.codeHash, hashCode(userId, email, code.data))) {
- if (challenge.attempts + 1 >= MAX_ATTEMPTS) {
- await db
- .delete(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId));
- } else {
- await db
- .update(studentVerificationChallenge)
- .set({ attempts: challenge.attempts + 1 })
- .where(eq(studentVerificationChallenge.userId, userId));
- }
- throw new StudentVerificationError(400, "The code is invalid or expired.");
- }
+ if (!codeMatches(challenge.codeHash, hashCode(userId, email, code.data))) {
+ if (challenge.attempts + 1 >= MAX_ATTEMPTS) {
+ await transaction
+ .delete(studentVerificationChallenge)
+ .where(eq(studentVerificationChallenge.userId, userId));
+ } else {
+ await transaction
+ .update(studentVerificationChallenge)
+ .set({ attempts: challenge.attempts + 1 })
+ .where(eq(studentVerificationChallenge.userId, userId));
+ }
+ return new StudentVerificationError(400, "The code is invalid or expired.");
+ }
- const now = new Date();
- const validUntil = new Date(
- now.getTime() + env.STUDENT_VERIFICATION_TTL_DAYS * 24 * 60 * 60 * 1_000,
- );
- await db.transaction(async (transaction) => {
- const [currentStudentAccount] = await transaction
- .select({ accountId: account.accountId })
- .from(account)
- .where(and(eq(account.userId, userId), eq(account.providerId, "polimi-email")))
- .limit(1);
- if (currentStudentAccount && currentStudentAccount.accountId !== email) {
- throw new StudentVerificationError(
- 409,
- "Unlink your current Polimi email before linking another one.",
+ const now = new Date();
+ const validUntil = new Date(
+ now.getTime() + env.STUDENT_VERIFICATION_TTL_DAYS * 24 * 60 * 60 * 1_000,
);
- }
- const [existingAccount] = await transaction
- .select({ id: account.id, userId: account.userId })
- .from(account)
- .where(and(eq(account.issuer, POLIMI_EMAIL_ISSUER), eq(account.accountId, email)))
- .limit(1);
- if (existingAccount && existingAccount.userId !== userId) {
- throw new StudentVerificationError(
- 409,
- "This Polimi email is already connected to another account.",
- );
- }
- if (!existingAccount) {
- await transaction.insert(account).values({
- id: randomUUID(),
- accountId: email,
- providerId: "polimi-email",
+ const [currentStudentAccount] = await transaction
+ .select({ accountId: account.accountId })
+ .from(account)
+ .where(and(eq(account.userId, userId), eq(account.providerId, "polimi-email")))
+ .limit(1);
+ if (currentStudentAccount && currentStudentAccount.accountId !== email) {
+ throw new StudentVerificationError(
+ 409,
+ "Unlink your current Polimi email before linking another one.",
+ );
+ }
+ const [existingAccount] = await transaction
+ .select({ id: account.id, userId: account.userId })
+ .from(account)
+ .where(and(eq(account.issuer, POLIMI_EMAIL_ISSUER), eq(account.accountId, email)))
+ .limit(1);
+ if (existingAccount && existingAccount.userId !== userId) {
+ throw new StudentVerificationError(
+ 409,
+ "This Polimi email is already connected to another account.",
+ );
+ }
+ if (!existingAccount) {
+ await transaction.insert(account).values({
+ id: randomUUID(),
+ accountId: email,
+ providerId: "polimi-email",
+ issuer: POLIMI_EMAIL_ISSUER,
+ userId,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+ const proof = {
issuer: POLIMI_EMAIL_ISSUER,
- userId,
- createdAt: now,
- updatedAt: now,
- });
- }
- const proof = {
- issuer: POLIMI_EMAIL_ISSUER,
- subject: email,
- providerId: "polimi-email",
- states: ["student"],
- validUntil,
- telegramId: null,
- };
- await transaction
- .insert(identityEvidence)
- .values(proof)
- .onConflictDoUpdate({
- target: [identityEvidence.issuer, identityEvidence.subject],
- set: proof,
- });
- await transaction
- .delete(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId));
- });
- return { email, validUntil };
+ subject: email,
+ providerId: "polimi-email",
+ states: ["student"],
+ validUntil,
+ telegramId: null,
+ };
+ await transaction
+ .insert(identityEvidence)
+ .values(proof)
+ .onConflictDoUpdate({
+ target: [identityEvidence.issuer, identityEvidence.subject],
+ set: proof,
+ });
+ await transaction
+ .delete(studentVerificationChallenge)
+ .where(eq(studentVerificationChallenge.userId, userId));
+ return { email, validUntil };
+ },
+ { isolationLevel: "read committed" },
+ );
+ // Failed attempts must commit their counter/removal before returning a denial.
+ if (result instanceof StudentVerificationError) throw result;
+ return result;
}
diff --git a/src/db/security-lock.ts b/src/db/security-lock.ts
new file mode 100644
index 0000000..a5ef432
--- /dev/null
+++ b/src/db/security-lock.ts
@@ -0,0 +1,5 @@
+import { sql } from "drizzle-orm";
+
+/** All local mutations of authorization graphs, assignments and linked evidence serialize
+ * on this transaction-scoped lock. Use READ COMMITTED and read only after acquiring it. */
+export const authorizationMutationLock = sql`select pg_advisory_xact_lock(hashtext('polinetwork-auth'), hashtext('rbac-hierarchy'))`;
From a3d14a45a9ccb46691bc2468c5bf1c06d8e9e9e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 17:40:13 +0000
Subject: [PATCH 08/15] fix: enforce repository read boundaries and stop
write-only member disclosure
---
src/auth/oidc-registry.ts | 80 +++++++++++-------
src/auth/rbac-security.integration.test.mjs | 44 +++++++++-
src/auth/rbac-store.ts | 94 ++++++++++++++-------
src/routes/api/oidc/client-update.ts | 6 +-
src/routes/api/oidc/clients.ts | 4 +-
src/routes/api/rbac/catalog.ts | 3 +-
src/routes/api/rbac/role-members.ts | 11 ++-
src/routes/api/rbac/users.ts | 2 +-
8 files changed, 172 insertions(+), 72 deletions(-)
diff --git a/src/auth/oidc-registry.ts b/src/auth/oidc-registry.ts
index bca86df..9e93f6b 100644
--- a/src/auth/oidc-registry.ts
+++ b/src/auth/oidc-registry.ts
@@ -1,5 +1,6 @@
import { and, countDistinct, desc, eq } from "drizzle-orm";
import { db } from "../db/index";
+import { withAuthorizedRbacRead, withAuthorizedRbacWrite } from "./rbac-store";
import { oauthClient, oauthConsent } from "../db/schema";
import {
grantTypesForScopes,
@@ -33,16 +34,19 @@ function toSummary(row: ClientRow, authorizedUsers: number): OidcClientSummary {
}
/** Clients in the shared PoliNetwork pool, newest first, with how many people authorized each. */
-export async function listOidcClients(clientId?: string): Promise {
+async function readOidcClients(
+ reader: Pick,
+ clientId?: string,
+): Promise {
const filter = clientId
? and(eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE), eq(oauthClient.clientId, clientId))
: eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE);
- const rows = await db
+ const rows = await reader
.select()
.from(oauthClient)
.where(filter)
.orderBy(desc(oauthClient.createdAt));
- const consents = await db
+ const consents = await reader
.select({ clientId: oauthConsent.clientId, users: countDistinct(oauthConsent.userId) })
.from(oauthConsent)
.groupBy(oauthConsent.clientId);
@@ -50,6 +54,15 @@ export async function listOidcClients(clientId?: string): Promise toSummary(row, usersByClient.get(row.clientId) ?? 0));
}
+export async function listOidcClients(
+ actorId: string,
+ clientId?: string,
+): Promise {
+ return withAuthorizedRbacRead(actorId, ["idp:applications:read"], (transaction) =>
+ readOidcClients(transaction, clientId),
+ );
+}
+
export type OidcClientPatch = {
draft?: OidcClientDraft;
disabled?: boolean;
@@ -58,36 +71,39 @@ export type OidcClientPatch = {
/** Applies validated settings to a pooled client. Returns null when the client is not in the pool. */
export async function updateOidcClient(
+ actorId: string,
clientId: string,
patch: OidcClientPatch,
): Promise {
- const values: Partial = { updatedAt: new Date() };
- if (patch.draft) {
- const draft = patch.draft;
- values.name = draft.name;
- values.uri = draft.uri || null;
- values.icon = draft.logo || null;
- values.redirectUris = draft.redirectUris;
- values.postLogoutRedirectUris = draft.postLogoutRedirectUris.length
- ? draft.postLogoutRedirectUris
- : null;
- values.contacts = draft.contacts.length ? draft.contacts : null;
- values.tos = draft.tosUri || null;
- values.policy = draft.policyUri || null;
- values.scopes = draft.scopes;
- values.grantTypes = grantTypesForScopes(draft.scopes);
- values.applicationType = draft.applicationType;
- }
- if (patch.disabled !== undefined) values.disabled = patch.disabled;
- if (patch.skipConsent !== undefined) values.skipConsent = patch.skipConsent;
- const [updated] = await db
- .update(oauthClient)
- .set(values)
- .where(
- and(eq(oauthClient.clientId, clientId), eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE)),
- )
- .returning();
- if (!updated) return null;
- const [summary] = await listOidcClients(clientId);
- return summary ?? toSummary(updated, 0);
+ return withAuthorizedRbacWrite(actorId, "idp:applications:write", async (transaction) => {
+ const values: Partial = { updatedAt: new Date() };
+ if (patch.draft) {
+ const draft = patch.draft;
+ values.name = draft.name;
+ values.uri = draft.uri || null;
+ values.icon = draft.logo || null;
+ values.redirectUris = draft.redirectUris;
+ values.postLogoutRedirectUris = draft.postLogoutRedirectUris.length
+ ? draft.postLogoutRedirectUris
+ : null;
+ values.contacts = draft.contacts.length ? draft.contacts : null;
+ values.tos = draft.tosUri || null;
+ values.policy = draft.policyUri || null;
+ values.scopes = draft.scopes;
+ values.grantTypes = grantTypesForScopes(draft.scopes);
+ values.applicationType = draft.applicationType;
+ }
+ if (patch.disabled !== undefined) values.disabled = patch.disabled;
+ if (patch.skipConsent !== undefined) values.skipConsent = patch.skipConsent;
+ const [updated] = await transaction
+ .update(oauthClient)
+ .set(values)
+ .where(
+ and(eq(oauthClient.clientId, clientId), eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE)),
+ )
+ .returning();
+ if (!updated) return null;
+ const [summary] = await readOidcClients(transaction, clientId);
+ return summary ?? toSummary(updated, 0);
+ });
}
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
index 3492cbb..2e2835b 100644
--- a/src/auth/rbac-security.integration.test.mjs
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -48,11 +48,14 @@ import { db } from "../db/index";
import { confirmStudentVerification, requestStudentVerification } from "./student-verification";
import { disconnectAccount } from "./accounts";
import { getIdentity } from "./identity";
+import { listOidcClients, updateOidcClient } from "./oidc-registry";
import {
assignRole,
deletePermission,
deleteRole,
loadCatalog,
+ listRoleMembers,
+ searchUsers,
savePermission,
saveRole,
unassignRole,
@@ -166,7 +169,7 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
it("denies escalation through managed and custom permission implications", async () => {
const own = await savePermission(root, draftPermission(unique("own")));
const actor = await delegate(["idp:permissions:write", own.key]);
- const managed = (await loadCatalog()).permissions.find(
+ const managed = (await loadCatalog(root)).permissions.find(
(entry) => entry.key === "idp:permissions:write",
);
for (const target of [managed, own]) {
@@ -221,7 +224,7 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
await assignRole(actor.id, low.id, ordinary);
expect((await getIdentity(ordinary)).permissions).toContain(own.key);
await unassignRole(actor.id, low.id, ordinary);
- const managed = (await loadCatalog()).roles.find((entry) => entry.key === "socio");
+ const managed = (await loadCatalog(root)).roles.find((entry) => entry.key === "socio");
expect(
(
await post(roleSave, actor.id, {
@@ -329,6 +332,39 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
).toHaveLength(1);
});
+ it("denies unguarded repository reads and application mutations", async () => {
+ for (const operation of [
+ () => loadCatalog(ordinary),
+ () => listRoleMembers(ordinary, "unknown"),
+ () => searchUsers(ordinary, ""),
+ () => listOidcClients(ordinary),
+ () => updateOidcClient(ordinary, "unknown", { disabled: true }),
+ ])
+ await expect(operation()).rejects.toMatchObject({ status: 403 });
+ });
+
+ it("does not leak role members through a write-only membership response", async () => {
+ const managed = (await loadCatalog(root)).permissions.find(
+ (entry) => entry.key === "idp:roles:write",
+ );
+ await savePermission(root, draftPermission(managed.key), managed.id);
+ try {
+ const actor = await delegate([managed.key]);
+ const role = await saveRole(root, draftRole(unique("private-members")));
+ await assignRole(root, role.id, ordinary);
+ const response = await post(members, actor.id, {
+ action: "assign",
+ roleId: role.id,
+ userId: actor.id,
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual([]);
+ await expect(listRoleMembers(actor.id, role.id)).rejects.toMatchObject({ status: 403 });
+ } finally {
+ await savePermission(root, draftPermission(managed.key, managed.implies), managed.id);
+ }
+ });
+
it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => {
const role = await saveRole(root, draftRole(unique("target")));
const permission = await savePermission(root, draftPermission(unique("permission")));
@@ -371,13 +407,13 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
});
it("does not revive removed implications when the catalog is reloaded", async () => {
- const catalog = await loadCatalog();
+ const catalog = await loadCatalog(root);
const managed = catalog.permissions.find((entry) => entry.key === "idp:applications:write");
await savePermission(root, draftPermission(managed.key), managed.id);
const role = await saveRole(root, draftRole(unique("appwriter"), [managed.key]));
await assignRole(root, role.id, ordinary);
expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
- await loadCatalog();
+ await loadCatalog(root);
expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
await unassignRole(root, role.id, ordinary);
await savePermission(root, draftPermission(managed.key, ["idp:applications:read"]), managed.id);
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index d1d6084..35eb0b0 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -28,6 +28,7 @@ import {
type UserSearchResult,
MASTER_ADMIN_ROLE_KEY,
hasDraftErrors,
+ catalogForIdpPermissions,
normalizePermissionDraft,
normalizeRoleDraft,
resolveAccess,
@@ -131,13 +132,45 @@ async function readCatalog(db: CatalogReader): Promise {
};
}
-export async function loadCatalog(): Promise {
+async function loadCatalogSnapshot(): Promise {
return db.transaction((transaction) => readCatalog(transaction), {
isolationLevel: "repeatable read",
accessMode: "read only",
});
}
+/** Read guards and protected data share one database snapshot. */
+export async function withAuthorizedRbacRead(
+ actorId: string,
+ required: readonly ManagedPermissionKey[],
+ read: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
+): Promise {
+ return db.transaction(
+ async (transaction) => {
+ const subject = await readIdentitySubject(actorId, transaction);
+ const catalog = await readCatalog(transaction);
+ const access = resolveAccess(catalog, [
+ ...(await assignedRoleKeys(actorId, catalog, transaction)),
+ ...subject.roleKeys,
+ ]);
+ if (!required.some((key) => access.permissions.includes(key))) {
+ logAuthorizationDenial(actorId, "rbac-store", required);
+ throw new RbacError(403, "You do not have permission to do that.");
+ }
+ return read(transaction, catalog, access);
+ },
+ { isolationLevel: "repeatable read", accessMode: "read only" },
+ );
+}
+
+export async function loadCatalog(actorId: string): Promise {
+ return withAuthorizedRbacRead(
+ actorId,
+ ["idp:roles:read", "idp:permissions:read"],
+ async (_transaction, catalog, access) => catalogForIdpPermissions(catalog, access.permissions),
+ );
+}
+
/**
* Runs a change to the role or permission graph against the graph as it actually is.
*
@@ -149,7 +182,7 @@ export async function loadCatalog(): Promise {
*/
export async function withAuthorizedRbacWrite(
actorId: string,
- required: ManagedPermissionKey,
+ required: Extract,
change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
): Promise {
return db.transaction(
@@ -302,7 +335,7 @@ export async function savePermission(
return id;
},
);
- const saved = (await loadCatalog()).permissions.find((entry) => entry.id === id);
+ const saved = (await loadCatalogSnapshot()).permissions.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The permission could not be read back.");
return saved;
}
@@ -376,7 +409,7 @@ export async function saveRole(
return id;
},
);
- const saved = (await loadCatalog()).roles.find((entry) => entry.id === id);
+ const saved = (await loadCatalogSnapshot()).roles.find((entry) => entry.id === id);
if (!saved) throw new RbacError(500, "The role could not be read back.");
return saved;
}
@@ -390,23 +423,24 @@ export async function deleteRole(actorId: string, roleId: string) {
});
}
-export async function listRoleMembers(roleId: string): Promise {
- const catalog = await loadCatalog();
- requireRole(catalog, roleId);
- const rows = await db
- .select({
- userId: user.id,
- name: user.name,
- email: user.email,
- image: user.image,
- assignedAt: userRole.assignedAt,
- assignedBy: userRole.assignedBy,
- })
- .from(userRole)
- .innerJoin(user, eq(user.id, userRole.userId))
- .where(eq(userRole.roleId, roleId))
- .orderBy(desc(userRole.assignedAt));
- return rows.map((row) => ({ ...row, assignedAt: row.assignedAt?.toISOString() ?? null }));
+export async function listRoleMembers(actorId: string, roleId: string): Promise {
+ return withAuthorizedRbacRead(actorId, ["idp:roles:read"], async (transaction, catalog) => {
+ requireRole(catalog, roleId);
+ const rows = await transaction
+ .select({
+ userId: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ assignedAt: userRole.assignedAt,
+ assignedBy: userRole.assignedBy,
+ })
+ .from(userRole)
+ .innerJoin(user, eq(user.id, userRole.userId))
+ .where(eq(userRole.roleId, roleId))
+ .orderBy(desc(userRole.assignedAt));
+ return rows.map((row) => ({ ...row, assignedAt: row.assignedAt?.toISOString() ?? null }));
+ });
}
export async function assignRole(actorId: string, roleId: string, userId: string) {
@@ -433,14 +467,16 @@ export async function unassignRole(actorId: string, roleId: string, userId: stri
}
/** People an administrator can pick when assigning a role. */
-export async function searchUsers(query: string): Promise {
- const term = `%${query.trim().replace(/[%_\\]/g, (match) => `\\${match}`)}%`;
- return db
- .select({ id: user.id, name: user.name, email: user.email, image: user.image })
- .from(user)
- .where(query.trim() ? or(ilike(user.name, term), ilike(user.email, term)) : undefined)
- .orderBy(user.name)
- .limit(25);
+export async function searchUsers(actorId: string, query: string): Promise {
+ return withAuthorizedRbacRead(actorId, ["idp:people:read"], async (transaction) => {
+ const term = `%${query.trim().replace(/[%_\\]/g, (match) => `\\${match}`)}%`;
+ return transaction
+ .select({ id: user.id, name: user.name, email: user.email, image: user.image })
+ .from(user)
+ .where(query.trim() ? or(ilike(user.name, term), ilike(user.email, term)) : undefined)
+ .orderBy(user.name)
+ .limit(25);
+ });
}
/** The role keys a person has been given by hand, ignoring anything managed. */
diff --git a/src/routes/api/oidc/client-update.ts b/src/routes/api/oidc/client-update.ts
index 3ad38e9..2998d6f 100644
--- a/src/routes/api/oidc/client-update.ts
+++ b/src/routes/api/oidc/client-update.ts
@@ -46,7 +46,11 @@ export const Route = createFileRoute("/api/oidc/client-update")({
{ status: 400, headers: noStore },
);
}
- const client = await updateOidcClient(clientId, { draft, disabled, skipConsent });
+ const client = await updateOidcClient(guard.session.userId, clientId, {
+ draft,
+ disabled,
+ skipConsent,
+ });
if (!client)
return Response.json(
{ error: "Application not found." },
diff --git a/src/routes/api/oidc/clients.ts b/src/routes/api/oidc/clients.ts
index e471882..101b121 100644
--- a/src/routes/api/oidc/clients.ts
+++ b/src/routes/api/oidc/clients.ts
@@ -9,7 +9,9 @@ export const Route = createFileRoute("/api/oidc/clients")({
const guard = await requireIdpPermission(request, "idp:applications:read");
if ("response" in guard) return guard.response;
const clientId = new URL(request.url).searchParams.get("client_id") ?? undefined;
- return Response.json(await listOidcClients(clientId), { headers: noStore });
+ return Response.json(await listOidcClients(guard.session.userId, clientId), {
+ headers: noStore,
+ });
},
},
},
diff --git a/src/routes/api/rbac/catalog.ts b/src/routes/api/rbac/catalog.ts
index b428556..b4f0997 100644
--- a/src/routes/api/rbac/catalog.ts
+++ b/src/routes/api/rbac/catalog.ts
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { noStore, requireAnyIdpPermission } from "@/auth/api-guard";
-import { catalogForIdpPermissions } from "@/auth/rbac";
import { loadCatalog } from "@/auth/rbac-store";
export const Route = createFileRoute("/api/rbac/catalog")({
@@ -12,7 +11,7 @@ export const Route = createFileRoute("/api/rbac/catalog")({
"idp:roles:read",
]);
if ("response" in guard) return guard.response;
- const catalog = catalogForIdpPermissions(await loadCatalog(), guard.session.permissions);
+ const catalog = await loadCatalog(guard.session.userId);
return Response.json(catalog, { headers: noStore });
},
},
diff --git a/src/routes/api/rbac/role-members.ts b/src/routes/api/rbac/role-members.ts
index 447c577..1787854 100644
--- a/src/routes/api/rbac/role-members.ts
+++ b/src/routes/api/rbac/role-members.ts
@@ -18,7 +18,9 @@ export const Route = createFileRoute("/api/rbac/role-members")({
const roleId = new URL(request.url).searchParams.get("role_id");
if (!roleId) return apiError(400, "Name the role to list.");
try {
- return Response.json(await listRoleMembers(roleId), { headers: noStore });
+ return Response.json(await listRoleMembers(guard.session.userId, roleId), {
+ headers: noStore,
+ });
} catch (cause) {
if (cause instanceof RbacError) return apiError(cause.status, cause.message);
throw cause;
@@ -33,7 +35,12 @@ export const Route = createFileRoute("/api/rbac/role-members")({
try {
if (action === "assign") await assignRole(guard.session.userId, roleId, userId);
else await unassignRole(guard.session.userId, roleId, userId);
- return Response.json(await listRoleMembers(roleId), { headers: noStore });
+ return Response.json(
+ guard.session.permissions.includes("idp:roles:read")
+ ? await listRoleMembers(guard.session.userId, roleId)
+ : [],
+ { headers: noStore },
+ );
} catch (cause) {
if (cause instanceof RbacError) return apiError(cause.status, cause.message);
throw cause;
diff --git a/src/routes/api/rbac/users.ts b/src/routes/api/rbac/users.ts
index 0377c08..a6c4d77 100644
--- a/src/routes/api/rbac/users.ts
+++ b/src/routes/api/rbac/users.ts
@@ -9,7 +9,7 @@ export const Route = createFileRoute("/api/rbac/users")({
const guard = await requireIdpPermission(request, "idp:people:read");
if ("response" in guard) return guard.response;
const query = new URL(request.url).searchParams.get("q") ?? "";
- return Response.json(await searchUsers(query), { headers: noStore });
+ return Response.json(await searchUsers(guard.session.userId, query), { headers: noStore });
},
},
},
From ef96aa305c2f7354aa6e1346c9613b8149be7e26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 17:43:39 +0000
Subject: [PATCH 09/15] fix: validate security settings before startup in
production containers
---
Dockerfile | 1 +
docker/write-runtime-package.mjs | 2 +-
scripts/security-config.mjs | 32 +++++++++++++++++++++++++++++++-
src/auth/security-config.test.ts | 17 ++++++++++++++++-
4 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index cffb908..f714482 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -63,6 +63,7 @@ COPY --from=build --chown=node:node /app/.output ./.output
COPY --from=build --chown=node:node /app/drizzle ./drizzle
COPY --from=build --chown=node:node /app/scripts/migrate.mjs ./scripts/migrate.mjs
COPY --from=build --chown=node:node /app/scripts/start.mjs ./scripts/start.mjs
+COPY --from=build --chown=node:node /app/scripts/security-config.mjs ./scripts/security-config.mjs
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
diff --git a/docker/write-runtime-package.mjs b/docker/write-runtime-package.mjs
index 293de93..2dacd77 100644
--- a/docker/write-runtime-package.mjs
+++ b/docker/write-runtime-package.mjs
@@ -8,7 +8,7 @@
// the versions bundled into .output, which matters for Sentry in particular.
import { readFileSync, writeFileSync } from "node:fs";
-const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg"];
+const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg", "zod"];
const dependencies = Object.fromEntries(
runtimePackages.map((name) => {
diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs
index a36f40e..90db6c2 100644
--- a/scripts/security-config.mjs
+++ b/scripts/security-config.mjs
@@ -4,6 +4,26 @@ const optional = (schema) =>
z.preprocess((value) => (value === "" ? undefined : value), schema.optional());
const schema = z
.object({
+ BETTER_AUTH_URL: z
+ .url()
+ .default("https://auth.polinetwork.org")
+ .refine((value) => {
+ const url = new URL(value);
+ return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password;
+ }, "BETTER_AUTH_URL must be an HTTP(S) URL without credentials."),
+ BETTER_AUTH_SECRET: z.string().trim().min(32),
+ PN_ENTRA_MEMBER_GROUP_ID: optional(z.uuid()),
+ PN_ENTRA_DIRETTIVO_GROUP_ID: optional(z.uuid()),
+ PN_ENTRA_MEMBER_REFRESH_HOURS: optional(z.coerce.number().int().positive()),
+ STUDENT_VERIFICATION_TTL_DAYS: optional(z.coerce.number().int().positive()),
+ GOOGLE_CLIENT_ID: optional(z.string().trim().min(1)),
+ GOOGLE_CLIENT_SECRET: optional(z.string().trim().min(1)),
+ TELEGRAM_CLIENT_ID: optional(z.string().trim().min(1)),
+ TELEGRAM_CLIENT_SECRET: optional(z.string().trim().min(1)),
+ AZURE_TENANT_ID: optional(z.string().trim().min(1)),
+ AZURE_CLIENT_ID: optional(z.string().trim().min(1)),
+ AZURE_CLIENT_SECRET: optional(z.string().trim().min(1)),
+ AZURE_EMAIL_SENDER: optional(z.email()),
PN_ENTRA_TENANT_ID: optional(z.uuid()),
PN_ENTRA_CLIENT_ID: optional(z.string().trim().min(1)),
PN_ENTRA_CLIENT_SECRET: optional(z.string().trim().min(1)),
@@ -15,13 +35,23 @@ const schema = z
.pipe(z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/))),
})
.superRefine((config, context) => {
+ for (const keys of [
+ ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
+ ["TELEGRAM_CLIENT_ID", "TELEGRAM_CLIENT_SECRET"],
+ ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"],
+ ]) {
+ if (keys.some((key) => config[key]) && !keys.every((key) => config[key]))
+ context.addIssue({ code: "custom", message: `Configure ${keys.join(", ")} together.` });
+ }
const credentials = [
config.PN_ENTRA_TENANT_ID,
config.PN_ENTRA_CLIENT_ID,
config.PN_ENTRA_CLIENT_SECRET,
];
if (
- (credentials.some(Boolean) || config.PN_ENTRA_OIDC_ADMIN_GROUP_ID) &&
+ (credentials.some(Boolean) ||
+ config.PN_ENTRA_OIDC_ADMIN_GROUP_ID ||
+ config.PN_ENTRA_DIRETTIVO_GROUP_ID) &&
!credentials.every(Boolean)
)
context.addIssue({
diff --git a/src/auth/security-config.test.ts b/src/auth/security-config.test.ts
index f35c404..c5f9ed3 100644
--- a/src/auth/security-config.test.ts
+++ b/src/auth/security-config.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vite-plus/test";
-import { validateSecurityConfiguration } from "../../scripts/security-config.mjs";
+import { validateSecurityConfiguration as validate } from "../../scripts/security-config.mjs";
+
+const validateSecurityConfiguration = (environment: Record) =>
+ validate({ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", ...environment });
describe("security configuration startup validation", () => {
it.each([undefined, "", " ", ",", "root,", "root,,other", "*", "root user"])(
@@ -36,4 +39,16 @@ describe("security configuration startup validation", () => {
validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root, another-user" }),
).not.toThrow();
});
+ it.each([
+ { BETTER_AUTH_SECRET: "" },
+ { PN_ENTRA_MEMBER_GROUP_ID: "bad" },
+ { PN_ENTRA_MEMBER_REFRESH_HOURS: "NaN" },
+ { STUDENT_VERIFICATION_TTL_DAYS: "-1" },
+ { BETTER_AUTH_URL: "javascript:alert(1)" },
+ { GOOGLE_CLIENT_ID: "partial" },
+ ])("rejects malformed security settings before startup: %j", (invalid) => {
+ expect(() =>
+ validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root", ...invalid }),
+ ).toThrow();
+ });
});
From 77c71742d69338eff197281370cfe4acbba9b226 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 17:43:39 +0000
Subject: [PATCH 10/15] fix: deny unconfigured resource-policy administration
and cover plugin boundaries
---
src/auth/api-guard.test.ts | 42 ++++++++
src/auth/identity.integration.test.ts | 112 +++++++++++++++++---
src/auth/index.ts | 14 ++-
src/auth/rbac-security.integration.test.mjs | 28 +++++
4 files changed, 179 insertions(+), 17 deletions(-)
create mode 100644 src/auth/api-guard.test.ts
diff --git a/src/auth/api-guard.test.ts b/src/auth/api-guard.test.ts
new file mode 100644
index 0000000..81859f7
--- /dev/null
+++ b/src/auth/api-guard.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, expect, it, vi } from "vite-plus/test";
+const mocks = vi.hoisted(() => ({ session: vi.fn(), permissions: vi.fn() }));
+vi.mock("./index", () => ({ auth: { api: { getSession: mocks.session } } }));
+vi.mock("./idp-access", () => ({ idpPermissions: mocks.permissions }));
+vi.mock("../env", () => ({ env: { BETTER_AUTH_URL: "https://auth.example" } }));
+import { requireAnyIdpPermission, requireIdpPermission } from "./api-guard";
+const request = () =>
+ new Request("https://auth.example/api/rbac/role-save?token=never-log", {
+ method: "POST",
+ headers: { origin: "https://auth.example" },
+ body: "secret-body",
+ });
+beforeEach(() => {
+ mocks.session.mockReset().mockResolvedValue({ user: { id: "actor" } });
+ mocks.permissions.mockReset().mockResolvedValue(["idp:roles:write"]);
+});
+it("denies missing sessions", async () => {
+ mocks.session.mockResolvedValue(null);
+ expect(await requireIdpPermission(request(), "idp:roles:write")).toMatchObject({
+ response: { status: 401 },
+ });
+});
+it("denies an empty required set or an unknown permission", async () => {
+ expect(await requireAnyIdpPermission(request(), [])).toMatchObject({ response: { status: 403 } });
+ expect(await requireIdpPermission(request(), "idp:roles:write:extra" as never)).toMatchObject({
+ response: { status: 403 },
+ });
+});
+it("denies a failed authorization lookup without logging secrets", async () => {
+ mocks.permissions.mockRejectedValue(new Error("private-provider-token"));
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ expect(await requireIdpPermission(request(), "idp:roles:write")).toMatchObject({
+ response: { status: 503 },
+ });
+ const log = JSON.stringify(warn.mock.calls);
+ expect(log).not.toMatch(/never-log|secret-body|private-provider-token/);
+ expect(log).toContain("authorization_denied");
+ } finally {
+ warn.mockRestore();
+ }
+});
diff --git a/src/auth/identity.integration.test.ts b/src/auth/identity.integration.test.ts
index 42e7e7b..ae38d1b 100644
--- a/src/auth/identity.integration.test.ts
+++ b/src/auth/identity.integration.test.ts
@@ -20,6 +20,7 @@ function sessionHeaders(token: string) {
describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration", () => {
const pool = new Pool({ connectionString: databaseURL });
+ let usedInternalApi = false;
const token = "identity-integration-session";
const headers = sessionHeaders(token);
const permissionReaderHeaders = sessionHeaders("permission-reader-session");
@@ -27,12 +28,16 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
await pool.query(
`INSERT INTO "user" (id, name, email) VALUES
('integration-user', 'Test', 'test@identity.invalid'),
- ('permission-reader', 'Permission Reader', 'permission-reader@identity.invalid')`,
+ ('permission-reader', 'Permission Reader', 'permission-reader@identity.invalid'),
+ ('application-reader', 'Application Reader', 'application-reader@identity.invalid'),
+ ('application-writer', 'Application Writer', 'application-writer@identity.invalid')`,
);
await pool.query(
`INSERT INTO session (id, token, user_id, expires_at, updated_at) VALUES
('integration-session', $1, 'integration-user', NOW() + interval '1 hour', NOW()),
- ('permission-reader-session', 'permission-reader-session', 'permission-reader', NOW() + interval '1 hour', NOW())`,
+ ('permission-reader-session', 'permission-reader-session', 'permission-reader', NOW() + interval '1 hour', NOW()),
+ ('application-reader-session', 'application-reader-session', 'application-reader', NOW() + interval '1 hour', NOW()),
+ ('application-writer-session', 'application-writer-session', 'application-writer', NOW() + interval '1 hour', NOW())`,
[token],
);
for (const [id, provider, subject] of [
@@ -41,14 +46,14 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
["integration-tg", "telegram", "tg-subject"],
]) {
await pool.query(
- `INSERT INTO account (id, provider_id, issuer, account_id, user_id, updated_at) VALUES ($1, $2, $2, $3, 'integration-user', NOW())`,
- [id, provider, subject],
+ `INSERT INTO account (id, provider_id, issuer, account_id, user_id, updated_at) VALUES ($1, $2, $4, $3, 'integration-user', NOW())`,
+ [id, provider, subject, provider === "telegram" ? "https://oauth.telegram.org" : provider],
);
}
await pool.query(
`INSERT INTO identity_evidence (issuer, subject, provider_id, states, valid_until, telegram_id) VALUES
('pn-entra', 'pn-subject', 'pn-entra', ARRAY['socio']::text[], NOW() + interval '1 hour', NULL),
- ('telegram', 'tg-subject', 'telegram', ARRAY[]::text[], NOW() + interval '1 hour', '123456')`,
+ ('https://oauth.telegram.org', 'tg-subject', 'telegram', ARRAY[]::text[], NOW() + interval '1 hour', '123456')`,
);
await pool.query(
`INSERT INTO role (id, key, name) VALUES
@@ -64,14 +69,39 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
`INSERT INTO user_role (user_id, role_id)
VALUES ('permission-reader', 'integration-permission-reader-role')`,
);
+ for (const suffix of ["reader", "writer"]) {
+ await pool.query(`INSERT INTO role (id, key, name) VALUES ($1, $1, $1)`, [
+ `integration-application-${suffix}-role`,
+ ]);
+ await pool.query(
+ `INSERT INTO role_permission (role_id, permission_id) SELECT $1, id FROM permission WHERE key = $2`,
+ [
+ `integration-application-${suffix}-role`,
+ `idp:applications:${suffix === "reader" ? "read" : "write"}`,
+ ],
+ );
+ await pool.query(`INSERT INTO user_role (user_id, role_id) VALUES ($1, $2)`, [
+ `application-${suffix}`,
+ `integration-application-${suffix}-role`,
+ ]);
+ }
+ await pool.query(
+ `INSERT INTO oauth_client (id, client_id, name, reference_id, redirect_uris) VALUES ('integration-foreign-client', 'integration-foreign-client', 'Foreign', 'foreign-pool', ARRAY['https://example.com/callback'])`,
+ );
});
afterAll(async () => {
- await pool.query(`DELETE FROM "user" WHERE id IN ('integration-user', 'permission-reader')`);
- await pool.query(`DELETE FROM role WHERE id = 'integration-permission-reader-role'`);
await pool.query(
- `DELETE FROM identity_evidence WHERE issuer IN ('pn-entra', 'telegram', 'https://mail.polimi.it')`,
+ `DELETE FROM "user" WHERE id IN ('integration-user', 'permission-reader', 'application-reader', 'application-writer')`,
+ );
+ await pool.query(
+ `DELETE FROM role WHERE id IN ('integration-permission-reader-role', 'integration-application-reader-role', 'integration-application-writer-role')`,
);
+ await pool.query(
+ `DELETE FROM identity_evidence WHERE subject IN ('pn-subject', 'tg-subject', 'student@mail.polimi.it')`,
+ );
+ await pool.query(`DELETE FROM oauth_client WHERE id = 'integration-foreign-client'`);
await pool.end();
+ if (usedInternalApi) await (await import("../db/index")).db.$client.end();
});
it("denies anonymous identity access", async () => {
const response = await fetch(`${baseURL}/api/identity`);
@@ -131,14 +161,14 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
grant_types_supported: ["authorization_code", "refresh_token"],
});
});
- it("returns linked proofs but never grants Telegram moderation", async () => {
+ it("rejects the fabricated Entra issuer while returning linked Telegram metadata", async () => {
const response = await fetch(`${baseURL}/api/identity`, { headers });
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
- states: ["socio"],
- roles: ["socio"],
- permissions: ["membership:read"],
+ states: [],
+ roles: [],
+ permissions: [],
telegramId: "123456",
});
});
@@ -168,6 +198,58 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
});
expect(response.status).toBe(401);
});
+ it("denies every built-in client mutation to a read-only application administrator", async () => {
+ const readHeaders = sessionHeaders("application-reader-session");
+ expect((await fetch(`${baseURL}/api/oidc/clients`, { headers: readHeaders })).status).toBe(200);
+ for (const [path, body] of [
+ ["create-client", { redirect_uris: ["https://example.com/callback"] }],
+ [
+ "update-client",
+ { client_id: "integration-foreign-client", update: { client_name: "Stolen" } },
+ ],
+ ["delete-client", { client_id: "integration-foreign-client" }],
+ ["client/rotate-secret", { client_id: "integration-foreign-client" }],
+ ] as const) {
+ const response = await fetch(`${baseURL}/api/auth/oauth2/${path}`, {
+ method: "POST",
+ headers: readHeaders,
+ body: JSON.stringify(body),
+ });
+ expect(response.status).toBe(401);
+ }
+ const response = await fetch(`${baseURL}/api/oidc/client-update`, {
+ method: "POST",
+ headers: readHeaders,
+ body: JSON.stringify({ clientId: "integration-foreign-client", disabled: true }),
+ });
+ expect(response.status).toBe(403);
+ });
+ it("denies cross-pool built-in client mutation even to an application writer", async () => {
+ const response = await fetch(`${baseURL}/api/auth/oauth2/delete-client`, {
+ method: "POST",
+ headers: sessionHeaders("application-writer-session"),
+ body: JSON.stringify({ client_id: "integration-foreign-client" }),
+ });
+ expect(response.status).toBe(401);
+ expect(
+ (await pool.query(`SELECT id FROM oauth_client WHERE id = 'integration-foreign-client'`))
+ .rows,
+ ).toHaveLength(1);
+ });
+ it("denies resource-policy mutation through the server SDK to an ordinary session", async () => {
+ usedInternalApi = true;
+ const { auth } = await import("./index");
+ await expect(
+ auth.api.adminCreateOAuthResource({
+ headers: new Headers(headers),
+ body: { identifier: "https://example.invalid/security-resource" },
+ }),
+ ).rejects.toMatchObject({ status: "UNAUTHORIZED" });
+ });
+ it("does not expose server-only resource administration over HTTP", async () => {
+ const response = await fetch(`${baseURL}/api/auth/admin/oauth2/resources`, { headers });
+ expect(response.status).toBe(404);
+ });
it("prevents duplicate ownership of an upstream identity", async () => {
await expect(
pool.query(
@@ -192,9 +274,9 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
});
expect(response.status).toBe(200);
expect(await (await fetch(`${baseURL}/api/identity`, { headers })).json()).toEqual({
- states: ["socio", "student"],
- roles: ["socio", "student"],
- permissions: ["membership:read", "student:verified"],
+ states: ["student"],
+ roles: ["student"],
+ permissions: ["student:verified"],
telegramId: "123456",
});
});
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 87d6d80..ebc2d45 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -11,6 +11,7 @@ import { db } from "../db";
import * as schema from "../db/schema";
import { env } from "../env";
import { getOidcClaims } from "./identity";
+import { logAuthorizationDenial } from "./denial-log";
import { hasIdpPermission } from "./idp-access";
import { OIDC_CLIENT_REFERENCE } from "./oidc-clients";
import { isLinkOnlyProvider } from "./policy";
@@ -104,8 +105,17 @@ export const auth = betterAuth({
allowPublicClientPrelogin: true,
// Administrators share one client pool instead of owning clients individually.
clientReference: () => OIDC_CLIENT_REFERENCE,
- clientPrivileges: ({ user }) =>
- user ? hasIdpPermission(user.id, "idp:applications:write") : false,
+ clientPrivileges: async ({ user, action }) => {
+ const allowed = user ? await hasIdpPermission(user.id, "idp:applications:write") : false;
+ if (!allowed)
+ logAuthorizationDenial(user?.id ?? null, `oauth-client:${action}`, [
+ "idp:applications:write",
+ ]);
+ return allowed;
+ },
+ // Resource-policy administration is not a supported product surface. The plugin's
+ // server-only SDK defaults to permitting any session unless this hook is set.
+ resourcePrivileges: () => false,
accessTokenExpiresIn: 300,
idTokenExpiresIn: 300,
customIdTokenClaims: ({ user, scopes }) => getOidcClaims(user.id, scopes),
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
index 2e2835b..a3be807 100644
--- a/src/auth/rbac-security.integration.test.mjs
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -1,5 +1,6 @@
import { createHmac, randomUUID } from "node:crypto";
import { Pool } from "pg";
+import { spawnSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({ graph: vi.fn().mockResolvedValue(false), sendEmail: vi.fn() }));
@@ -415,6 +416,33 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
await loadCatalog(root);
expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read");
+ const connection = new URL(process.env.RBAC_TEST_DATABASE_URL);
+ const fresh = spawnSync(
+ process.execPath,
+ [
+ "--import",
+ "tsx",
+ "--input-type=module",
+ "-e",
+ 'import { getIdentity } from "./src/auth/identity.ts"; console.log(JSON.stringify((await getIdentity("security-ordinary")).permissions)); process.exit(0);',
+ ],
+ {
+ env: {
+ PATH: process.env.PATH,
+ DB_HOST: connection.hostname,
+ DB_PORT: connection.port,
+ DB_USER: connection.username,
+ DB_PASS: connection.password,
+ DB_NAME: connection.pathname.slice(1),
+ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ IDP_ADMIN_USER_IDS: root,
+ },
+ encoding: "utf8",
+ timeout: 10000,
+ },
+ );
+ expect(fresh.status, fresh.stderr).toBe(0);
+ expect(JSON.parse(fresh.stdout)).not.toContain("idp:applications:read");
await unassignRole(root, role.id, ordinary);
await savePermission(root, draftPermission(managed.key, ["idp:applications:read"]), managed.id);
});
From 87280c257b4a985948e2cbb6cb996a13a201cce6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?=
Date: Thu, 17 Sep 2026 17:48:48 +0000
Subject: [PATCH 11/15] docs: record RBAC threat model findings and deployment
requirements
---
.env.example | 3 +-
README.md | 10 ++++-
docs/rbac-security-review.md | 78 ++++++++++++++++++++++++++++++++++++
3 files changed, 88 insertions(+), 3 deletions(-)
create mode 100644 docs/rbac-security-review.md
diff --git a/.env.example b/.env.example
index e2e0682..04c77ef 100644
--- a/.env.example
+++ b/.env.example
@@ -21,7 +21,8 @@ PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd
# Microsoft Entra security group whose direct members hold the built-in Direttivo role.
# Unset: nobody is inferred as Direttivo.
# PN_ENTRA_DIRETTIVO_GROUP_ID=
-# Membership is checked with Microsoft Graph again after this interval.
+# Lifetime of persisted sign-in evidence. Authorization independently rechecks Graph
+# using a fixed maximum 60-second cache; this setting cannot extend RBAC access.
PN_ENTRA_MEMBER_REFRESH_HOURS=24
# Google login.
diff --git a/README.md b/README.md
index 1619d78..c81190c 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ A standalone TanStack Start and Better Auth identity provider. The backend remai
Use Node and pnpm through Vite+.
1. Run `vp install`.
-2. Copy `.env.example` to `.env.local`, set a random secret, and point the `DB_*` variables at a **new, separate PostgreSQL database**.
+2. Copy `.env.example` to `.env.local`, set a random secret, point `DB_*` at a **new, separate PostgreSQL database**, and configure an explicit admin group or `IDP_ADMIN_USER_IDS` bootstrap allowlist.
3. Set `BETTER_AUTH_URL=http://localhost:3000` for local development.
4. Run `vp run db:migrate` to apply the checked-in migration to that database.
5. Run `vp run dev` and open the origin set in `BETTER_AUTH_URL`.
@@ -18,7 +18,7 @@ The included `Dockerfile` builds the app and runs the same migration-first start
Google and PoliNetwork Entra create accounts. Once signed in, users can add a passkey from the account page and use it for future logins. Signed-out visitors see a login form with configured providers and passkey sign-in. Email/password login is disabled. The server rejects direct Telegram sign-in requests and protects the last Google or PoliNetwork Entra account from being disconnected, including when passkeys or verifier accounts remain linked.
-Roles and permissions require the checked-in `0004`, `0005`, and `0006` migrations, which also move each account's single proven state into a list so one Entra identity can prove both Socio and Direttivo. `0004` carries the old `state` column into the new `states` list and seeds the built-in roles before `0005` drops it, so apply them in order and never `0005` alone. `0006` adds Master Admin and the `idp:*` permissions. Passkeys require the checked-in `0003` database migration. Run `vp run db:migrate` before using them. Their relying-party ID and origin come from `BETTER_AUTH_URL`; use that exact origin in your browser, with HTTPS in production or localhost in development. Register a passkey after signing in with Google or PoliNetwork Entra. The account page lists and removes registered passkeys.
+Roles and permissions require the checked-in `0004` through `0007` migrations, which also move each account's single proven state into a list so one Entra identity can prove both Socio and Direttivo. `0004` carries the old `state` column into the new `states` list and seeds the built-in roles before `0005` drops it, so apply them in order and never `0005` alone. `0006` adds Master Admin and the `idp:*` permissions. `0007` adds immutable RBAC audit history and rejects/quarantines unsafe managed-role links. Passkeys require the checked-in `0003` database migration. Run `vp run db:migrate` before using them. Their relying-party ID and origin come from `BETTER_AUTH_URL`; use that exact origin in your browser, with HTTPS in production or localhost in development. Register a passkey after signing in with Google or PoliNetwork Entra. The account page lists and removes registered passkeys.
New registrations send `PoliNetwork Auth` as the relying-party name. The username uses the user's real email, then an email from stored Google or Microsoft ID-token claims, and falls back to the user's name if neither is available. These claims are display metadata only. Passkey labels use the authenticator's AAGUID to recognize password managers such as 1Password; unknown authenticators display `Passkey`. Existing default labels are resolved when listed, while custom names are preserved. Password managers control their own vault item titles and may still show `localhost` during development. Previously saved vault metadata is not updated by the app.
@@ -225,3 +225,9 @@ The integration suite covers discovery, anonymous rejection, current identity cl
The auth schema was generated with the Better Auth CLI and includes the `account.issuer` field and issuer/subject unique index required by installed Better Auth 1.7.2. Review regeneration diffs: older CLI core schemas omit that field. Generate Drizzle SQL with `vp run db:generate` after any schema change.
References: [Better Auth OAuth provider](https://better-auth.com/docs/plugins/oauth-provider), [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth), [Telegram OIDC](https://core.telegram.org/bots/telegram-login).
+
+The full RBAC security audit, findings, deployment changes and verification limits are in
+[docs/rbac-security-review.md](docs/rbac-security-review.md). The additional PostgreSQL suite
+runs when `RBAC_TEST_DATABASE_URL` points to a disposable migrated database. To run all tests
+without skips, provide that variable together with the HTTP integration variables above and
+the matching `DB_*`, `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` and admin bootstrap configuration.
diff --git a/docs/rbac-security-review.md b/docs/rbac-security-review.md
new file mode 100644
index 0000000..a76dc9d
--- /dev/null
+++ b/docs/rbac-security-review.md
@@ -0,0 +1,78 @@
+# RBAC security remediation for PR #6
+
+Audited base: `d3e466d9e56952dc423e6d3468557503d6ae918b`, the final stacked state of #6 on #4. Read the owner’s [security review](https://github.com/PoliNetworkOrg/auth/pull/6#issuecomment-5714698921), all issue comments and reviews on #4/#6, and the inline discussions. The owner approved replacing the documented root-equivalent writer behavior with bounded delegation during this remediation.
+
+## 1. Findings
+
+Locations below refer to the remediated source. “Review” identifies findings from the owner’s comment; the other rows come from the independent audit.
+
+| Issue | Severity | Location | Concrete exploit path | Status |
+| ------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Review: fail-open Master Admin bootstrap | Critical | `src/auth/oidc-admin.ts:33`; `scripts/security-config.mjs:74` | A user with a formerly linked PN account signs in through Google/passkey while the admin group is unset and obtains every permission. | Fixed: explicit group/allowlist required; invalid deployment settings stop startup before migrations. |
+| Legacy inherited wildcard | Critical | `src/auth/rbac.ts:220`; `drizzle/0007_mushy_the_fury.sql:20` | A holder of a previously created custom role inheriting Master Admin obtains every present and future permission despite new-write validation. | Fixed: resolution ignores that edge; migration records/removes unsafe edges and assignments; database rejects new ones. |
+| Review: role-writer self-escalation and above-authority delegation | High | `src/auth/rbac-delegation.ts:36`; `src/auth/rbac-store.ts:235` | A role writer creates/edits a role with application administration, then assigns it to themselves or an accomplice. | Fixed: effective permissions before and after, including inheritance, must stay within the actor’s authority; managed changes require Master Admin. |
+| Review: permission-implication self-escalation | High | `src/auth/rbac-delegation.ts:24` | A permission writer makes a held permission transitively imply application administration, acquiring it on their next request. | Fixed: managed permissions require Master Admin; custom implication/rename changes cannot increase the actor’s authority. |
+| Indirect managed-role/permission changes | High | `src/auth/rbac-delegation.ts:44` | A delegated writer edits a custom ancestor or implied permission used by a managed role, changing managed grants without editing the managed row itself. | Fixed: compare affected effective role grants and managed permission closures across both graphs. |
+| Mutation/revocation race and unguarded store methods | High | `src/auth/rbac-store.ts:183`; `src/db/security-lock.ts:5` | A writer passes the HTTP guard, waits behind a revocation, then uses stale authority to grant another role; an internal caller could also omit the guard entirely. | Fixed: actor required by repository APIs; fresh authorization, dominance and writes share one READ COMMITTED transaction and advisory lock, including grant/revoke. |
+| Mixed-version graph resolution | High | `src/auth/rbac-store.ts:135`; `src/auth/rbac-store.ts:502` | A user races token issuance with a graph replacement and combines an old role grant with a new implication that never coexisted in a committed graph. | Fixed: graph, assignments and linked identity read from a REPEATABLE READ snapshot; no graph cache. |
+| Concurrent student-code guesses/replay | High | `src/auth/student-verification.ts:116` | An ordinary user submits concurrent guesses that overwrite the attempt counter, exceeds five guesses and can obtain Student and its configured permissions; concurrent valid requests can replay consumption. | Fixed: verification, attempts, code consumption and evidence creation share a serialized transaction; failed attempts commit before denial. |
+| Review: 24-hour group-backed authorization; separate 15-minute root cache | Medium | `src/auth/identity-subject.ts:11`; `src/auth/oidc-admin.ts:46` | A removed Socio/Direttivo/admin group member keeps using an existing session or minting fresh privileged tokens from stale membership. | Fixed: 60-second maximum application cache measured from lookup start; failed/overlong/superseded checks deny. |
+| Evidence from a former/different configured tenant | Medium | `src/auth/identity-subject.ts:18` | After a tenant change, an old linked account’s still-valid `pn-entra` evidence continues conferring membership without matching the new tenant. | Fixed: issuer and provider must match the configured trust boundary; current group verification is required. |
+| Restart restores revoked implications | Medium | `src/auth/rbac-store.ts:135` | A write-only actor reaches a cold replica after an operator removed a default read implication; runtime seeding recreates it and restores access. | Fixed: authorization reads never seed or repair the graph; regression exercises a fresh process. |
+| Review: no durable mutation audit / denial logging | Medium | `src/auth/rbac-store.ts:255`; `src/auth/denial-log.ts:2`; `drizzle/0007_mushy_the_fury.sql:12` | A compromised writer grants access, uses it and removes the grant, erasing attribution with the live assignment. | Fixed: actor, operation, target and before/after state recorded atomically; UPDATE/DELETE/TRUNCATE rejected; denials omit bodies, tokens and query strings. |
+| Write-only member disclosure | Medium | `src/routes/api/rbac/role-members.ts:36` | A writer whose read implication was removed mutates an empty role and receives all members’ names/emails in the response without role-read permission. | Fixed: separate read authorization, including inside the repository; write-only responses contain no member data. |
+| Unconfigured resource-policy SDK privileges | Medium, internal only | `src/auth/index.ts:118` | An internal caller passes an ordinary authenticated session to the provider’s resource-administration SDK and changes token policy because its absent privilege hook defaults to allow. | Fixed: unsupported resource-policy administration explicitly denies; real SDK denial regression. These endpoints were already server-only, not an anonymous/public HTTP exploit. |
+| Concurrent resend / last-account removal | Medium | `src/auth/student-verification.ts:53`; `src/auth/accounts.ts:18` | A user races resend requests to bypass the cooldown, or races two unlink requests so each sees the other login method and both are removed. | Fixed: read/check/write operations serialized; ownership remains derived from the authenticated subject. |
+
+No finding from the owner’s review was silently skipped or treated as a non-issue.
+
+## 2. Rebuttals and preserved decisions
+
+- **#4’s original cycle race was already fixed for graph saves at the reviewed commit.** `withRbacWriteLock` already took a transaction advisory lock and reread the graph. The missing piece was authorization and assignment/revocation under that same serialization. Concurrent opposite-edge regression tests verify exactly one edge commits.
+- **#4’s new-write Master Admin inheritance path was already blocked.** `validateRoleDraft` rejected Master Admin as a parent for every role, including managed roles. That restriction remains; only the resolver’s treatment of historical edges still needed fixing.
+- **The application pool is intentionally global, not user-owned tenancy.** `OIDC_CLIENT_REFERENCE` is the fixed `polinetwork` pool; custom SQL predicates and the installed provider’s client-reference checks reject a different pool. Real HTTP tests cover both ordinary/read-only rejection and cross-pool writer rejection. Per-user application ownership was not invented.
+- **Provider resource administration was not remotely exposed.** Installed provider endpoints carry `metadata: { SERVER_ONLY: true }`; an ordinary authenticated HTTP request returns 404. The separate internal SDK default was still explicitly closed.
+- **No prefix or wildcard permission matching exists.** Stored permission checks use exact keys. Unknown required keys and empty required sets deny. Master Admin expands only to catalog entries, and only direct configuration-derived membership activates it.
+- **Raw client claims are not proof.** PN/Telegram linking verifies signature, issuer, audience, expiry and tenant before recording evidence (`src/auth/providers.ts`). Authorization then joins evidence to an account owned by the persisted subject. Telegram provides no permission. The additional issuer check protects stored evidence after configuration changes.
+- **There is no RBAC “remove the last Master Admin” endpoint.** Managed roles cannot be assigned, unassigned or deleted; Master Admin membership lives in deployment configuration/Entra. Changing that external bootstrap remains an operator responsibility. Self-revocation of a delegated role remains allowed.
+- **Prior UI fixes remain intact.** The #4 discussion’s server-error display, synthetic preview key, busy controls, failed-load screens and permitted Access tab routing were checked in the full files. They were already addressed and were not reverted. #6’s independent catalog/read-only application boundaries remain in place.
+
+## 3. Deferred and limits
+
+No known code-remediable blocker in the audited RBAC paths is deliberately deferred. These boundaries remain:
+
+- Microsoft Graph propagation and real upstream provider availability are outside this repository. No live tenant, Google, Telegram or mail-delivery integration was exercised. Membership tests simulate positive, negative, expired, failed and superseded checks. A Graph outage loses group-derived access after the short cache expires; the explicit break-glass allowlist remains available.
+- The 60 seconds is the application cache bound for new authorization decisions, not a promise to cancel requests already in progress. Already-issued five-minute OIDC tokens remain valid until expiry. A token minted near the cache deadline can therefore retain group-derived rights for approximately six minutes, plus upstream propagation and consumer clock tolerance. Consumers requiring immediate revocation must use fresh authorization rather than offline token claims.
+- PostgreSQL owners and deployment operators are trusted. They can disable triggers/change configuration. Audit events resist application UPDATE/DELETE/TRUNCATE, but protection from database owners requires external, separately administered audit retention. Console denial logs likewise require an operational log sink.
+- A custom role may still inherit Socio/Direttivo/Student permissions without conferring the corresponding raw identity state. This is documented product behavior; downstream applications should authorize by permissions and use `states` when they need evidence of actual membership.
+- Bounded delegation protects the stored graph, not arbitrary downstream interpretations of new permission names or future edits made by Master Admin. Master Admin remains intentionally omnipotent.
+- Built-in OAuth client SDK actions check current authorization at their provider hook; an action already authorized before revocation may finish. Local RBAC mutations additionally reauthorize inside their serialized mutation transaction. There is no claim of distributed cancellation across the upstream identity provider and the database.
+
+## 4. Deployment and changed flows
+
+1. Configure **either** `PN_ENTRA_OIDC_ADMIN_GROUP_ID` with complete PN tenant/client credentials **or** a nonempty `IDP_ADMIN_USER_IDS` containing intended local user IDs. Missing/empty/malformed bootstrap settings now stop startup. Partial provider/mail credentials and malformed security settings also fail at startup.
+2. Apply migration `0007_mushy_the_fury.sql` before serving the new code. It creates append-only audit storage, rejects managed-role assignments/root inheritance, and records/removes existing unsafe links. The normal migration-first start command does this automatically. Runtime authorization no longer repairs missing seed rows: use the checked-in migrations.
+3. Role/permission writers are **no longer root-equivalent**, as explicitly approved. Ask Master Admin to edit managed access, grant new capabilities initially, rename a permission into an unheld capability, or change a graph affecting more privileged/managed roles. Existing delegates can continue delegating access they already hold.
+4. A linked PN account alone no longer grants administration. Former-tenant or malformed stored evidence grants nothing. Group-derived access now requires reachable Graph checks with a 60-second cache; `PN_ENTRA_MEMBER_REFRESH_HOURS` controls persisted sign-in evidence only.
+5. A write-only role membership mutation no longer returns member identities. Repository functions now require actor IDs, so internal callers must pass their authenticated actor rather than calling unguarded helpers.
+6. Unsupported resource-policy administration via the server SDK is denied. Standard shared-pool application management retains its existing permissions and UI.
+7. Concurrent verification attempts count individually; consumed codes cannot be replayed; concurrent resends and last-account removals are denied.
+
+Changed pre-existing authorization tests are deliberate: `oidc-admin.test.ts` now expects missing-group denial; `rbac.test.ts` now rejects a legacy inherited wildcard; `identity.integration.test.ts` no longer treats the fabricated issuer `pn-entra` as verified tenant evidence. Its Telegram fixture uses the canonical issuer, and valid student verification remains covered. No guard was weakened to satisfy those tests.
+
+## 5. Proposed PR #6 description
+
+This PR remains stacked on #4. It closes the fail-open Master Admin bootstrap and replaces root-equivalent RBAC writers with bounded delegation, approved by the owner. Repository operations require the authenticated actor; authorization, graph validation, assignment/revocation and durable audit records share the mutation transaction. Readers use consistent snapshots, historical Master Admin inheritance is rejected, and group-backed authorization has a 60-second application cache rather than 24-hour evidence/15-minute admin caches.
+
+The audit also closes permission restoration on restart, stale-tenant evidence, write-only membership disclosure, internal resource-policy defaults, and student-verification/unlink concurrency defects. Existing independent permissions and shared-pool application ownership remain intact.
+
+Deployment requires explicit admin bootstrap configuration and migration 0007. Existing OIDC tokens still expire after five minutes; live Graph/provider behavior and externally retained audit logs remain operator/integration responsibilities.
+
+Validation: `pnpm exec vp check`, `pnpm exec tsc --noEmit`, production build, Docker runtime build/startup checks, and full suite with real PostgreSQL plus the running compiled server: **140 passed, zero skipped**. Coverage includes signed-cookie HTTP denials, role/implication self-escalation, above-authority grants, different tenants/pools, immediate database revocation, bounded group caching, concurrent grant/revoke and cycles, restart behavior, immutable audit/rollback, and verification replay/attempt limits.
+
+## Authorization model and coverage notes
+
+- Resolution order: persisted user → owned accounts and trusted evidence → current group checks/student evidence → configured Master Admin + manual custom roles → role inheritance → transitive permission implications. There are no explicit deny rules or client-selected tenants. Roles/permissions use normalized lowercase keys; reads/checks use exact matching. Only Master Admin has wildcard behavior.
+- HTTP entry points audited: `/api/identity`, `/api/idp/access`, every `/api/rbac/*` and `/api/oidc/*` route, account unlink, student verification, providers, and the `/api/auth/$` dispatcher. Also inspected OAuth client hooks, internal/admin SDK methods, token/UserInfo claim callbacks, provider verification and direct repository callers. No bulk or webhook RBAC mutation endpoint exists.
+- Database enforcement: PKs/unique keys, issuer-subject ownership uniqueness, edge/assignment FKs and delete cascades; new triggers forbid managed assignments, Master Admin parents and audit rewrites. Cycle/dominance rules remain application-level but execute under the shared advisory lock. Account unlink and student evidence mutation use that lock too.
+- Test infrastructure: disposable PostgreSQL 17 on loopback, no production data; real signed-cookie tests run against the compiled server. The additional repository/route integration suite substitutes session authentication and Graph/mail responses but uses real authorization code, SQL, transactions and constraints. Group cache tests use controlled time. Startup configuration denial was also checked before migrations.
From 1291fc447e201474555b9b0410a69ff2e4a03093 Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Thu, 17 Sep 2026 21:15:29 +0200
Subject: [PATCH 12/15] fix: preserve verification resend limits after code
consumption
---
.../student-verification.integration.test.mjs | 170 ++++++++++++++++++
src/auth/student-verification.ts | 45 ++---
2 files changed, 195 insertions(+), 20 deletions(-)
create mode 100644 src/auth/student-verification.integration.test.mjs
diff --git a/src/auth/student-verification.integration.test.mjs b/src/auth/student-verification.integration.test.mjs
new file mode 100644
index 0000000..0e55973
--- /dev/null
+++ b/src/auth/student-verification.integration.test.mjs
@@ -0,0 +1,170 @@
+import { randomUUID } from "node:crypto";
+import { Pool } from "pg";
+import { afterAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({ sendEmail: vi.fn() }));
+vi.mock("../env", () => {
+ const url = new URL(
+ process.env.RBAC_TEST_DATABASE_URL ??
+ "postgresql://postgres:test@localhost:55439/auth_security",
+ );
+ return {
+ env: {
+ DB_HOST: url.hostname,
+ DB_PORT: Number(url.port),
+ DB_USER: url.username,
+ DB_PASS: url.password,
+ DB_NAME: url.pathname.slice(1),
+ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ STUDENT_VERIFICATION_TTL_DAYS: 365,
+ },
+ };
+});
+vi.mock("./email", () => ({
+ studentVerificationEmailConfigured: true,
+ sendStudentVerificationEmail: mocks.sendEmail,
+}));
+
+import { db } from "../db/index";
+import { confirmStudentVerification, requestStudentVerification } from "./student-verification";
+
+describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)(
+ "Student verification resend limits with PostgreSQL",
+ () => {
+ const pool = new Pool({ connectionString: process.env.RBAC_TEST_DATABASE_URL });
+ const users = [];
+ const emails = [];
+
+ beforeEach(() => mocks.sendEmail.mockReset().mockResolvedValue(undefined));
+ afterAll(async () => {
+ await pool.query('DELETE FROM "user" WHERE id = ANY($1::text[])', [users]);
+ await pool.query(
+ "DELETE FROM identity_evidence WHERE issuer = 'https://mail.polimi.it' AND subject = ANY($1::text[])",
+ [emails],
+ );
+ await pool.end();
+ await db.$client.end();
+ });
+
+ async function subject() {
+ const id = `student-cooldown-${randomUUID()}`;
+ await pool.query('INSERT INTO "user" (id, name, email) VALUES ($1, $1, $2)', [
+ id,
+ `${id}@identity.invalid`,
+ ]);
+ users.push(id);
+ const email = `${id}@mail.polimi.it`;
+ emails.push(email);
+ return { id, email };
+ }
+
+ async function requested() {
+ const actor = await subject();
+ await requestStudentVerification(actor.id, actor.email);
+ return { ...actor, code: mocks.sendEmail.mock.lastCall[1] };
+ }
+
+ async function allowResend(id) {
+ await pool.query(
+ "UPDATE student_verification_challenge SET last_sent_at = now() - interval '61 seconds' WHERE user_id = $1",
+ [id],
+ );
+ }
+
+ it("keeps the cooldown and original code after an email mismatch", async () => {
+ const actor = await requested();
+ const other = await subject();
+ await expect(
+ confirmStudentVerification(actor.id, { email: other.email, code: actor.code }),
+ ).rejects.toMatchObject({ status: 400 });
+ await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ await expect(requestStudentVerification(other.id, actor.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
+ await expect(confirmStudentVerification(actor.id, actor)).resolves.toMatchObject({
+ email: actor.email,
+ });
+ });
+
+ it("exhausts five guesses without permitting immediate resend, then accepts a fresh code", async () => {
+ const actor = await requested();
+ const wrong = actor.code === "000000" ? "000001" : "000000";
+ for (let attempt = 0; attempt < 5; attempt++)
+ await expect(
+ confirmStudentVerification(actor.id, { email: actor.email, code: wrong }),
+ ).rejects.toMatchObject({ status: 400 });
+ await expect(confirmStudentVerification(actor.id, actor)).rejects.toMatchObject({
+ status: 400,
+ });
+ await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ await allowResend(actor.id);
+ await requestStudentVerification(actor.id, actor.email);
+ await expect(
+ confirmStudentVerification(actor.id, {
+ email: actor.email,
+ code: mocks.sendEmail.mock.lastCall[1],
+ }),
+ ).resolves.toMatchObject({ email: actor.email });
+ });
+
+ it("consumes a valid code once without clearing user or recipient cooldowns", async () => {
+ const actor = await requested();
+ const other = await subject();
+ const results = await Promise.allSettled([
+ confirmStudentVerification(actor.id, actor),
+ confirmStudentVerification(actor.id, actor),
+ ]);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
+ await expect(requestStudentVerification(actor.id, other.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ await expect(requestStudentVerification(other.id, actor.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ });
+
+ it("invalidates a failed delivery while preserving its resend cooldown", async () => {
+ const actor = await subject();
+ mocks.sendEmail.mockRejectedValueOnce(new Error("mail unavailable"));
+ await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({
+ status: 502,
+ });
+ await expect(
+ confirmStudentVerification(actor.id, {
+ email: actor.email,
+ code: mocks.sendEmail.mock.lastCall[1],
+ }),
+ ).rejects.toMatchObject({ status: 400 });
+ await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({
+ status: 429,
+ });
+ expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not invalidate a replacement code when an earlier delivery fails late", async () => {
+ const actor = await subject();
+ const delivery = Promise.withResolvers();
+ const started = Promise.withResolvers();
+ mocks.sendEmail.mockImplementationOnce(() => {
+ started.resolve();
+ return delivery.promise;
+ });
+ const first = requestStudentVerification(actor.id, actor.email).catch((error) => error);
+ await started.promise;
+ await allowResend(actor.id);
+ await requestStudentVerification(actor.id, actor.email);
+ const code = mocks.sendEmail.mock.lastCall[1];
+ delivery.reject(new Error("late delivery failure"));
+ expect(await first).toMatchObject({ status: 502 });
+ await expect(
+ confirmStudentVerification(actor.id, { email: actor.email, code }),
+ ).resolves.toMatchObject({ email: actor.email });
+ });
+ },
+);
diff --git a/src/auth/student-verification.ts b/src/auth/student-verification.ts
index b4a85be..e654f25 100644
--- a/src/auth/student-verification.ts
+++ b/src/auth/student-verification.ts
@@ -50,7 +50,7 @@ export async function requestStudentVerification(userId: string, input: unknown)
const email = parsePolimiStudentEmail(input);
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
const codeHash = hashCode(userId, email, code);
- await db.transaction(
+ const sentAt = await db.transaction(
async (transaction) => {
await transaction.execute(authorizationMutationLock);
const now = new Date();
@@ -82,6 +82,7 @@ export async function requestStudentVerification(userId: string, input: unknown)
expiresAt: new Date(now.getTime() + CODE_LIFETIME_MS),
lastSentAt: now,
});
+ return now;
},
{ isolationLevel: "read committed" },
);
@@ -90,11 +91,13 @@ export async function requestStudentVerification(userId: string, input: unknown)
await sendStudentVerificationEmail(email, code);
} catch {
await db
- .delete(studentVerificationChallenge)
+ .update(studentVerificationChallenge)
+ .set({ codeHash: "", expiresAt: new Date() })
.where(
and(
eq(studentVerificationChallenge.userId, userId),
eq(studentVerificationChallenge.codeHash, codeHash),
+ eq(studentVerificationChallenge.lastSentAt, sentAt),
),
);
throw new StudentVerificationError(502, "The verification email could not be sent.");
@@ -121,26 +124,27 @@ export async function confirmStudentVerification(
.from(studentVerificationChallenge)
.where(eq(studentVerificationChallenge.userId, userId))
.limit(1);
- if (!challenge || challenge.email !== email || challenge.expiresAt <= new Date()) {
- if (challenge) {
- await transaction
- .delete(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId));
- }
+ // Keep the send timestamp after invalidation or consumption. Removing this row
+ // would let a failed confirmation reset both the user and email resend limits.
+ if (
+ !challenge ||
+ challenge.email !== email ||
+ challenge.expiresAt <= new Date() ||
+ challenge.attempts >= MAX_ATTEMPTS
+ ) {
return new StudentVerificationError(400, "The code is invalid or expired.");
}
if (!codeMatches(challenge.codeHash, hashCode(userId, email, code.data))) {
- if (challenge.attempts + 1 >= MAX_ATTEMPTS) {
- await transaction
- .delete(studentVerificationChallenge)
- .where(eq(studentVerificationChallenge.userId, userId));
- } else {
- await transaction
- .update(studentVerificationChallenge)
- .set({ attempts: challenge.attempts + 1 })
- .where(eq(studentVerificationChallenge.userId, userId));
- }
+ await transaction
+ .update(studentVerificationChallenge)
+ .set({
+ attempts: challenge.attempts + 1,
+ ...(challenge.attempts + 1 >= MAX_ATTEMPTS
+ ? { codeHash: "", expiresAt: new Date() }
+ : {}),
+ })
+ .where(eq(studentVerificationChallenge.userId, userId));
return new StudentVerificationError(400, "The code is invalid or expired.");
}
@@ -197,13 +201,14 @@ export async function confirmStudentVerification(
set: proof,
});
await transaction
- .delete(studentVerificationChallenge)
+ .update(studentVerificationChallenge)
+ .set({ codeHash: "", expiresAt: now })
.where(eq(studentVerificationChallenge.userId, userId));
return { email, validUntil };
},
{ isolationLevel: "read committed" },
);
- // Failed attempts must commit their counter/removal before returning a denial.
+ // Failed attempts must commit their counter/invalidation before returning a denial.
if (result instanceof StudentVerificationError) throw result;
return result;
}
From defba774e62a8ff03a347b105a2ccfc82413ca9a Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Thu, 17 Sep 2026 21:15:29 +0200
Subject: [PATCH 13/15] fix: keep RBAC authorization current without blocking
on Graph
---
src/auth/identity-subject.test.ts | 13 +-
src/auth/identity-subject.ts | 33 +++-
src/auth/membership.test.ts | 25 ++-
src/auth/membership.ts | 19 +-
src/auth/oidc-admin.test.ts | 13 +-
src/auth/oidc-admin.ts | 46 +++--
src/auth/rbac-security.integration.test.mjs | 99 +++++++++-
src/auth/rbac-store.ts | 187 +++++++++---------
src/auth/rbac.ts | 7 +-
src/components/idp-access.tsx | 11 +-
src/components/rbac/api.ts | 12 +-
src/components/rbac/delegation.test.ts | 64 ++++++
src/components/rbac/delegation.ts | 22 +++
src/components/rbac/permission-form.tsx | 30 ++-
src/components/rbac/role-form.tsx | 36 +++-
src/components/rbac/role-members.tsx | 61 ++++--
.../access/permissions/$permissionId.tsx | 17 +-
src/routes/access/roles/$roleId.tsx | 19 +-
src/routes/access/route.tsx | 10 +-
src/routes/api/idp/access.ts | 5 +-
src/routes/api/rbac/role-members.ts | 21 +-
src/routes/applications/route.tsx | 9 +-
22 files changed, 562 insertions(+), 197 deletions(-)
create mode 100644 src/components/rbac/delegation.test.ts
create mode 100644 src/components/rbac/delegation.ts
diff --git a/src/auth/identity-subject.test.ts b/src/auth/identity-subject.test.ts
index 036dce1..d3bfae5 100644
--- a/src/auth/identity-subject.test.ts
+++ b/src/auth/identity-subject.test.ts
@@ -7,7 +7,7 @@ vi.mock("../db/index", () => ({ db: {} }));
vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.check }));
vi.mock("./oidc-admin", () => ({
canAdministerIdp: async () => false,
- createGroupMembershipCache: (check: unknown) => check,
+ createGroupMembershipCache: (check: unknown) => Object.assign(check!, { cached: () => false }),
}));
import { readIdentitySubject, type IdentityReader } from "./identity-subject";
@@ -35,14 +35,19 @@ describe("authorization evidence trust and freshness", () => {
"denies stored unexpired membership when live verification returns %s",
async (result) => {
mocks.check.mockResolvedValue(result);
- expect((await readIdentitySubject("user", reader([proof]))).roleKeys).toEqual([]);
+ expect((await readIdentitySubject("user", reader([proof]), true)).roleKeys).toEqual([]);
},
);
it("denies another tenant's evidence even if membership checks would pass", async () => {
mocks.check.mockResolvedValue(true);
expect(
- (await readIdentitySubject("user", reader([{ ...proof, issuer: "https://foreign.invalid" }])))
- .roleKeys,
+ (
+ await readIdentitySubject(
+ "user",
+ reader([{ ...proof, issuer: "https://foreign.invalid" }]),
+ true,
+ )
+ ).roleKeys,
).toEqual([]);
});
it("denies missing subjects before considering any evidence", async () => {
diff --git a/src/auth/identity-subject.ts b/src/auth/identity-subject.ts
index 381e893..8900b4a 100644
--- a/src/auth/identity-subject.ts
+++ b/src/auth/identity-subject.ts
@@ -12,7 +12,11 @@ export const MEMBERSHIP_CACHE_MS = 60_000;
const member = createGroupMembershipCache(checkEntraGroupMember, MEMBERSHIP_CACHE_MS);
/** The subject is always a persisted user; evidence must match the configured issuer. */
-export async function readIdentitySubject(userId: string, reader: IdentityReader) {
+export async function readIdentitySubject(
+ userId: string,
+ reader: IdentityReader,
+ refreshMembership = false,
+) {
const [subject] = await reader.select({ id: user.id }).from(user).where(eq(user.id, userId));
if (!subject) throw new Error("Unknown identity subject.");
const proofs = await reader
@@ -46,21 +50,32 @@ export async function readIdentitySubject(userId: string, reader: IdentityReader
const verifiedStates = new Set(states);
// Persisted group evidence is display/history data, never an authorization cache.
// Recheck all group-backed rights with the same short bound, including downstream rights.
- for (const proof of proofs) {
+ const check = refreshMembership ? member : member.cached;
+ const checks = proofs.flatMap((proof) => {
if (proof.providerId !== "pn-entra" || proof.issuer !== entraIssuer || !proof.externalId)
- continue;
- const groups = [
+ return [];
+ const objectId = proof.externalId;
+ return [
["socio", env.PN_ENTRA_MEMBER_GROUP_ID],
["direttivo", env.PN_ENTRA_DIRETTIVO_GROUP_ID],
- ] as const;
- for (const [state, groupId] of groups)
- if (groupId && (await member(groupId, proof.externalId))) verifiedStates.add(state);
- }
+ ].map(async ([state, groupId]) => {
+ if (groupId && (await check(groupId, objectId))) verifiedStates.add(state!);
+ });
+ });
+ const [master] = await Promise.all([
+ canAdministerIdp(userId, reader, refreshMembership),
+ ...checks,
+ ]);
const currentStates = [...verifiedStates].sort();
- const master = await canAdministerIdp(userId, reader);
return {
states: currentStates,
telegramId,
roleKeys: [...staticRolesForStates(currentStates), ...(master ? [MASTER_ADMIN_ROLE_KEY] : [])],
};
}
+
+/** Warm only external membership facts, before taking a transaction or mutation lock.
+ * The transaction then rereads account ownership/evidence and checks cache expiry again. */
+export async function refreshIdentityMembership(userId: string) {
+ await readIdentitySubject(userId, db, true);
+}
diff --git a/src/auth/membership.test.ts b/src/auth/membership.test.ts
index d8b0ffc..32db3fb 100644
--- a/src/auth/membership.test.ts
+++ b/src/auth/membership.test.ts
@@ -19,10 +19,17 @@ vi.mock("@azure/identity", () => ({
},
}));
vi.mock("@microsoft/microsoft-graph-client", () => ({
- Client: { initWithMiddleware: () => ({ api: () => ({ get: mocks.get }) }) },
+ Client: {
+ initWithMiddleware: () => ({ api: () => ({ option: () => ({ get: mocks.get }) }) }),
+ },
}));
-import { checkPnGroupStates, membershipEvidence, readGroupMembership } from "./membership";
+import {
+ GRAPH_CHECK_TIMEOUT_MS,
+ checkPnGroupStates,
+ membershipEvidence,
+ readGroupMembership,
+} from "./membership";
describe("PN membership verification", () => {
beforeEach(() => {
@@ -103,4 +110,18 @@ describe("PN membership verification", () => {
validUntil: new Date("2026-09-08T00:00:00Z"),
});
});
+
+ it("bounds a stalled Graph lookup and denies group evidence", async () => {
+ vi.useFakeTimers();
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ mocks.get.mockImplementation(() => new Promise(() => {}));
+ const result = checkPnGroupStates("member");
+ await vi.advanceTimersByTimeAsync(GRAPH_CHECK_TIMEOUT_MS);
+ await expect(result).resolves.toBeNull();
+ } finally {
+ warn.mockRestore();
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/src/auth/membership.ts b/src/auth/membership.ts
index 2dfdc34..4d2b97c 100644
--- a/src/auth/membership.ts
+++ b/src/auth/membership.ts
@@ -20,6 +20,7 @@ export async function readGroupMembership(
}
let graphClient: Client | undefined;
+export const GRAPH_CHECK_TIMEOUT_MS = 5_000;
/**
* Checks whether an Entra object is a direct member of a group through Microsoft Graph.
@@ -34,6 +35,8 @@ export async function checkEntraGroupMember(
console.warn("Entra group check unavailable: configure PN_ENTRA credentials.");
return null;
}
+ const controller = new AbortController();
+ let timer: ReturnType | undefined;
try {
if (!graphClient) {
const credential = new ClientSecretCredential(
@@ -49,7 +52,19 @@ export async function checkEntraGroupMember(
});
}
const client = graphClient;
- return await readGroupMembership((path) => client.api(path).get(), groupId, objectId);
+ return await Promise.race([
+ readGroupMembership(
+ (path) => client.api(path).option("signal", controller.signal).get(),
+ groupId,
+ objectId,
+ ),
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ controller.abort();
+ reject(new Error("Graph membership check timed out."));
+ }, GRAPH_CHECK_TIMEOUT_MS);
+ }),
+ ]);
} catch (error) {
// Do not log Graph errors wholesale: they may contain tokens or personal data.
const status = error instanceof Error && "statusCode" in error ? error.statusCode : null;
@@ -61,6 +76,8 @@ export async function checkEntraGroupMember(
"The PN_ENTRA_CLIENT_ID app needs Graph application GroupMember.Read.All with admin consent; check the group ID too.",
);
return null;
+ } finally {
+ clearTimeout(timer);
}
}
diff --git a/src/auth/oidc-admin.test.ts b/src/auth/oidc-admin.test.ts
index a87a8f9..409972a 100644
--- a/src/auth/oidc-admin.test.ts
+++ b/src/auth/oidc-admin.test.ts
@@ -105,7 +105,7 @@ describe("bounded administrative revocation", () => {
);
expect(await member("group", "user")).toBe(false);
});
- it("does not let a late positive overwrite a newer denial", async () => {
+ it("shares a concurrent refresh and expires it at the original deadline", async () => {
let time = 0;
let finish!: (value: boolean) => void;
const check = vi
@@ -120,9 +120,16 @@ describe("bounded administrative revocation", () => {
const member = createGroupMembershipCache(check, 60_000, () => time);
const old = member("group", "user");
time = 10;
- expect(await member("group", "user")).toBe(false);
+ const concurrent = member("group", "user");
+ expect(member.cached("group", "user")).toBe(false);
+ expect(check).toHaveBeenCalledTimes(1);
finish(true);
- expect(await old).toBe(false);
+ expect(await old).toBe(true);
+ expect(await concurrent).toBe(true);
+ expect(member.cached("group", "user")).toBe(true);
+ time = 60_000;
+ expect(member.cached("group", "user")).toBe(false);
expect(await member("group", "user")).toBe(false);
+ expect(check).toHaveBeenCalledTimes(2);
});
});
diff --git a/src/auth/oidc-admin.ts b/src/auth/oidc-admin.ts
index ce1a0d5..ca93b8d 100644
--- a/src/auth/oidc-admin.ts
+++ b/src/auth/oidc-admin.ts
@@ -44,23 +44,35 @@ type GroupCheck = (groupId: string, objectId: string) => Promise
* through the group. Failed checks are never cached and grant nothing.
*/
export function createGroupMembershipCache(check: GroupCheck, ttlMs: number, now = Date.now) {
- let version = 0;
- const cache = new Map();
- return async (groupId: string, objectId: string): Promise => {
+ type Entry = { member: boolean; expiresAt: number; pending?: Promise };
+ const cache = new Map();
+ const cachedMember = (groupId: string, objectId: string) => {
+ const entry = cache.get(`${groupId} ${objectId}`);
+ return Boolean(entry && entry.expiresAt > now() && entry.member);
+ };
+ const isMember = async (groupId: string, objectId: string): Promise => {
const key = `${groupId} ${objectId}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.member;
+ if (cached?.pending) return cached.pending;
const startedAt = now();
- const requestVersion = ++version;
if (cache.size >= 1000) cache.delete(cache.keys().next().value!);
- cache.set(key, { member: false, expiresAt: 0, version: requestVersion });
- const member = await check(groupId, objectId).catch(() => null);
- // A slow positive must never replace or outlive a more recent verification.
- if (member === null || now() >= startedAt + ttlMs || cache.get(key)?.version !== requestVersion)
- return false;
- cache.set(key, { member, expiresAt: startedAt + ttlMs, version: requestVersion });
- return member;
+ const entry: Entry = { member: false, expiresAt: 0 };
+ cache.set(key, entry);
+ entry.pending = (async () => {
+ const member = await check(groupId, objectId).catch(() => null);
+ // Measure freshness from request start, never from a delayed response.
+ if (member === null || now() >= startedAt + ttlMs || cache.get(key) !== entry) return false;
+ entry.member = member;
+ entry.expiresAt = startedAt + ttlMs;
+ return member;
+ })().finally(() => {
+ entry.pending = undefined;
+ });
+ return entry.pending;
};
+ // Transactional authorization must never initiate or wait for remote I/O.
+ return Object.assign(isMember, { cached: cachedMember });
}
const ADMIN_GROUP_CACHE_MS = 60_000;
@@ -78,6 +90,7 @@ async function pnEntraObjectIds(userId: string, reader: IdentityReader): Promise
and(
eq(account.issuer, identityEvidence.issuer),
eq(account.accountId, identityEvidence.subject),
+ eq(account.providerId, identityEvidence.providerId),
),
)
.where(
@@ -94,18 +107,17 @@ async function pnEntraObjectIds(userId: string, reader: IdentityReader): Promise
export async function canAdministerIdp(
userId: string,
reader: IdentityReader = db,
+ refreshMembership = false,
): Promise {
if (env.IDP_ADMIN_USER_IDS.includes(userId)) return true;
const objectIds = await pnEntraObjectIds(userId, reader);
const groupId = env.PN_ENTRA_OIDC_ADMIN_GROUP_ID;
let groupMember = false;
if (groupId) {
- for (const objectId of objectIds) {
- if (await adminGroupMember(groupId, objectId)) {
- groupMember = true;
- break;
- }
- }
+ const check = refreshMembership ? adminGroupMember : adminGroupMember.cached;
+ groupMember = (
+ await Promise.all(objectIds.map(async (objectId) => check(groupId, objectId)))
+ ).some(Boolean);
}
return decideOidcAdmin({
allowlisted: false,
diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs
index a3be807..6cc3a4b 100644
--- a/src/auth/rbac-security.integration.test.mjs
+++ b/src/auth/rbac-security.integration.test.mjs
@@ -359,13 +359,110 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with Postgre
userId: actor.id,
});
expect(response.status).toBe(200);
- expect(await response.json()).toEqual([]);
+ expect(await response.json()).toEqual({ changed: true });
await expect(listRoleMembers(actor.id, role.id)).rejects.toMatchObject({ status: 403 });
} finally {
await savePermission(root, draftPermission(managed.key, managed.implies), managed.id);
}
});
+ it("acknowledges self-revocation after the actor loses read access", async () => {
+ const actor = await delegate(["idp:roles:write"]);
+ const response = await post(members, actor.id, {
+ action: "unassign",
+ roleId: actor.role.id,
+ userId: actor.id,
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ changed: true });
+ expect((await getIdentity(actor.id)).permissions).not.toContain("idp:roles:read");
+ await expect(listRoleMembers(actor.id, actor.role.id)).rejects.toMatchObject({ status: 403 });
+ });
+
+ it("pages every member beyond the former 500-person cutoff without duplicates", async () => {
+ const role = await saveRole(root, draftRole(unique("paged")));
+ const prefix = unique("paged-person");
+ await pool.query(
+ `INSERT INTO "user" (id, name, email)
+ SELECT $1 || '-' || n, $1, $1 || '-' || n || '@identity.invalid'
+ FROM generate_series(1, 505) AS n`,
+ [prefix],
+ );
+ await pool.query(
+ `INSERT INTO user_role (user_id, role_id)
+ SELECT id, $1 FROM "user" WHERE id LIKE $2`,
+ [role.id, `${prefix}-%`],
+ );
+ const ids = [];
+ let cursor;
+ do {
+ const page = await listRoleMembers(root, role.id, cursor);
+ expect(page.members.length).toBeLessThanOrEqual(100);
+ ids.push(...page.members.map((member) => member.userId));
+ cursor = page.nextCursor;
+ } while (cursor);
+ expect(ids).toHaveLength(505);
+ expect(new Set(ids).size).toBe(505);
+ const response = await members.options.server.handlers.GET({
+ request: new Request(`http://localhost:35439/api/rbac/role-members?role_id=${role.id}`, {
+ headers: { "x-test-user": root },
+ }),
+ });
+ const page = await response.json();
+ expect(response.status).toBe(200);
+ expect(page.members).toHaveLength(100);
+ expect(page.nextCursor).toBe(page.members[99].userId);
+ });
+
+ it("does not lock unrelated writes during Graph I/O or trust an account unlinked meanwhile", async () => {
+ const actor = await delegate([]);
+ const subject = unique("slow-graph");
+ const issuer = "https://login.microsoftonline.com/11111111-1111-4111-8111-111111111111/v2.0";
+ await pool.query(
+ `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at)
+ VALUES ($1, $1, 'pn-entra', $2, $3, now()), ($1 || '-google', $1, 'google', 'google', $3, now())`,
+ [subject, issuer, actor.id],
+ );
+ await pool.query(
+ `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until)
+ VALUES ($1, $2, 'pn-entra', $2, ARRAY['socio'], now() + interval '24 hours')`,
+ [issuer, subject],
+ );
+ const graph = Promise.withResolvers();
+ const started = Promise.withResolvers();
+ mocks.graph.mockImplementation(async (_group, objectId) => {
+ if (objectId !== subject) return false;
+ started.resolve();
+ return graph.promise;
+ });
+ const pending = saveRole(actor.id, draftRole(unique("stale-actor"))).catch((error) => error);
+ try {
+ await started.promise;
+ const independent = (async () => {
+ await saveRole(root, draftRole(unique("unblocked")));
+ await disconnectAccount(actor.id, subject);
+ return "completed";
+ })();
+ let timer;
+ try {
+ expect(
+ await Promise.race([
+ independent,
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve("blocked"), 2000);
+ }),
+ ]),
+ ).toBe("completed");
+ } finally {
+ clearTimeout(timer);
+ }
+ } finally {
+ graph.resolve(true);
+ mocks.graph.mockResolvedValue(false);
+ }
+ expect(await pending).toMatchObject({ status: 403 });
+ });
+
it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => {
const role = await saveRole(root, draftRole(unique("target")));
const permission = await savePermission(root, draftPermission(unique("permission")));
diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts
index 35eb0b0..3985d94 100644
--- a/src/auth/rbac-store.ts
+++ b/src/auth/rbac-store.ts
@@ -1,9 +1,9 @@
import { mayDelegateMutation } from "./rbac-delegation";
import { logAuthorizationDenial } from "./denial-log";
-import { readIdentitySubject } from "./identity-subject";
+import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject";
import type { IdentityClaims } from "./policy";
import { randomUUID } from "node:crypto";
-import { and, count, desc, eq, ilike, or } from "drizzle-orm";
+import { and, count, eq, gt, ilike, or } from "drizzle-orm";
import { db } from "../db/index";
import { authorizationMutationLock } from "../db/security-lock";
import {
@@ -23,7 +23,7 @@ import {
type PermissionSummary,
type RbacCatalog,
type RoleDraft,
- type RoleMember,
+ type RoleMemberPage,
type RoleSummary,
type UserSearchResult,
MASTER_ADMIN_ROLE_KEY,
@@ -132,19 +132,13 @@ async function readCatalog(db: CatalogReader): Promise {
};
}
-async function loadCatalogSnapshot(): Promise {
- return db.transaction((transaction) => readCatalog(transaction), {
- isolationLevel: "repeatable read",
- accessMode: "read only",
- });
-}
-
/** Read guards and protected data share one database snapshot. */
export async function withAuthorizedRbacRead(
actorId: string,
required: readonly ManagedPermissionKey[],
read: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
): Promise {
+ await refreshIdentityMembership(actorId);
return db.transaction(
async (transaction) => {
const subject = await readIdentitySubject(actorId, transaction);
@@ -185,6 +179,7 @@ export async function withAuthorizedRbacWrite(
required: Extract,
change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise,
): Promise {
+ await refreshIdentityMembership(actorId);
return db.transaction(
async (transaction) => {
await transaction.execute(authorizationMutationLock);
@@ -301,43 +296,35 @@ export async function savePermission(
): Promise {
const draft = normalizePermissionDraft(input);
const targetId = permissionId ?? randomUUID();
- const id = await withRbacWriteLock(
- actorId,
- "permission.save",
- targetId,
- async (transaction, catalog) => {
- const current = permissionId ? requirePermission(catalog, permissionId) : undefined;
- // A managed permission's key is what the identity provider's own checks look for.
- if (current?.managed && draft.key !== current.key)
- throw new RbacError(400, "The key of a built-in permission cannot be changed.", {
- key: "This permission is defined by the identity provider.",
- });
- checked(validatePermissionDraft(draft, { catalog, currentKey: current?.key }));
- const id = targetId;
- const impliedIds = idsForPermissionKeys(catalog, draft.implies);
- const values = {
- key: draft.key,
- name: draft.name,
- description: draft.description || null,
- updatedAt: new Date(),
- };
- if (current) await transaction.update(permission).set(values).where(eq(permission.id, id));
- else await transaction.insert(permission).values({ id, ...values });
+ return withRbacWriteLock(actorId, "permission.save", targetId, async (transaction, catalog) => {
+ const current = permissionId ? requirePermission(catalog, permissionId) : undefined;
+ // A managed permission's key is what the identity provider's own checks look for.
+ if (current?.managed && draft.key !== current.key)
+ throw new RbacError(400, "The key of a built-in permission cannot be changed.", {
+ key: "This permission is defined by the identity provider.",
+ });
+ checked(validatePermissionDraft(draft, { catalog, currentKey: current?.key }));
+ const id = targetId;
+ const impliedIds = idsForPermissionKeys(catalog, draft.implies);
+ const values = {
+ key: draft.key,
+ name: draft.name,
+ description: draft.description || null,
+ updatedAt: new Date(),
+ };
+ if (current) await transaction.update(permission).set(values).where(eq(permission.id, id));
+ else await transaction.insert(permission).values({ id, ...values });
+ await transaction
+ .delete(permissionImplication)
+ .where(eq(permissionImplication.permissionId, id));
+ if (impliedIds.length)
await transaction
- .delete(permissionImplication)
- .where(eq(permissionImplication.permissionId, id));
- if (impliedIds.length)
- await transaction
- .insert(permissionImplication)
- .values(
- impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })),
- );
- return id;
- },
- );
- const saved = (await loadCatalogSnapshot()).permissions.find((entry) => entry.id === id);
- if (!saved) throw new RbacError(500, "The permission could not be read back.");
- return saved;
+ .insert(permissionImplication)
+ .values(
+ impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })),
+ );
+ return (await readCatalog(transaction)).permissions.find((entry) => entry.id === id)!;
+ });
}
export async function deletePermission(actorId: string, permissionId: string) {
@@ -364,54 +351,46 @@ export async function saveRole(
): Promise {
const draft = normalizeRoleDraft(input);
const targetId = roleId ?? randomUUID();
- const id = await withRbacWriteLock(
- actorId,
- "role.save",
- targetId,
- async (transaction, catalog) => {
- const current = roleId ? requireRole(catalog, roleId) : undefined;
- // A managed role's key is what ties it to the evidence that grants it.
- if (current?.managed && draft.key !== current.key)
- throw new RbacError(400, "The key of a built-in role cannot be changed.", {
- key: "This role is defined by the identity provider.",
- });
- // Master Admin already holds everything, so a stored grant list would only mislead.
- if (
- current?.key === MASTER_ADMIN_ROLE_KEY &&
- (draft.permissions.length > 0 || draft.parents.length > 0)
- )
- throw new RbacError(
- 400,
- `${current.name} already holds every permission, so it needs no grants of its own.`,
- );
- checked(validateRoleDraft(draft, { catalog, currentKey: current?.key }));
- const id = targetId;
- const permissionIds = idsForPermissionKeys(catalog, draft.permissions);
- const parentIds = idsForRoleKeys(catalog, draft.parents);
- const values = {
- key: draft.key,
- name: draft.name,
- description: draft.description || null,
- updatedAt: new Date(),
- };
- if (current) await transaction.update(role).set(values).where(eq(role.id, id));
- else await transaction.insert(role).values({ id, managed: false, ...values });
- await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id));
- if (permissionIds.length)
- await transaction
- .insert(rolePermission)
- .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId })));
- await transaction.delete(roleParent).where(eq(roleParent.roleId, id));
- if (parentIds.length)
- await transaction
- .insert(roleParent)
- .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId })));
- return id;
- },
- );
- const saved = (await loadCatalogSnapshot()).roles.find((entry) => entry.id === id);
- if (!saved) throw new RbacError(500, "The role could not be read back.");
- return saved;
+ return withRbacWriteLock(actorId, "role.save", targetId, async (transaction, catalog) => {
+ const current = roleId ? requireRole(catalog, roleId) : undefined;
+ // A managed role's key is what ties it to the evidence that grants it.
+ if (current?.managed && draft.key !== current.key)
+ throw new RbacError(400, "The key of a built-in role cannot be changed.", {
+ key: "This role is defined by the identity provider.",
+ });
+ // Master Admin already holds everything, so a stored grant list would only mislead.
+ if (
+ current?.key === MASTER_ADMIN_ROLE_KEY &&
+ (draft.permissions.length > 0 || draft.parents.length > 0)
+ )
+ throw new RbacError(
+ 400,
+ `${current.name} already holds every permission, so it needs no grants of its own.`,
+ );
+ checked(validateRoleDraft(draft, { catalog, currentKey: current?.key }));
+ const id = targetId;
+ const permissionIds = idsForPermissionKeys(catalog, draft.permissions);
+ const parentIds = idsForRoleKeys(catalog, draft.parents);
+ const values = {
+ key: draft.key,
+ name: draft.name,
+ description: draft.description || null,
+ updatedAt: new Date(),
+ };
+ if (current) await transaction.update(role).set(values).where(eq(role.id, id));
+ else await transaction.insert(role).values({ id, managed: false, ...values });
+ await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id));
+ if (permissionIds.length)
+ await transaction
+ .insert(rolePermission)
+ .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId })));
+ await transaction.delete(roleParent).where(eq(roleParent.roleId, id));
+ if (parentIds.length)
+ await transaction
+ .insert(roleParent)
+ .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId })));
+ return (await readCatalog(transaction)).roles.find((entry) => entry.id === id)!;
+ });
}
export async function deleteRole(actorId: string, roleId: string) {
@@ -423,7 +402,11 @@ export async function deleteRole(actorId: string, roleId: string) {
});
}
-export async function listRoleMembers(actorId: string, roleId: string): Promise {
+export async function listRoleMembers(
+ actorId: string,
+ roleId: string,
+ after?: string,
+): Promise {
return withAuthorizedRbacRead(actorId, ["idp:roles:read"], async (transaction, catalog) => {
requireRole(catalog, roleId);
const rows = await transaction
@@ -437,9 +420,16 @@ export async function listRoleMembers(actorId: string, roleId: string): Promise<
})
.from(userRole)
.innerJoin(user, eq(user.id, userRole.userId))
- .where(eq(userRole.roleId, roleId))
- .orderBy(desc(userRole.assignedAt));
- return rows.map((row) => ({ ...row, assignedAt: row.assignedAt?.toISOString() ?? null }));
+ .where(and(eq(userRole.roleId, roleId), after ? gt(user.id, after) : undefined))
+ .orderBy(user.id)
+ .limit(101);
+ return {
+ members: rows.slice(0, 100).map((row) => ({
+ ...row,
+ assignedAt: row.assignedAt?.toISOString() ?? null,
+ })),
+ nextCursor: rows.length > 100 ? rows[99]!.userId : null,
+ };
});
}
@@ -500,6 +490,7 @@ async function assignedRoleKeys(
/** Read identity, assignments and graph from one committed database snapshot. */
export async function resolveUserIdentity(userId: string): Promise {
+ await refreshIdentityMembership(userId);
return db.transaction(
async (transaction) => {
const subject = await readIdentitySubject(userId, transaction);
diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts
index fbd2a7a..79704f5 100644
--- a/src/auth/rbac.ts
+++ b/src/auth/rbac.ts
@@ -26,7 +26,8 @@ export const STATIC_ROLES = [
grantsAllPermissions: false,
name: "Socio",
description: "Member of PoliNetwork APS.",
- evidence: "Direct membership of the Soci group in PoliNetwork Entra ID, rechecked on sign-in.",
+ evidence:
+ "Direct membership of the Soci group in PoliNetwork Entra ID. Membership checks expire after one minute.",
},
{
key: "direttivo",
@@ -35,7 +36,7 @@ export const STATIC_ROLES = [
name: "Direttivo",
description: "Member of the PoliNetwork APS board.",
evidence:
- "Direct membership of the Direttivo group in PoliNetwork Entra ID, rechecked on sign-in.",
+ "Direct membership of the Direttivo group in PoliNetwork Entra ID. Membership checks expire after one minute.",
},
{
key: "student",
@@ -173,6 +174,8 @@ export type RoleSummary = {
export type RbacCatalog = { roles: RoleSummary[]; permissions: PermissionSummary[] };
+export type RoleMemberPage = { members: RoleMember[]; nextCursor: string | null };
+
export const emptyCatalog: RbacCatalog = { roles: [], permissions: [] };
/**
diff --git a/src/components/idp-access.tsx b/src/components/idp-access.tsx
index 9e6b178..5c0aeff 100644
--- a/src/components/idp-access.tsx
+++ b/src/components/idp-access.tsx
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
-import type { ManagedPermissionKey } from "@/auth/rbac";
+import { MASTER_ADMIN_ROLE_KEY, type ManagedPermissionKey } from "@/auth/rbac";
import type { OidcAdminPolicy } from "@/auth/oidc-admin";
export type IdpAccessStatus = "idle" | "loading" | "ready" | "error";
@@ -8,18 +8,21 @@ export type IdpAccess = {
status: IdpAccessStatus;
policy: OidcAdminPolicy | null;
permissions: string[];
+ roles: string[];
+ isMasterAdmin: boolean;
/** Whether the signed-in person holds a managed permission. False until loaded. */
can: (permission: ManagedPermissionKey) => boolean;
retry: () => void;
};
-type AccessResponse = { permissions: string[]; policy: OidcAdminPolicy };
+type AccessResponse = { permissions: string[]; roles: string[]; policy: OidcAdminPolicy };
/** What the signed-in person may do to the identity provider, as decided by the server. */
export function useIdpAccess(enabled: boolean): IdpAccess {
const [status, setStatus] = useState("idle");
const [policy, setPolicy] = useState(null);
const [permissions, setPermissions] = useState([]);
+ const [roles, setRoles] = useState([]);
const [revision, setRevision] = useState(0);
useEffect(() => {
@@ -27,6 +30,7 @@ export function useIdpAccess(enabled: boolean): IdpAccess {
setStatus("idle");
setPolicy(null);
setPermissions([]);
+ setRoles([]);
return;
}
const controller = new AbortController();
@@ -37,6 +41,7 @@ export function useIdpAccess(enabled: boolean): IdpAccess {
const access: AccessResponse = await response.json();
setPolicy(access.policy);
setPermissions(access.permissions);
+ setRoles(access.roles);
setStatus("ready");
})
.catch(() => {
@@ -49,6 +54,8 @@ export function useIdpAccess(enabled: boolean): IdpAccess {
status,
policy,
permissions,
+ roles,
+ isMasterAdmin: roles.includes(MASTER_ADMIN_ROLE_KEY),
can: useCallback(
(permission: ManagedPermissionKey) => permissions.includes(permission),
[permissions],
diff --git a/src/components/rbac/api.ts b/src/components/rbac/api.ts
index bde8ecc..8f04530 100644
--- a/src/components/rbac/api.ts
+++ b/src/components/rbac/api.ts
@@ -4,7 +4,7 @@ import type {
RbacCatalog,
RbacDraftErrors,
RoleDraft,
- RoleMember,
+ RoleMemberPage,
RoleSummary,
UserSearchResult,
} from "@/auth/rbac";
@@ -77,14 +77,16 @@ export function deleteRole(roleId: string) {
);
}
-export function fetchRoleMembers(roleId: string, signal?: AbortSignal) {
- return fetch(`/api/rbac/role-members?role_id=${encodeURIComponent(roleId)}`, { signal }).then(
- (response) => readJson(response, "Unable to load the people in this role."),
+export function fetchRoleMembers(roleId: string, signal?: AbortSignal, after?: string) {
+ const params = new URLSearchParams({ role_id: roleId });
+ if (after) params.set("after", after);
+ return fetch(`/api/rbac/role-members?${params}`, { signal }).then((response) =>
+ readJson(response, "Unable to load the people in this role."),
);
}
export function changeRoleMember(action: "assign" | "unassign", roleId: string, userId: string) {
- return post(
+ return post<{ changed: true }>(
"/api/rbac/role-members",
{ action, roleId, userId },
"Unable to change who holds this role.",
diff --git a/src/components/rbac/delegation.test.ts b/src/components/rbac/delegation.test.ts
new file mode 100644
index 0000000..783a0a1
--- /dev/null
+++ b/src/components/rbac/delegation.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vite-plus/test";
+import type { RbacCatalog } from "@/auth/rbac";
+import { canGrantPermission, canGrantRole } from "./delegation";
+
+const catalog: RbacCatalog = {
+ permissions: ["read", "write"].map((key) => ({
+ id: key,
+ key,
+ name: key,
+ description: null,
+ managed: false,
+ implies: key === "write" ? ["read"] : [],
+ roleCount: 0,
+ createdAt: null,
+ updatedAt: null,
+ })),
+ roles: ["writer", "inherited-writer"].map((key) => ({
+ id: key,
+ key,
+ name: key,
+ description: null,
+ managed: false,
+ sourceState: null,
+ permissions: key === "writer" ? ["write"] : [],
+ parents: key === "writer" ? [] : ["writer"],
+ memberCount: 0,
+ createdAt: null,
+ updatedAt: null,
+ })),
+};
+
+describe("delegation choices", () => {
+ it("requires the complete implied permission set", () => {
+ expect(
+ canGrantPermission({ isMasterAdmin: false, permissions: ["write"] }, catalog, "write"),
+ ).toBe(false);
+ expect(
+ canGrantPermission(
+ { isMasterAdmin: false, permissions: ["read", "write"] },
+ catalog,
+ "write",
+ ),
+ ).toBe(true);
+ });
+
+ it("includes inherited role permissions in the boundary", () => {
+ expect(
+ canGrantRole({ isMasterAdmin: false, permissions: ["read"] }, catalog, "inherited-writer"),
+ ).toBe(false);
+ expect(
+ canGrantRole(
+ { isMasterAdmin: false, permissions: ["read", "write"] },
+ catalog,
+ "inherited-writer",
+ ),
+ ).toBe(true);
+ });
+
+ it("lets Master Admin manage capabilities added after the access response", () => {
+ const access = { isMasterAdmin: true, permissions: [] };
+ expect(canGrantPermission(access, catalog, "write")).toBe(true);
+ expect(canGrantRole(access, catalog, "inherited-writer")).toBe(true);
+ });
+});
diff --git a/src/components/rbac/delegation.ts b/src/components/rbac/delegation.ts
new file mode 100644
index 0000000..d716530
--- /dev/null
+++ b/src/components/rbac/delegation.ts
@@ -0,0 +1,22 @@
+import { effectiveRolePermissions, expandPermissionKeys, type RbacCatalog } from "@/auth/rbac";
+
+type DelegationAccess = { isMasterAdmin: boolean; permissions: readonly string[] };
+
+/** UI hints only. The server checks the complete proposed graph with current authority. */
+export function canGrantPermission(access: DelegationAccess, catalog: RbacCatalog, key: string) {
+ return (
+ access.isMasterAdmin ||
+ expandPermissionKeys(catalog, [key]).every((permission) =>
+ access.permissions.includes(permission),
+ )
+ );
+}
+
+export function canGrantRole(access: DelegationAccess, catalog: RbacCatalog, key: string) {
+ return (
+ access.isMasterAdmin ||
+ effectiveRolePermissions(catalog, key).every((permission) =>
+ access.permissions.includes(permission),
+ )
+ );
+}
diff --git a/src/components/rbac/permission-form.tsx b/src/components/rbac/permission-form.tsx
index 0c84d56..c8dcf63 100644
--- a/src/components/rbac/permission-form.tsx
+++ b/src/components/rbac/permission-form.tsx
@@ -13,6 +13,8 @@ import {
validatePermissionDraft,
} from "@/auth/rbac";
import { Field, KeyChip } from "@/components/rbac/fields";
+import { useIdpAccessContext } from "@/components/idp-access";
+import { canGrantPermission } from "@/components/rbac/delegation";
import { PickList } from "@/components/rbac/pick-list";
import { useDraftErrors } from "@/components/rbac/use-draft-errors";
import { Button } from "@/components/ui/button";
@@ -47,6 +49,7 @@ export function PermissionForm({
onSubmit: (draft: PermissionDraft) => void;
onCancel?: () => void;
}) {
+ const access = useIdpAccessContext();
const [draft, setDraft] = useState(initial);
const [touched, setTouched] = useState(false);
const ids = { key: useId(), name: useId(), description: useId() };
@@ -60,6 +63,10 @@ export function PermissionForm({
const options = catalog.permissions
.filter((entry) => entry.key !== (currentKey ?? draft.key.trim().toLowerCase()))
+ .filter(
+ (entry) =>
+ canGrantPermission(access, catalog, entry.key) || draft.implies.includes(entry.key),
+ )
.map((entry) => {
const cycles = currentKey
? permissionImplicationWouldCycle(catalog, currentKey, entry.key)
@@ -68,8 +75,10 @@ export function PermissionForm({
key: entry.key,
label: entry.name,
hint: entry.description ?? undefined,
- disabled: cycles,
- disabledReason: `${entry.name} already grants this permission.`,
+ disabled: cycles || !canGrantPermission(access, catalog, entry.key),
+ disabledReason: cycles
+ ? `${entry.name} already grants this permission.`
+ : "You do not hold this permission or everything it grants.",
};
});
@@ -89,6 +98,13 @@ export function PermissionForm({
onSubmit(normalizePermissionDraft(draft));
}}
>
+ {!access.isMasterAdmin && !readOnly && (
+
+ You can include only permissions you already hold. Master Admin must grant a newly created
+ permission before you can use or edit it. Changes affecting built-in access require Master
+ Admin.
+
+ )}
@@ -122,7 +140,7 @@ export function PermissionForm({
id={ids.key}
value={draft.key}
maxLength={64}
- disabled={managed}
+ disabled={managed || (mode === "edit" && !access.isMasterAdmin)}
aria-invalid={!!errors.key}
className="font-mono"
placeholder="membership:read"
diff --git a/src/components/rbac/role-form.tsx b/src/components/rbac/role-form.tsx
index f8889bf..fc07047 100644
--- a/src/components/rbac/role-form.tsx
+++ b/src/components/rbac/role-form.tsx
@@ -14,6 +14,8 @@ import {
validateRoleDraft,
} from "@/auth/rbac";
import { Field, KeyChip } from "@/components/rbac/fields";
+import { useIdpAccessContext } from "@/components/idp-access";
+import { canGrantPermission, canGrantRole } from "@/components/rbac/delegation";
import { PickList } from "@/components/rbac/pick-list";
import { useDraftErrors } from "@/components/rbac/use-draft-errors";
import { Button } from "@/components/ui/button";
@@ -56,6 +58,7 @@ export function RoleForm({
onSubmit: (draft: RoleDraft) => void;
onCancel?: () => void;
}) {
+ const access = useIdpAccessContext();
const [draft, setDraft] = useState(initial);
const [touched, setTouched] = useState(false);
const ids = { key: useId(), name: useId(), description: useId() };
@@ -69,25 +72,38 @@ export function RoleForm({
const parentOptions = catalog.roles
.filter((entry) => entry.key !== (currentKey ?? draft.key.trim().toLowerCase()))
+ .filter(
+ (entry) => canGrantRole(access, catalog, entry.key) || draft.parents.includes(entry.key),
+ )
.map((entry) => {
const cycles = currentKey ? roleParentWouldCycle(catalog, currentKey, entry.key) : false;
const wildcard = entry.key === MASTER_ADMIN_ROLE_KEY;
+ const outsideAuthority = !canGrantRole(access, catalog, entry.key);
return {
key: entry.key,
label: entry.name,
hint: entry.description ?? undefined,
- disabled: cycles || wildcard,
+ disabled: cycles || wildcard || outsideAuthority,
disabledReason: wildcard
? `${entry.name} holds every permission and is granted only by this deployment's configuration, so no role can inherit from it.`
- : `${entry.name} already inherits from this role.`,
+ : outsideAuthority
+ ? "This role grants permissions you do not hold."
+ : `${entry.name} already inherits from this role.`,
};
});
- const permissionOptions = catalog.permissions.map((entry) => ({
- key: entry.key,
- label: entry.name,
- hint: entry.description ?? undefined,
- }));
+ const permissionOptions = catalog.permissions
+ .filter(
+ (entry) =>
+ canGrantPermission(access, catalog, entry.key) || draft.permissions.includes(entry.key),
+ )
+ .map((entry) => ({
+ key: entry.key,
+ label: entry.name,
+ hint: entry.description ?? undefined,
+ disabled: !canGrantPermission(access, catalog, entry.key),
+ disabledReason: "You do not hold this permission or everything it grants.",
+ }));
// Everything a holder would end up with once both hierarchies are followed.
const preview = resolveAccess(
@@ -125,6 +141,12 @@ export function RoleForm({
onSubmit(normalizeRoleDraft(draft));
}}
>
+ {!access.isMasterAdmin && !readOnly && (
+
+ You can delegate only permissions you already hold. Changes affecting built-in roles or
+ access above your own require Master Admin.
+
+ )}
([undefined]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [revision, setRevision] = useState(0);
+ const { can, retry } = useIdpAccessContext();
+ const canSearch = canWrite && can("idp:people:read");
+ const cursor = cursors[cursors.length - 1];
useEffect(() => {
const controller = new AbortController();
- fetchRoleMembers(roleId, controller.signal)
- .then(setMembers)
+ setMembers(null);
+ setNextCursor(null);
+ setError("");
+ fetchRoleMembers(roleId, controller.signal, cursor)
+ .then((page) => {
+ if (controller.signal.aborted) return;
+ setMembers(page.members);
+ setNextCursor(page.nextCursor);
+ })
.catch((cause: unknown) => {
if (!controller.signal.aborted) setError(errorMessage(cause, "Unable to load members."));
});
return () => controller.abort();
- }, [roleId]);
+ }, [roleId, cursor, revision]);
useEffect(() => {
const term = query.trim();
- if (!term) {
+ if (!term || !canSearch) {
setResults([]);
setSearching(false);
return;
@@ -78,13 +92,16 @@ export function RoleMembers({
controller.abort();
clearTimeout(timer);
};
- }, [query]);
+ }, [query, canSearch]);
async function change(action: "assign" | "unassign", userId: string) {
setBusyUser(userId);
setError("");
try {
- setMembers(await changeRoleMember(action, roleId, userId));
+ await changeRoleMember(action, roleId, userId);
+ setCursors([undefined]);
+ setRevision((value) => value + 1);
+ retry();
if (action === "assign") setQuery("");
} catch (cause) {
setError(errorMessage(cause, "Unable to change who holds this role."));
@@ -93,8 +110,7 @@ export function RoleMembers({
}
}
- // One change at a time: each one answers with the whole list, so overlapping requests
- // would race to decide what is shown.
+ // Refresh access and the current page after each successful change.
const changing = busyUser !== "";
const held = new Set(members?.map((member) => member.userId));
const candidates = results.filter((person) => !held.has(person.id));
@@ -109,7 +125,7 @@ export function RoleMembers({
{error}
)}
- {canWrite && (
+ {canSearch && (
)}
- {members === null ? (
+ {members === null && error ? (
+ setRevision((value) => value + 1)}>
+ Retry loading members
+
+ ) : members === null ? (
Loading members…
) : members.length === 0 ? (
- {canWrite
+ {canSearch && cursors.length === 1
? `Nobody holds ${roleName} yet. Search above to give it to someone.`
: `Nobody holds ${roleName} yet.`}
@@ -192,6 +212,25 @@ export function RoleMembers({
))}
)}
+ {(cursors.length > 1 || nextCursor) && (
+
+ setCursors((pages) => pages.slice(0, -1))}
+ >
+ Previous
+
+ Page {cursors.length}
+ setCursors((pages) => [...pages, nextCursor!])}
+ >
+ Next
+
+
+ )}
);
}
diff --git a/src/routes/access/permissions/$permissionId.tsx b/src/routes/access/permissions/$permissionId.tsx
index 0c8509c..8dff4fa 100644
--- a/src/routes/access/permissions/$permissionId.tsx
+++ b/src/routes/access/permissions/$permissionId.tsx
@@ -11,6 +11,7 @@ import {
} from "@/components/rbac/api";
import { KeyChip } from "@/components/rbac/fields";
import { PermissionForm } from "@/components/rbac/permission-form";
+import { canGrantPermission } from "@/components/rbac/delegation";
import { RequirePermission } from "@/components/rbac/require-permission";
import { useCatalog } from "@/components/rbac/use-catalog";
import { Button } from "@/components/ui/button";
@@ -23,8 +24,8 @@ export const Route = createFileRoute("/access/permissions/$permissionId")({
function PermissionDetail() {
const { permissionId } = Route.useParams();
const navigate = useNavigate();
- const { can } = useIdpAccessContext();
- const canWrite = can("idp:permissions:write");
+ const access = useIdpAccessContext();
+ const { can } = access;
const { catalog, loading, error: loadError, reload } = useCatalog();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -32,6 +33,11 @@ function PermissionDetail() {
const [fields, setFields] = useState
();
const permission = catalog.permissions.find((entry) => entry.id === permissionId);
+ const canWrite =
+ can("idp:permissions:write") &&
+ !!permission &&
+ (access.isMasterAdmin || !permission.managed) &&
+ canGrantPermission(access, catalog, permission.key);
const grantedBy = permission
? catalog.roles.filter((role) => role.permissions.includes(permission.key))
: [];
@@ -133,6 +139,13 @@ function PermissionDetail() {
+ {can("idp:permissions:write") && !canWrite && (
+
+ {permission.managed
+ ? "Only Master Admin can change a built-in permission."
+ : "You can change only permissions you already hold, including everything they grant. Ask Master Admin for access."}
+
+ )}
();
const role = catalog.roles.find((entry) => entry.id === roleId);
+ const canWrite =
+ access.can("idp:roles:write") &&
+ !!role &&
+ (access.isMasterAdmin || !role.managed) &&
+ canGrantRole(access, catalog, role.key);
async function save(draft: RoleDraft) {
setBusy(true);
@@ -113,7 +118,8 @@ function RoleDetail() {
Granted automatically. {inferred.evidence}{" "}
- Nobody can be given or refused this role by hand; choose what it grants below.
+ Nobody can be given or refused this role by hand.
+ {canWrite && " Choose what it grants below."}
)}
@@ -135,6 +141,13 @@ function RoleDetail() {
+ {access.can("idp:roles:write") && !canWrite && (
+
+ {role.managed
+ ? "Only Master Admin can change a built-in role."
+ : "This role grants permissions you do not hold. Ask Master Admin to change it or manage its members."}
+
+ )}
@@ -24,9 +23,8 @@ function NoAccess({ policy }: { policy: OidcAdminPolicy | null }) {
Roles are managed by PoliNetwork staff
- {policy === "entra-group"
- ? "Editing roles and permissions is limited to members of the PoliNetwork Entra administrators group. Ask an administrator to add your PoliNetwork Microsoft account."
- : "Access requires explicitly delegated permissions. Ask an administrator to grant the appropriate role."}
+ Access requires role or permission administration access. Ask an administrator to grant the
+ appropriate role.
@@ -102,7 +100,7 @@ function AccessLayout() {
) : access.status === "ready" ? (
-
+
) : access.status === "error" ? (
We couldn't check whether you can manage roles.
diff --git a/src/routes/api/idp/access.ts b/src/routes/api/idp/access.ts
index 24112d4..de7e57c 100644
--- a/src/routes/api/idp/access.ts
+++ b/src/routes/api/idp/access.ts
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/auth";
import { noStore } from "@/auth/api-guard";
-import { idpPermissions } from "@/auth/idp-access";
+import { getIdentity } from "@/auth/identity";
import { oidcAdminPolicy } from "@/auth/oidc-admin";
/** What the signed-in person may do to the identity provider, so the UI can match it. */
@@ -12,8 +12,9 @@ export const Route = createFileRoute("/api/idp/access")({
const session = await auth.api.getSession({ headers: request.headers });
if (!session)
return Response.json({ error: "Unauthorized." }, { status: 401, headers: noStore });
+ const { permissions, roles } = await getIdentity(session.user.id);
return Response.json(
- { permissions: await idpPermissions(session.user.id), policy: oidcAdminPolicy() },
+ { permissions, roles, policy: oidcAdminPolicy() },
{ headers: noStore },
);
},
diff --git a/src/routes/api/rbac/role-members.ts b/src/routes/api/rbac/role-members.ts
index 1787854..60cb15e 100644
--- a/src/routes/api/rbac/role-members.ts
+++ b/src/routes/api/rbac/role-members.ts
@@ -15,12 +15,16 @@ export const Route = createFileRoute("/api/rbac/role-members")({
GET: async ({ request }) => {
const guard = await requireIdpPermission(request, "idp:roles:read");
if ("response" in guard) return guard.response;
- const roleId = new URL(request.url).searchParams.get("role_id");
+ const params = new URL(request.url).searchParams;
+ const roleId = params.get("role_id");
if (!roleId) return apiError(400, "Name the role to list.");
try {
- return Response.json(await listRoleMembers(guard.session.userId, roleId), {
- headers: noStore,
- });
+ return Response.json(
+ await listRoleMembers(guard.session.userId, roleId, params.get("after") ?? undefined),
+ {
+ headers: noStore,
+ },
+ );
} catch (cause) {
if (cause instanceof RbacError) return apiError(cause.status, cause.message);
throw cause;
@@ -35,12 +39,9 @@ export const Route = createFileRoute("/api/rbac/role-members")({
try {
if (action === "assign") await assignRole(guard.session.userId, roleId, userId);
else await unassignRole(guard.session.userId, roleId, userId);
- return Response.json(
- guard.session.permissions.includes("idp:roles:read")
- ? await listRoleMembers(guard.session.userId, roleId)
- : [],
- { headers: noStore },
- );
+ // A successful self-revocation may remove read access. Acknowledge the write;
+ // subsequent reads authorize independently and never turn success into an error.
+ return Response.json({ changed: true }, { headers: noStore });
} catch (cause) {
if (cause instanceof RbacError) return apiError(cause.status, cause.message);
throw cause;
diff --git a/src/routes/applications/route.tsx b/src/routes/applications/route.tsx
index 3e3ee8e..76119d8 100644
--- a/src/routes/applications/route.tsx
+++ b/src/routes/applications/route.tsx
@@ -1,6 +1,5 @@
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { ArrowLeft, Building2, ShieldOff } from "lucide-react";
-import type { OidcAdminPolicy } from "@/auth/oidc-admin";
import { authClient } from "@/auth/client";
import { AppHeader } from "@/components/app-header";
import { LoginLayout, LoginPage } from "@/components/login-page";
@@ -12,7 +11,7 @@ export const Route = createFileRoute("/applications")({
component: ApplicationsLayout,
});
-function NoAccess({ policy }: { policy: OidcAdminPolicy | null }) {
+function NoAccess() {
return (
@@ -22,9 +21,7 @@ function NoAccess({ policy }: { policy: OidcAdminPolicy | null }) {
Applications are managed by PoliNetwork staff
- {policy === "entra-group"
- ? "Managing sign-in applications is limited to members of the PoliNetwork Entra administrators group. Ask an administrator to add your PoliNetwork Microsoft account."
- : "Access requires explicitly delegated application permissions. Ask an administrator to grant the appropriate role."}
+ Access requires application permissions. Ask an administrator to grant the appropriate role.
@@ -72,7 +69,7 @@ function ApplicationsLayout() {
) : access.status === "ready" ? (
-
+
) : access.status === "error" ? (
We couldn't check whether you can manage applications.
From 2812c32c65544f552e71980d6d33f8f7e3bd22c9 Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Thu, 17 Sep 2026 21:15:29 +0200
Subject: [PATCH 14/15] docs: explain RBAC rollout and final security review
---
README.md | 37 +++++++++++++++++++++++++++---
docs/rbac-security-review.md | 16 +++++++++----
drizzle/0006_volatile_pandemic.sql | 6 ++---
3 files changed, 48 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index c81190c..00337eb 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,33 @@ Roles and permissions require the checked-in `0004` through `0007` migrations, w
New registrations send `PoliNetwork Auth` as the relying-party name. The username uses the user's real email, then an email from stored Google or Microsoft ID-token claims, and falls back to the user's name if neither is available. These claims are display metadata only. Passkey labels use the authenticator's AAGUID to recognize password managers such as 1Password; unknown authenticators display `Passkey`. Existing default labels are resolved when listed, while custom names are preserved. Password managers control their own vault item titles and may still show `localhost` during development. Previously saved vault metadata is not updated by the app.
+### Upgrading an existing deployment to RBAC
+
+Merge #6 into #4 before merging #4 to `main`, and deploy the resulting code together.
+The base feature alone does not include the security fixes.
+
+Back up the database, configure the admin bootstrap, stop every old replica, and then
+start the new release with the normal migration-first command. This upgrade requires a
+maintenance window: migration `0005` removes the `state` column still used by the old
+server, so a mixed-version rolling deployment is incompatible. Rollback requires restoring
+the database backup as well as the old image. The migration lock prevents simultaneous
+migrators; it does not make old server code compatible with the new schema.
+
+The environment changes are:
+
+| Setting | RBAC behavior |
+| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `PN_ENTRA_DIRETTIVO_GROUP_ID` | New, optional group object ID for Direttivo. Unset grants nobody that evidence-backed role. |
+| `PN_ENTRA_OIDC_ADMIN_GROUP_ID` | Existing setting now grants Master Admin. Without it, PN accounts are not administrators. Requires complete PN tenant/client credentials when set. |
+| `IDP_ADMIN_USER_IDS` | Existing comma-separated local user IDs remain the explicit break-glass administrators. Configure at least this or the admin group before startup. |
+| `PN_ENTRA_MEMBER_REFRESH_HOURS` | Still controls persisted sign-in evidence, defaults to 24. It no longer determines authorization freshness. |
+
+Authorization uses a fixed one-minute Graph cache and five-second lookup deadline, with
+no new environment knobs. Configure the PN application with Graph `GroupMember.Read.All`
+application permission and tenant admin consent. A failed lookup grants no group access;
+the local break-glass IDs remain usable. Partial provider/mail credentials and malformed
+security settings now fail validation before migrations run.
+
## Connect identities
Sign in with Google or PoliNetwork Entra, then connect Telegram and a Polimi student email from the account page. Accounts are keyed by verified issuer and subject, with a database uniqueness constraint. Matching emails never merge users. Account links can have different email addresses. The last login method cannot be disconnected.
@@ -38,7 +65,7 @@ Register these callback URLs, replacing the origin with your deployment:
PoliNetwork Entra uses a tenant-specific registration, not the `common` tenant. Google and PoliNetwork Entra are login providers. Telegram uses the official OIDC authorization-code flow with PKCE and RS256 ID tokens, but the server only permits it through the account-linking flow. Configure its allowed origin and callback in BotFather. Its bot user ID comes from the signed `id` claim, separately from its OIDC `sub`.
-Polimi verification accepts only the exact `mail.polimi.it` domain. Codes contain six digits, expire after 10 minutes, allow five attempts, and cannot be resent for 60 seconds. The database stores only an HMAC of each code. Successful verification creates a `polimi-email` account link and grants student status for `STUDENT_VERIFICATION_TTL_DAYS`.
+Polimi verification accepts only the exact `mail.polimi.it` domain. Codes contain six digits, expire after 10 minutes, allow five attempts, and cannot be resent for 60 seconds. The cooldown applies to both the user and recipient and survives failed guesses, consumption, and failed delivery. The database stores only an HMAC of each code. Successful verification creates a `polimi-email` account link and grants student status for `STUDENT_VERIFICATION_TTL_DAYS`.
Email delivery uses the same Microsoft Graph client-credential setup as the current backend. The Azure application needs the Graph `Mail.Send` application permission and permission to send as `AZURE_EMAIL_SENDER`. These Azure credentials belong to the mail sender; they do not require access to Polimi Entra.
@@ -135,7 +162,10 @@ or permissions, including through custom ancestors or implications. Other writer
change, assign, revoke or delete only access within their current effective permissions;
neither writer permission permits self-escalation. A new permission definition confers
nothing: Master Admin must first grant it before others can delegate it. All checks use
-current authority inside the same serialized transaction as the mutation.
+current authority inside the same serialized transaction as the mutation. Graph lookups
+finish before a database transaction starts. Inside the transaction, authorization rereads
+the actor's accounts, evidence, assigned roles and graph, using only still-valid cached
+membership answers. An account unlinked while Graph is pending cannot authorize the write.
Every RBAC mutation records its actor, operation, target and before/after state in
`rbac_audit_event`. These events commit atomically with the change and reject updates,
@@ -145,7 +175,8 @@ audit events to separately controlled storage if protection from database owners
### Assigning a role
Roles you create are given to people from the role's page at `/access/roles`, which lists
-who holds it and searches for someone to add. An assignment lasts until it is removed.
+who holds it in pages of 100 and searches for someone to add. Searching requires
+`idp:people:read`; removing an existing member does not. An assignment lasts until it is removed.
Deleting a role removes it from everyone who held it and from every role that inherited it.
Changes take effect on the next token. Already-issued OIDC tokens expire after five
diff --git a/docs/rbac-security-review.md b/docs/rbac-security-review.md
index a76dc9d..42afd54 100644
--- a/docs/rbac-security-review.md
+++ b/docs/rbac-security-review.md
@@ -60,15 +60,21 @@ No known code-remediable blocker in the audited RBAC paths is deliberately defer
Changed pre-existing authorization tests are deliberate: `oidc-admin.test.ts` now expects missing-group denial; `rbac.test.ts` now rejects a legacy inherited wildcard; `identity.integration.test.ts` no longer treats the fabricated issuer `pn-entra` as verified tenant evidence. Its Telegram fixture uses the canonical issuer, and valid student verification remains covered. No guard was weakened to satisfy those tests.
-## 5. Proposed PR #6 description
+## 5. Follow-up review of the complete stack
-This PR remains stacked on #4. It closes the fail-open Master Admin bootstrap and replaces root-equivalent RBAC writers with bounded delegation, approved by the owner. Repository operations require the authenticated actor; authorization, graph validation, assignment/revocation and durable audit records share the mutation transaction. Readers use consistent snapshots, historical Master Admin inheritance is rejected, and group-backed authorization has a 60-second application cache rather than 24-hour evidence/15-minute admin caches.
+The fresh review started at `87280c2`, compared the complete #4/#6 stack with `main`, and rechecked viganogabriele's report against the code. The four original security controls remain in place. Additional findings and fixes:
-The audit also closes permission restoration on restart, stale-tenant evidence, write-only membership disclosure, internal resource-policy defaults, and student-verification/unlink concurrency defects. Existing independent permissions and shared-pool application ownership remain intact.
+- **Graph I/O blocked unrelated security writes.** Membership refresh now happens before opening a transaction. Transactional authorization rereads current owned accounts/evidence and checks only unexpired cached group facts. It cannot authorize an account unlinked during the lookup. Lookups have a five-second deadline and share in-flight work per group/person. Concurrent valid administrators no longer supersede and deny one another's checks.
+- **Successful saves could return another transaction's state or fail after committing.** Role and permission saves now return their own transactional summary.
+- **Self-revocation could return a misleading 403 after success.** Membership writes return an acknowledgement without member data. The UI refreshes access separately.
+- **Unbounded member responses replaced the old silent cutoff.** The endpoint and UI now use cursor pagination with 100 members per page. A 505-person regression verifies complete traversal without duplicates.
+- **Confirmation could reset email resend throttling.** Consumed, exhausted and failed-delivery challenges retain their send timestamps; mismatched emails do not delete the original challenge. Five database regressions cover throttling, replay and delayed delivery failures.
+- **Delegated writers saw controls the server would always reject.** The UI now distinguishes Master Admin, makes built-in and above-authority objects read-only, limits grant choices, and respects the separate people-search permission. The server still validates the complete proposed graph.
+- **Rollout documentation omitted a schema compatibility break.** The README now requires stopping old replicas before migration `0005` removes their `state` column. Merge #6 into #4 first and deploy the combined release. Rollback requires the database backup and old image.
-Deployment requires explicit admin bootstrap configuration and migration 0007. Existing OIDC tokens still expire after five minutes; live Graph/provider behavior and externally retained audit logs remain operator/integration responsibilities.
+No additional environment variables or migrations are needed for these follow-up fixes. The stack still requires explicit admin bootstrap and migrations `0004` through `0007`. The [Microsoft Graph SDK cancellation guidance](https://github.com/microsoftgraph/msgraph-sdk-javascript/wiki/Microsoft-Graph-JavaScript-SDK-V3.0-Upgrade-Guide) documents the request signal used for the lookup deadline.
-Validation: `pnpm exec vp check`, `pnpm exec tsc --noEmit`, production build, Docker runtime build/startup checks, and full suite with real PostgreSQL plus the running compiled server: **140 passed, zero skipped**. Coverage includes signed-cookie HTTP denials, role/implication self-escalation, above-authority grants, different tenants/pools, immediate database revocation, bounded group caching, concurrent grant/revoke and cycles, restart behavior, immutable audit/rollback, and verification replay/attempt limits.
+Validation: formatting, lint, TypeScript, production build, Docker image build/startup, and the full suite against disposable PostgreSQL and the compiled HTTP server: **152 passed, zero skipped**. Upgrade rehearsals from `main` and the original RBAC branch preserve existing states and record/remove unsafe legacy links. Missing bootstrap configuration stops the container before migrations. Browser checks cover delegated read-only controls, Master Admin editing and page navigation. The integration tests include a blocked Graph lookup concurrent with an unrelated write and account unlink, proving both progress and denial of stale authority. The limits in section 3 still apply.
## Authorization model and coverage notes
diff --git a/drizzle/0006_volatile_pandemic.sql b/drizzle/0006_volatile_pandemic.sql
index 18a6059..ff7d86c 100644
--- a/drizzle/0006_volatile_pandemic.sql
+++ b/drizzle/0006_volatile_pandemic.sql
@@ -2,9 +2,9 @@ ALTER TABLE "permission" ADD COLUMN "managed" boolean DEFAULT false NOT NULL;-->
-- Master Admin holds every permission that exists, as a wildcard rather than a stored
-- grant list, so it keeps covering permissions created later. Its membership comes from
-- IDP_ADMIN_USER_IDS or the Entra administration policy rather than from identity evidence.
--- When no administrators group is configured, every linked PN Entra account holds it,
--- preserving the previous client-administration policy. It has no source_state because this
--- deployment configuration is the bootstrap path that prevents an administrative lockout.
+-- Master Admin requires an explicitly configured administrators group or user allowlist.
+-- It has no source_state because deployment configuration provides the bootstrap path
+-- independently of the editable role graph.
INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES
('static-role-master-admin', 'master-admin', 'Master Admin', 'Complete control of this identity provider.', true, NULL)
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
From 2d84c289a1796b1203c7f2e0ad4af237b4bb863f Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Thu, 17 Sep 2026 21:28:41 +0200
Subject: [PATCH 15/15] fix: reset member pagination when changing roles
---
src/routes/access/roles/$roleId.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/routes/access/roles/$roleId.tsx b/src/routes/access/roles/$roleId.tsx
index eeb63fb..7e4398b 100644
--- a/src/routes/access/roles/$roleId.tsx
+++ b/src/routes/access/roles/$roleId.tsx
@@ -183,7 +183,7 @@ function RoleDetail() {
list to edit here.
) : (
-
+
)}