diff --git a/frontend/src/lib/demo.ts b/frontend/src/lib/demo.ts index 09edbff85..a73bc34e3 100644 --- a/frontend/src/lib/demo.ts +++ b/frontend/src/lib/demo.ts @@ -464,6 +464,19 @@ export function createDemoClient(): ParseableClient { const missing = names.filter((name) => !Object.hasOwn(roles, name)); if (missing.length) fail(`Roles do not exist: ${missing.join(', ')}`); } + // 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); if (error) fail(error); @@ -613,7 +626,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 +654,7 @@ export function createDemoClient(): ParseableClient { async addUserRoles(id, assigned) { await ready(); const user = findUser(id, true); - knownRoles(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])]; @@ -678,7 +691,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..5bdb0165e 100644 --- a/frontend/src/lib/demoTeam.test.ts +++ b/frontend/src/lib/demoTeam.test.ts @@ -95,6 +95,79 @@ 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.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([ + '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')!;