diff --git a/.changeset/preserve-scoped-catalog-capabilities.md b/.changeset/preserve-scoped-catalog-capabilities.md new file mode 100644 index 000000000..462da7605 --- /dev/null +++ b/.changeset/preserve-scoped-catalog-capabilities.md @@ -0,0 +1,5 @@ +--- +'@openchoreo/backstage-plugin-permission-backend-module-openchoreo-policy': patch +--- + +Preserve scoped capability paths for catalog entity visibility checks (Fixes #763) diff --git a/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.test.ts b/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.test.ts new file mode 100644 index 000000000..2a491327c --- /dev/null +++ b/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.test.ts @@ -0,0 +1,181 @@ +import { mockServices } from '@backstage/backend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/alpha'; +import { Entity } from '@backstage/catalog-model'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { PolicyQueryUser } from '@backstage/plugin-permission-node'; +import { OpenChoreoPermissionPolicy } from './OpenChoreoPermissionPolicy'; +import { matchesCatalogEntityCapability } from '../rules'; +import { AuthzProfileService } from '../services'; + +function makeEntity( + kind: string, + annotations: Record = {}, +): Entity { + const cleanAnnotations: Record = {}; + for (const [k, v] of Object.entries(annotations)) { + if (v !== undefined) { + cleanAnnotations[k] = v; + } + } + return { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { + name: 'test-entity', + annotations: cleanAnnotations, + }, + }; +} + +describe('OpenChoreoPermissionPolicy - Catalog Permission Scoped Capabilities (Issue #763)', () => { + const mockLogger = mockServices.logger.mock(); + + it('preserves scoped capability paths (e.g. with constraints) in kindCapabilities for System/Project entities', async () => { + const mockAuthzService = { + getCapabilitiesForUser: jest.fn().mockResolvedValue({ + capabilities: { + 'project:view': { + allowed: [ + { + path: 'ns/acme/project/project-a', + constraints: { + expressions: ['resource.environment == "prod"'], + }, + }, + ], + denied: [], + }, + }, + }), + } as unknown as AuthzProfileService; + + const policy = new OpenChoreoPermissionPolicy({ + authzService: mockAuthzService, + logger: mockLogger, + }); + + const user: PolicyQueryUser = { + info: { + userEntityRef: 'user:default/scoped-user', + }, + } as unknown as PolicyQueryUser; + + const decision = await policy.handle( + { permission: catalogEntityReadPermission }, + user, + ); + + expect(decision.result).toBe(AuthorizeResult.CONDITIONAL); + if (decision.result !== AuthorizeResult.CONDITIONAL) { + throw new Error('Expected CONDITIONAL decision'); + } + + const ruleCondition = decision.conditions as any; + expect(ruleCondition).toBeDefined(); + expect(ruleCondition.rule).toBe('MATCHES_CATALOG_ENTITY_CAPABILITY'); + + const kindCapabilities = JSON.parse( + ruleCondition.params.kindCapabilitiesJson, + ); + + // Assert that kindCapabilities.system.allowedPaths contains the scoped project path + expect(kindCapabilities.system).toBeDefined(); + expect(kindCapabilities.system.allowedPaths).toEqual([ + 'ns/acme/project/project-a', + ]); + + // Test apply against a matching System entity (OpenChoreo Project) + const matchingEntity = makeEntity('System', { + [CHOREO_ANNOTATIONS.NAMESPACE]: 'acme', + [CHOREO_ANNOTATIONS.PROJECT_ID]: 'project-a', + }); + + const matchesMatching = matchesCatalogEntityCapability.apply( + matchingEntity, + ruleCondition.params, + ); + expect(matchesMatching).toBe(true); + + // Test apply against a non-matching System entity (out-of-scope project) + const nonMatchingEntity = makeEntity('System', { + [CHOREO_ANNOTATIONS.NAMESPACE]: 'acme', + [CHOREO_ANNOTATIONS.PROJECT_ID]: 'project-b', + }); + + const matchesNonMatching = matchesCatalogEntityCapability.apply( + nonMatchingEntity, + ruleCondition.params, + ); + expect(matchesNonMatching).toBe(false); + }); + + it('allows plain unconstrained scoped capability paths', async () => { + const mockAuthzService = { + getCapabilitiesForUser: jest.fn().mockResolvedValue({ + capabilities: { + 'project:view': { + allowed: [ + { + path: 'ns/acme/project/project-a', + }, + ], + denied: [], + }, + }, + }), + } as unknown as AuthzProfileService; + + const policy = new OpenChoreoPermissionPolicy({ + authzService: mockAuthzService, + logger: mockLogger, + }); + + const user: PolicyQueryUser = { + info: { + userEntityRef: 'user:default/scoped-user', + }, + } as unknown as PolicyQueryUser; + + const decision = await policy.handle( + { permission: catalogEntityReadPermission }, + user, + ); + + expect(decision.result).toBe(AuthorizeResult.CONDITIONAL); + if (decision.result !== AuthorizeResult.CONDITIONAL) { + throw new Error('Expected CONDITIONAL decision'); + } + + const ruleCondition = decision.conditions as any; + const kindCapabilities = JSON.parse( + ruleCondition.params.kindCapabilitiesJson, + ); + + expect(kindCapabilities.system.allowedPaths).toEqual([ + 'ns/acme/project/project-a', + ]); + + const matchingEntity = makeEntity('System', { + [CHOREO_ANNOTATIONS.NAMESPACE]: 'acme', + [CHOREO_ANNOTATIONS.PROJECT_ID]: 'project-a', + }); + expect( + matchesCatalogEntityCapability.apply( + matchingEntity, + ruleCondition.params, + ), + ).toBe(true); + + const nonMatchingEntity = makeEntity('System', { + [CHOREO_ANNOTATIONS.NAMESPACE]: 'acme', + [CHOREO_ANNOTATIONS.PROJECT_ID]: 'project-b', + }); + expect( + matchesCatalogEntityCapability.apply( + nonMatchingEntity, + ruleCondition.params, + ), + ).toBe(false); + }); +}); diff --git a/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.ts b/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.ts index 418784b5f..ba934aff7 100644 --- a/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.ts +++ b/plugins/permission-backend-module-openchoreo-policy/src/policy/OpenChoreoPermissionPolicy.ts @@ -302,11 +302,13 @@ export class OpenChoreoPermissionPolicy implements PermissionPolicy { action, ); - // Catalog visibility has no environment context, so we cannot satisfy - // ABAC CEL expressions here. Drop constrained entries — entity-level - // actions are gated separately by matchesCapability + env-aware hooks. - const allowedPaths = unconstrainedPaths(actionCapability?.allowed); - const deniedPaths = unconstrainedPaths(actionCapability?.denied); + // Catalog visibility controls which entities appear in the catalog by + // matching their hierarchical scope (namespace, project, component). + // Scoped capability paths are preserved and passed to matchesCatalogEntityCapability; + // fine-grained environment/resource-action authorization is enforced downstream + // by matchesCapability and env-aware hooks. + const allowedPaths = extractPaths(actionCapability?.allowed); + const deniedPaths = extractPaths(actionCapability?.denied); kindCapabilities[kindLower] = { action,