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 && ( - + )} + - )} - -
+ + )} ); } 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({

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)} + /> +
- - - - - - 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. -

- -
-
+ {canWrite && ( + + + + + + 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. +

+ +
+
+ )}