From 7a12f15f868bfd6b10f34a3e05204cbcc4c6043e Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Mon, 28 Sep 2026 16:46:46 +0200 Subject: [PATCH 1/2] Configure BaSyx Go ReBAC in the access control step - Add an optional ReBAC section that writes REBAC_ENABLED, REBAC_SUBJECT_CLAIM, REBAC_GROUP_CLAIM and REBAC_ADMINISTRATORS, and removes them when ReBAC or access control is disabled. - Validate administrator entries (issuer|subject or issuer|group:). - Add a groups claim mapper and a basyx-admins group containing basyx-admin to the local Keycloak realm; default the administrators to that group. - Allow /description publicly in the starting policy so clients can detect ReBAC. - Show ReBAC in the security summary and the generated README. Co-Authored-By: Claude Opus 5.5 --- components/OutputView.vue | 5 + .../get-started/deployment/access-control.vue | 134 +++++++++++++++--- tests/component/GoConfigurationPages.test.ts | 47 ++++++ tests/unit/generatedStacks.test.ts | 30 ++++ utils/securitySetup.ts | 59 +++++++- utils/setupAssets.ts | 8 +- 6 files changed, 257 insertions(+), 26 deletions(-) diff --git a/components/OutputView.vue b/components/OutputView.vue index 99f51715..e07da63e 100644 --- a/components/OutputView.vue +++ b/components/OutputView.vue @@ -463,6 +463,11 @@ function buildAasEnvironmentSummary(nodeId: string): SummaryPayload { value: boolChip(env.ABAC_ENABLED).value, color: boolChip(env.ABAC_ENABLED).color, }, + { + key: 'ReBAC', + value: boolChip(env.REBAC_ENABLED).value, + color: boolChip(env.REBAC_ENABLED).color, + }, { key: 'CORS Origins', value: env.CORS_ALLOWEDORIGINS || '*' }, { key: 'CORS Methods', value: env.CORS_ALLOWEDMETHODS || 'n/a' }, ], diff --git a/pages/get-started/deployment/access-control.vue b/pages/get-started/deployment/access-control.vue index 11f44c79..ebe72d97 100644 --- a/pages/get-started/deployment/access-control.vue +++ b/pages/get-started/deployment/access-control.vue @@ -55,6 +55,57 @@ The download includes a local realm and a one-time administrator password in its README. Keycloak is for development; use an appropriately secured identity provider in production. +

Resource sharing (ReBAC)

+

+ Relationship-based access control lets users share their own shells, Submodels and other + resources with users or groups, in addition to the access policy. Whoever creates a resource + becomes its owner. This feature is experimental. +

