From a40561fdd08603a5cd2b65fde7cfe03776f2a87d Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:04:39 -0500 Subject: [PATCH 1/2] fix(frontend): reject protected role grants in demo mode Return HTTP 400 with Role is protected for internal and SuperAdmin role grants when creating API keys or creating and updating users. Cover rejected mixed grants, unchanged state, and ordinary admin grants. Co-Authored-By: GPT-6.1 Sol --- frontend/src/lib/demo.ts | 16 ++++++++-- frontend/src/lib/demoTeam.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/demo.ts b/frontend/src/lib/demo.ts index 09edbff85..b9cf8acaa 100644 --- a/frontend/src/lib/demo.ts +++ b/frontend/src/lib/demo.ts @@ -464,6 +464,16 @@ export function createDemoClient(): ParseableClient { const missing = names.filter((name) => !Object.hasOwn(roles, name)); if (missing.length) fail(`Roles do not exist: ${missing.join(', ')}`); } + function assignableRoles(names: string[]) { + if ( + Object.entries(privileges(names)).some( + ([name, actions]) => + name === 'super-admin' || actions.some((action) => action.privilege === 'superadmin'), + ) + ) + fail('Role is protected'); + knownRoles(names); + } function named(name: string, kind: 'user' | 'role') { const error = validateName(name, kind); if (error) fail(error); @@ -613,7 +623,7 @@ export function createDemoClient(): ParseableClient { await ready(); if (username === 'admin') fail('Cannot call this API for root admin user'); named(username, 'user'); - knownRoles(assigned); + assignableRoles(assigned); if (!assigned.length) fail('User cannot be created without a role'); if ( users.some( @@ -641,7 +651,7 @@ export function createDemoClient(): ParseableClient { async addUserRoles(id, assigned) { await ready(); const user = findUser(id, true); - knownRoles(assigned); + assignableRoles(assigned); if (user.oidc && !user.oidc.legacy) user.oidc.manualRoles = [...new Set([...(user.oidc.manualRoles ?? []), ...assigned])]; else user.assigned = [...new Set([...user.assigned, ...assigned])]; @@ -678,7 +688,7 @@ export function createDemoClient(): ParseableClient { }, async createApiKey(keyName, assigned, ttlSeconds) { await ready(); - knownRoles(assigned); + assignableRoles(assigned); if ( ttlSeconds != null && (!Number.isInteger(ttlSeconds) || ttlSeconds < 1800 || ttlSeconds > 7776000) diff --git a/frontend/src/lib/demoTeam.test.ts b/frontend/src/lib/demoTeam.test.ts index e6478b595..3811b685f 100644 --- a/frontend/src/lib/demoTeam.test.ts +++ b/frontend/src/lib/demoTeam.test.ts @@ -95,6 +95,56 @@ describe('per-client Team demo', () => { } expect(await client.listUsers()).toEqual(users); }); + describe.each([ + { assigned: ['super-admin'] }, + { assigned: ['analysts', 'super-admin'] }, + { assigned: ['super-admin', 'analysts'] }, + ])('protected role grants: $assigned', ({ assigned }) => { + it('rejects API-key creation without changing the key inventory', async () => { + const client = createDemoClient(); + const keys = await client.listApiKeys(); + await expect(client.createApiKey('protected-key', assigned)).rejects.toMatchObject({ + status: 400, + message: 'Role is protected', + }); + expect(await client.listApiKeys()).toEqual(keys); + }); + it('rejects user creation without changing users', async () => { + const client = createDemoClient(); + const users = await client.listUsers(); + await expect(client.createUser('protected-user', assigned)).rejects.toMatchObject({ + status: 400, + message: 'Role is protected', + }); + expect(await client.listUsers()).toEqual(users); + }); + it.each(['analyst', 'https://issuer.example/sso-user'])( + 'rejects role additions to %s without changing role sources', + async (id) => { + const client = createDemoClient(); + const sources = await client.userRoleSources(id); + await expect(client.addUserRoles(id, assigned)).rejects.toMatchObject({ + status: 400, + message: 'Role is protected', + }); + expect(await client.userRoleSources(id)).toEqual(sources); + }, + ); + }); + it('allows ordinary admin roles for API keys and user grants', async () => { + const client = createDemoClient(); + expect((await client.createApiKey('admin-key', ['administrators'])).roles).toEqual([ + 'administrators', + ]); + await client.createUser('admin-user', ['administrators']); + await client.addUserRoles('analyst', ['administrators']); + for (const id of ['admin-user', 'analyst']) { + expect((await client.userRoleSources(id)).roles.administrators).toEqual({ + actions: [{ privilege: 'admin' }], + roleType: 'user', + }); + } + }); it('preserves provider overlap, removes manual grants and reconciles the fallback', async () => { const client = createDemoClient(); const oauth = (await client.listUsers()).find((user) => user.method === 'oauth')!; From 25f5b6b135775a59236bcb2babeccf5540b8c7bd Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:13:16 -0500 Subject: [PATCH 2/2] fix(frontend): check unknown demo roles before protected ones Inherited property names such as 'constructor' threw non-API errors because the protected check ran before the existence check. API keys and user creation now report unknown roles first and role additions protected roles first, as on the backend. Demo roles cannot hold the SuperAdmin privilege, so the predicate only needs the built-in super-admin name. Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/demo.ts | 23 +++++++++++++---------- frontend/src/lib/demoTeam.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/demo.ts b/frontend/src/lib/demo.ts index b9cf8acaa..a73bc34e3 100644 --- a/frontend/src/lib/demo.ts +++ b/frontend/src/lib/demo.ts @@ -464,15 +464,18 @@ export function createDemoClient(): ParseableClient { const missing = names.filter((name) => !Object.hasOwn(roles, name)); if (missing.length) fail(`Roles do not exist: ${missing.join(', ')}`); } - function assignableRoles(names: string[]) { - if ( - Object.entries(privileges(names)).some( - ([name, actions]) => - name === 'super-admin' || actions.some((action) => action.privilege === 'superadmin'), - ) - ) - fail('Role is protected'); - knownRoles(names); + // Demo roles can only hold admin, editor, reader, writer or ingestor, so + // the built-in `super-admin` is the only protected role. + function rejectProtectedRoles(names: string[]) { + if (names.includes('super-admin')) fail('Role is protected'); + } + // Like the backend, role additions report protected roles before unknown + // ones, and API keys and user creation the other way round. `super-admin` + // exists on the backend but not in the demo's role map. + function assignableRoles(names: string[], { protectedFirst = false } = {}) { + if (protectedFirst) rejectProtectedRoles(names); + knownRoles(names.filter((name) => name !== 'super-admin')); + rejectProtectedRoles(names); } function named(name: string, kind: 'user' | 'role') { const error = validateName(name, kind); @@ -651,7 +654,7 @@ export function createDemoClient(): ParseableClient { async addUserRoles(id, assigned) { await ready(); const user = findUser(id, true); - assignableRoles(assigned); + assignableRoles(assigned, { protectedFirst: true }); if (user.oidc && !user.oidc.legacy) user.oidc.manualRoles = [...new Set([...(user.oidc.manualRoles ?? []), ...assigned])]; else user.assigned = [...new Set([...user.assigned, ...assigned])]; diff --git a/frontend/src/lib/demoTeam.test.ts b/frontend/src/lib/demoTeam.test.ts index 3811b685f..5bdb0165e 100644 --- a/frontend/src/lib/demoTeam.test.ts +++ b/frontend/src/lib/demoTeam.test.ts @@ -131,6 +131,29 @@ describe('per-client Team demo', () => { }, ); }); + it.each(['constructor', 'toString', '__proto__'])( + 'rejects the inherited property name %s as an unknown role', + async (name) => { + const client = createDemoClient(); + const unknown = { status: 400, message: `Roles do not exist: ${name}` }; + await expect(client.createApiKey('inherited-key', [name])).rejects.toMatchObject(unknown); + await expect(client.createUser('inherited-user', [name])).rejects.toMatchObject(unknown); + await expect(client.addUserRoles('analyst', [name])).rejects.toMatchObject(unknown); + }, + ); + // Like the backend: API keys and user creation report unknown roles first, + // while role additions report protected roles first. + it('orders unknown and protected role errors like the backend', async () => { + const client = createDemoClient(); + const assigned = ['nope', 'super-admin']; + const unknown = { status: 400, message: 'Roles do not exist: nope' }; + await expect(client.createApiKey('mixed-key', assigned)).rejects.toMatchObject(unknown); + await expect(client.createUser('mixed-user', assigned)).rejects.toMatchObject(unknown); + await expect(client.addUserRoles('analyst', assigned)).rejects.toMatchObject({ + status: 400, + message: 'Role is protected', + }); + }); it('allows ordinary admin roles for API keys and user grants', async () => { const client = createDemoClient(); expect((await client.createApiKey('admin-key', ['administrators'])).roles).toEqual([