+ + + @@ -111,12 +162,7 @@ - Apply Access Control Settings Finalize @@ -156,9 +202,13 @@ import { import { envBoolean, readServiceEnvironment } from '@/utils/dockerEnvironment'; import { getComposeServices, updateOptionalServices } from '@/utils/optionalServices'; import { + defaultReBACAdministrators, defaultTrustList, + LOCAL_KEYCLOAK_ISSUER, localKeycloakService, + parseReBACAdministrators, validatePolicy, + validateReBACAdministrators, validateTrustList, } from '@/utils/securitySetup'; @@ -189,10 +239,14 @@ const enabled = ref(false); const includeKeycloak = ref(true); const policyJson = ref(appStore.accessPolicyJson); const trustListJson = ref(appStore.trustListJson); -const issuer = ref('http://keycloak.localhost:8080/realms/basyx'); +const issuer = ref(LOCAL_KEYCLOAK_ISSUER); const clientId = ref('basyx-ui'); const importMode = ref('if_missing'); const managementApi = ref(false); +const rebacEnabled = ref(false); +const rebacAdministrators = ref(''); +const rebacSubjectClaim = ref('sub'); +const rebacGroupClaim = ref('groups'); const appliedSignature = ref(''); const settingsSignature = computed(() => JSON.stringify({ @@ -204,6 +258,10 @@ const settingsSignature = computed(() => clientId: clientId.value, importMode: importMode.value, managementApi: managementApi.value, + rebacEnabled: rebacEnabled.value, + rebacAdministrators: rebacAdministrators.value, + rebacSubjectClaim: rebacSubjectClaim.value, + rebacGroupClaim: rebacGroupClaim.value, }) ); const applied = computed( @@ -213,6 +271,13 @@ const policyError = computed(() => (enabled.value ? validatePolicy(policyJson.va const trustListError = computed(() => enabled.value ? validateTrustList(trustListJson.value) : undefined ); +const rebacActive = computed(() => enabled.value && rebacEnabled.value); +const rebacAdministratorsError = computed(() => + rebacActive.value ? validateReBACAdministrators(rebacAdministrators.value) : undefined +); +const hasErrors = computed(() => + Boolean(policyError.value || trustListError.value || rebacAdministratorsError.value) +); function onEnabledChanged(value: boolean | null): void { if (value) includeKeycloak.value = true; @@ -220,23 +285,42 @@ function onEnabledChanged(value: boolean | null): void { function onKeycloakSelected(value: boolean | null): void { if (value) { - issuer.value = 'http://keycloak.localhost:8080/realms/basyx'; + issuer.value = LOCAL_KEYCLOAK_ISSUER; trustListJson.value = defaultTrustList(issuer.value); } } +function onReBACEnabledChanged(value: boolean | null): void { + if (value && !rebacAdministrators.value.trim()) { + rebacAdministrators.value = defaultReBACAdministrators(issuer.value); + } +} + +function reBACEnvironment(): Record { + return { + REBAC_ENABLED: 'true', + REBAC_SUBJECT_CLAIM: rebacSubjectClaim.value.trim() || 'sub', + REBAC_GROUP_CLAIM: rebacGroupClaim.value.trim() || 'groups', + REBAC_ADMINISTRATORS: parseReBACAdministrators(rebacAdministrators.value).join(','), + }; +} + function syncFromCompose(): void { const env = readServiceEnvironment(compose.value); enabled.value = envBoolean(env.ABAC_ENABLED); includeKeycloak.value = Boolean(getComposeServices()?.keycloak); importMode.value = env.ABAC_POLICY_FILE_IMPORT || 'if_missing'; managementApi.value = envBoolean(env.ABAC_MANAGEMENT_API_ENABLED); + rebacEnabled.value = envBoolean(env.REBAC_ENABLED); + rebacAdministrators.value = parseReBACAdministrators(env.REBAC_ADMINISTRATORS || '').join(', '); + rebacSubjectClaim.value = env.REBAC_SUBJECT_CLAIM || 'sub'; + rebacGroupClaim.value = env.REBAC_GROUP_CLAIM || 'groups'; const config = appStore.getBasyxInfraConfig?.value as { infrastructures?: Record } | undefined; const infrastructures = config?.infrastructures; const infra = infrastructures?.[String(infrastructures.default)] as { security?: { config?: { issuer?: string; clientId?: string } } } | undefined; - issuer.value = infra?.security?.config?.issuer || 'http://keycloak.localhost:8080/realms/basyx'; + issuer.value = infra?.security?.config?.issuer || LOCAL_KEYCLOAK_ISSUER; clientId.value = infra?.security?.config?.clientId || 'basyx-ui'; if (appStore.trustListJson === defaultTrustList()) { trustListJson.value = defaultTrustList(issuer.value); @@ -261,8 +345,14 @@ async function loadPolicyFile(value: File | File[] | null): Promise { } function applySettings(): void { - if (policyError.value || trustListError.value) return; + if (hasErrors.value) return; const securityEnabled = enabled.value; + const rebacKeys = [ + 'REBAC_ENABLED', + 'REBAC_SUBJECT_CLAIM', + 'REBAC_GROUP_CLAIM', + 'REBAC_ADMINISTRATORS', + ]; const local = securityEnabled && includeKeycloak.value; appStore.accessPolicyJson = policyJson.value; appStore.trustListJson = trustListJson.value; @@ -278,15 +368,19 @@ function applySettings(): void { ABAC_MANAGEMENT_API_ENABLED: String(managementApi.value), } : {}), + ...(rebacActive.value ? reBACEnvironment() : {}), }, - securityEnabled - ? [] - : [ - 'ABAC_MODELPATH', - 'OIDC_TRUSTLISTPATH', - 'ABAC_POLICY_FILE_IMPORT', - 'ABAC_MANAGEMENT_API_ENABLED', - ] + [ + ...(securityEnabled + ? [] + : [ + 'ABAC_MODELPATH', + 'OIDC_TRUSTLISTPATH', + 'ABAC_POLICY_FILE_IMPORT', + 'ABAC_MANAGEMENT_API_ENABLED', + ]), + ...(rebacActive.value ? [] : rebacKeys), + ] ); const databaseEnv = readServiceEnvironment(appStore.getDockerComposeConfig?.value); @@ -351,7 +445,7 @@ function applySettings(): void { } function finalizeSettings(): void { - if (policyError.value || trustListError.value) return; + if (hasErrors.value) return; applySettings(); navigateTo('/get-started/download'); } diff --git a/tests/component/GoConfigurationPages.test.ts b/tests/component/GoConfigurationPages.test.ts index 5f031164..bbc65537 100644 --- a/tests/component/GoConfigurationPages.test.ts +++ b/tests/component/GoConfigurationPages.test.ts @@ -438,4 +438,51 @@ describe('BaSyx Go configuration pages', () => { await applyButton(wrapper, 'Finalize')?.trigger('click'); expect(navigateTo).not.toHaveBeenCalled(); }); + + it('configures ReBAC with the local Keycloak administrator group and removes it again', async () => { + const wrapper = mount(AccessControlPage, { global: { stubs: globalStubs } }); + await wrapper.find('input[data-label="Enable access control"]').setValue(true); + await wrapper.find('input[data-label="Enable resource sharing (ReBAC)"]').setValue(true); + await nextTick(); + await wrapper.find('input[data-label="ReBAC subject claim"]').setValue('oid'); + await applyButton(wrapper, 'Apply Access Control Settings')?.trigger('click'); + await nextTick(); + expect(environment()).toMatchObject({ + REBAC_ENABLED: 'true', + REBAC_SUBJECT_CLAIM: 'oid', + REBAC_GROUP_CLAIM: 'groups', + REBAC_ADMINISTRATORS: 'http://keycloak.localhost:8080/realms/basyx|group:basyx-admins', + }); + + await wrapper.find('input[data-label="Enable resource sharing (ReBAC)"]').setValue(false); + await applyButton(wrapper, 'Apply Access Control Settings')?.trigger('click'); + await nextTick(); + expect(environment().REBAC_ENABLED).toBeUndefined(); + expect(environment().REBAC_ADMINISTRATORS).toBeUndefined(); + }); + + it('removes ReBAC when access control is disabled', async () => { + const wrapper = mount(AccessControlPage, { global: { stubs: globalStubs } }); + await wrapper.find('input[data-label="Enable access control"]').setValue(true); + await wrapper.find('input[data-label="Enable resource sharing (ReBAC)"]').setValue(true); + await applyButton(wrapper, 'Apply Access Control Settings')?.trigger('click'); + await nextTick(); + expect(environment().REBAC_ENABLED).toBe('true'); + + await wrapper.find('input[data-label="Enable access control"]').setValue(false); + await applyButton(wrapper, 'Apply Access Control Settings')?.trigger('click'); + await nextTick(); + expect(environment().ABAC_ENABLED).toBe('false'); + expect(environment().REBAC_ENABLED).toBeUndefined(); + }); + + it('blocks invalid ReBAC administrators', async () => { + const wrapper = mount(AccessControlPage, { global: { stubs: globalStubs } }); + await wrapper.find('input[data-label="Enable access control"]').setValue(true); + await wrapper.find('input[data-label="Enable resource sharing (ReBAC)"]').setValue(true); + await wrapper.find('input[data-label="ReBAC administrators"]').setValue('alice'); + await applyButton(wrapper, 'Finalize')?.trigger('click'); + expect(navigateTo).not.toHaveBeenCalled(); + expect(environment().REBAC_ENABLED).toBeUndefined(); + }); }); diff --git a/tests/unit/generatedStacks.test.ts b/tests/unit/generatedStacks.test.ts index b69d0912..3762830e 100644 --- a/tests/unit/generatedStacks.test.ts +++ b/tests/unit/generatedStacks.test.ts @@ -12,9 +12,13 @@ import { updateOptionalServices } from '@/utils/optionalServices'; import { createLocalRealm, DEFAULT_POLICY, + defaultReBACAdministrators, defaultTrustList, + LOCAL_REBAC_ADMIN_GROUP, localKeycloakService, + parseReBACAdministrators, validatePolicy, + validateReBACAdministrators, validateTrustList, } from '@/utils/securitySetup'; import { addOptionalSetupAssets } from '@/utils/setupAssets'; @@ -78,6 +82,14 @@ describe('generated local stacks', () => { ); expect(realm.clients[0].clientId).toBe('custom-ui'); expect(realm.users[0].credentials[0].temporary).toBe(true); + expect(realm.groups).toEqual([{ name: LOCAL_REBAC_ADMIN_GROUP }]); + expect(realm.users[0].groups).toEqual([`/${LOCAL_REBAC_ADMIN_GROUP}`]); + expect(realm.clients[0].protocolMappers).toContainEqual( + expect.objectContaining({ + protocolMapper: 'oidc-group-membership-mapper', + config: expect.objectContaining({ 'claim.name': 'groups', 'full.path': 'false' }), + }) + ); const service = localKeycloakService({ host: 'external-db', port: '5544', @@ -93,6 +105,24 @@ describe('generated local stacks', () => { expect(service?.networks).toEqual({ default: { aliases: ['keycloak.localhost'] } }); }); + it('allows the service description publicly and validates ReBAC administrators', () => { + const rules = JSON.parse(DEFAULT_POLICY).AllAccessPermissionRules; + expect(rules.DEFOBJECTS).toContainEqual({ + name: 'description', + objects: [{ ROUTE: '/description' }], + }); + expect(rules.rules[0]).toMatchObject({ USEACL: 'public_read', USEOBJECTS: ['description'] }); + expect(defaultReBACAdministrators()).toBe( + 'http://keycloak.localhost:8080/realms/basyx|group:basyx-admins' + ); + expect(parseReBACAdministrators(' a|b ,\n c|group:d ,')).toEqual(['a|b', 'c|group:d']); + expect(validateReBACAdministrators('https://idp|alice, https://idp|group:ops')).toBeUndefined(); + expect(validateReBACAdministrators('')).toBeUndefined(); + for (const invalid of ['alice', 'https://idp|', '|alice', 'https://idp|group:', 'a|b|c']) { + expect(validateReBACAdministrators(invalid), invalid).toMatch(/Invalid administrator/); + } + }); + it('packages optional files and omits policy content from share snapshots', async () => { const store = useAppStore(); store.accessPolicyJson = DEFAULT_POLICY; diff --git a/utils/securitySetup.ts b/utils/securitySetup.ts index 2f8f3a3f..cb35a922 100644 --- a/utils/securitySetup.ts +++ b/utils/securitySetup.ts @@ -1,12 +1,25 @@ import type { ComposeService } from '@/utils/optionalServices'; import { validateSchemaJson } from '@/utils/accessSchemas'; +export const LOCAL_KEYCLOAK_ISSUER = 'http://keycloak.localhost:8080/realms/basyx'; +export const LOCAL_REBAC_ADMIN_GROUP = 'basyx-admins'; + export const DEFAULT_POLICY = JSON.stringify( { AllAccessPermissionRules: { - DEFATTRIBUTES: [{ name: 'role_attr', attributes: [{ CLAIM: 'role' }] }], - DEFOBJECTS: [{ name: 'all_api', objects: [{ ROUTE: '/*' }] }], + DEFATTRIBUTES: [ + { name: 'anonymous_attr', attributes: [{ GLOBAL: 'ANONYMOUS' }] }, + { name: 'role_attr', attributes: [{ CLAIM: 'role' }] }, + ], + DEFOBJECTS: [ + { name: 'description', objects: [{ ROUTE: '/description' }] }, + { name: 'all_api', objects: [{ ROUTE: '/*' }] }, + ], DEFACLS: [ + { + name: 'public_read', + acl: { USEATTRIBUTES: 'anonymous_attr', RIGHTS: ['READ'], ACCESS: 'ALLOW' }, + }, { name: 'admin_full', acl: { USEATTRIBUTES: 'role_attr', RIGHTS: ['ALL'], ACCESS: 'ALLOW' }, @@ -18,7 +31,10 @@ export const DEFAULT_POLICY = JSON.stringify( formula: { $eq: [{ $attribute: { CLAIM: 'role' } }, { $strVal: 'admin' }] }, }, ], - rules: [{ USEACL: 'admin_full', USEOBJECTS: ['all_api'], USEFORMULA: 'is_admin' }], + rules: [ + { USEACL: 'public_read', USEOBJECTS: ['description'], FORMULA: { $boolean: true } }, + { USEACL: 'admin_full', USEOBJECTS: ['all_api'], USEFORMULA: 'is_admin' }, + ], }, }, null, @@ -26,7 +42,7 @@ export const DEFAULT_POLICY = JSON.stringify( ); export function defaultTrustList( - issuer = 'http://keycloak.localhost:8080/realms/basyx', + issuer = LOCAL_KEYCLOAK_ISSUER, audience = 'discovery-service' ): string { return JSON.stringify([{ issuer, audience, scopes: ['email', 'profile'] }], null, 2); @@ -40,6 +56,27 @@ export function validateTrustList(input: string): string | undefined { return validateSchemaJson(input, 'trust-list'); } +export function defaultReBACAdministrators(issuer = LOCAL_KEYCLOAK_ISSUER): string { + return `${issuer.trim()}|group:${LOCAL_REBAC_ADMIN_GROUP}`; +} + +export function parseReBACAdministrators(input: string): string[] { + return input + .split(/[\n,]/) + .map(entry => entry.trim()) + .filter(Boolean); +} + +export function validateReBACAdministrators(input: string): string | undefined { + const invalid = parseReBACAdministrators(input).find(entry => { + const [issuer, principal, ...rest] = entry.split('|').map(part => part.trim()); + return !issuer || !principal || rest.length > 0 || principal === 'group:'; + }); + return invalid + ? `Invalid administrator "${invalid}". Use issuer|subject or issuer|group:.` + : undefined; +} + export function localKeycloakService(database: { host: string; port: string; @@ -107,6 +144,18 @@ export function createLocalRealm( 'userinfo.token.claim': 'true', }, }, + { + name: 'groups', + protocol: 'openid-connect', + protocolMapper: 'oidc-group-membership-mapper', + config: { + 'full.path': 'false', + 'claim.name': 'groups', + 'access.token.claim': 'true', + 'id.token.claim': 'true', + 'userinfo.token.claim': 'true', + }, + }, { name: 'audience', protocol: 'openid-connect', @@ -119,11 +168,13 @@ export function createLocalRealm( ], }, ], + groups: [{ name: LOCAL_REBAC_ADMIN_GROUP }], users: [ { username: 'basyx-admin', enabled: true, attributes: { role: ['admin'] }, + groups: [`/${LOCAL_REBAC_ADMIN_GROUP}`], credentials: [{ type: 'password', value: adminPassword, temporary: true }], }, ], diff --git a/utils/setupAssets.ts b/utils/setupAssets.ts index c4480e95..1f65a6f8 100644 --- a/utils/setupAssets.ts +++ b/utils/setupAssets.ts @@ -11,7 +11,7 @@ import { rabbitmqDefinitions, TEMPO_CONFIG, } from '@/utils/localStacks'; -import { createLocalRealm } from '@/utils/securitySetup'; +import { createLocalRealm, LOCAL_REBAC_ADMIN_GROUP } from '@/utils/securitySetup'; export interface SetupAssets { services: Record; @@ -58,5 +58,9 @@ export function addOptionalSetupAssets(zip: JSZip, setup: SetupAssets): string { 'keycloak/realm/basyx-realm.json', createLocalRealm(setup.adminPassword, setup.uiUrl, setup.uiClientId) ); - return `\n## Local Keycloak\n\nOpen http://keycloak.localhost:8080 and sign in as \`basyx-admin\` with the temporary password \`${setup.adminPassword}\`. Change it immediately. This local identity provider is for development only.\n`; + const rebacNote = + environment.REBAC_ENABLED === 'true' + ? ` \`basyx-admin\` is a member of the \`${LOCAL_REBAC_ADMIN_GROUP}\` group. Add users to this group to make them ReBAC administrators, or to other groups to share resources with them.` + : ''; + return `\n## Local Keycloak\n\nOpen http://keycloak.localhost:8080 and sign in as \`basyx-admin\` with the temporary password \`${setup.adminPassword}\`. Change it immediately.${rebacNote} This local identity provider is for development only.\n`; } From ee0522b7c6d4edc915e49f20a532356d2520a83c Mon Sep 17 00:00:00 2001 From: Aaron Zielstorff Date: Mon, 28 Sep 2026 17:39:36 +0200 Subject: [PATCH 2/2] Accept pipes in ReBAC subjects and follow issuer changes - Split administrator entries only at the first "|", like BaSyx Go, so subjects such as auth0|123 are accepted. - Move an untouched generated administrator default to the new issuer when the issuer or identity provider changes; keep edited lists. Co-Authored-By: Claude Opus 5.5 --- .../get-started/deployment/access-control.vue | 5 +++ tests/component/GoConfigurationPages.test.ts | 31 +++++++++++++++++++ tests/unit/generatedStacks.test.ts | 3 +- utils/securitySetup.ts | 16 +++++++--- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/pages/get-started/deployment/access-control.vue b/pages/get-started/deployment/access-control.vue index ebe72d97..1a30844d 100644 --- a/pages/get-started/deployment/access-control.vue +++ b/pages/get-started/deployment/access-control.vue @@ -450,5 +450,10 @@ function finalizeSettings(): void { navigateTo('/get-started/download'); } +watch(issuer, (newIssuer, oldIssuer) => { + if (rebacAdministrators.value.trim() === defaultReBACAdministrators(oldIssuer)) { + rebacAdministrators.value = defaultReBACAdministrators(newIssuer); + } +}); watch(compose, syncFromCompose, { immediate: true }); diff --git a/tests/component/GoConfigurationPages.test.ts b/tests/component/GoConfigurationPages.test.ts index bbc65537..9de6b3c2 100644 --- a/tests/component/GoConfigurationPages.test.ts +++ b/tests/component/GoConfigurationPages.test.ts @@ -461,6 +461,37 @@ describe('BaSyx Go configuration pages', () => { expect(environment().REBAC_ADMINISTRATORS).toBeUndefined(); }); + it('moves the generated ReBAC administrator to the selected issuer', async () => { + const wrapper = mount(AccessControlPage, { global: { stubs: globalStubs } }); + await wrapper.find('input[data-label="Enable access control"]').setValue(true); + await wrapper.find('input[data-label="Include local Keycloak container"]').setValue(false); + await wrapper + .find('input[data-label="OIDC issuer URL"]') + .setValue('https://id.example.test/realms/basyx'); + await wrapper.find('input[data-label="Enable resource sharing (ReBAC)"]').setValue(true); + await nextTick(); + await wrapper.find('input[data-label="Include local Keycloak container"]').setValue(true); + await nextTick(); + await applyButton(wrapper, 'Finalize')?.trigger('click'); + expect(environment().REBAC_ADMINISTRATORS).toBe( + 'http://keycloak.localhost:8080/realms/basyx|group:basyx-admins' + ); + + await wrapper + .find('input[data-label="ReBAC administrators"]') + .setValue('http://keycloak.localhost:8080/realms/basyx|group:operators'); + await wrapper.find('input[data-label="Include local Keycloak container"]').setValue(false); + await wrapper + .find('input[data-label="OIDC issuer URL"]') + .setValue('https://id.example.test/realms/basyx'); + await nextTick(); + await applyButton(wrapper, 'Apply Access Control Settings')?.trigger('click'); + await nextTick(); + expect(environment().REBAC_ADMINISTRATORS).toBe( + 'http://keycloak.localhost:8080/realms/basyx|group:operators' + ); + }); + it('removes ReBAC when access control is disabled', async () => { const wrapper = mount(AccessControlPage, { global: { stubs: globalStubs } }); await wrapper.find('input[data-label="Enable access control"]').setValue(true); diff --git a/tests/unit/generatedStacks.test.ts b/tests/unit/generatedStacks.test.ts index 3762830e..1a96eb99 100644 --- a/tests/unit/generatedStacks.test.ts +++ b/tests/unit/generatedStacks.test.ts @@ -117,8 +117,9 @@ describe('generated local stacks', () => { ); expect(parseReBACAdministrators(' a|b ,\n c|group:d ,')).toEqual(['a|b', 'c|group:d']); expect(validateReBACAdministrators('https://idp|alice, https://idp|group:ops')).toBeUndefined(); + expect(validateReBACAdministrators('https://tenant.auth0.com/|auth0|123')).toBeUndefined(); expect(validateReBACAdministrators('')).toBeUndefined(); - for (const invalid of ['alice', 'https://idp|', '|alice', 'https://idp|group:', 'a|b|c']) { + for (const invalid of ['alice', 'https://idp|', '|alice', 'https://idp|group:', 'a|group: ']) { expect(validateReBACAdministrators(invalid), invalid).toMatch(/Invalid administrator/); } }); diff --git a/utils/securitySetup.ts b/utils/securitySetup.ts index cb35a922..60543f88 100644 --- a/utils/securitySetup.ts +++ b/utils/securitySetup.ts @@ -67,11 +67,19 @@ export function parseReBACAdministrators(input: string): string[] { .filter(Boolean); } +function isValidReBACAdministrator(entry: string): boolean { + const separator = entry.indexOf('|'); + if (separator < 0) return false; + const issuer = entry.slice(0, separator).trim(); + const principal = entry.slice(separator + 1).trim(); + const group = principal.startsWith('group:') + ? principal.slice('group:'.length).trim() + : principal; + return Boolean(issuer && principal && group); +} + export function validateReBACAdministrators(input: string): string | undefined { - const invalid = parseReBACAdministrators(input).find(entry => { - const [issuer, principal, ...rest] = entry.split('|').map(part => part.trim()); - return !issuer || !principal || rest.length > 0 || principal === 'group:'; - }); + const invalid = parseReBACAdministrators(input).find(entry => !isValidReBACAdministrator(entry)); return invalid ? `Invalid administrator "${invalid}". Use issuer|subject or issuer|group:.` : undefined;