diff --git a/plugins/rbac-backend/docs/conditions.md b/plugins/rbac-backend/docs/conditions.md index fe23a7ca28..96cb4fcbf4 100644 --- a/plugins/rbac-backend/docs/conditions.md +++ b/plugins/rbac-backend/docs/conditions.md @@ -274,13 +274,49 @@ To utilize this condition to the RBAC REST api you need to wrap it with more inf } ``` +## Conditional Policy Aliases + +The RBAC-backend plugin allows for the use of aliases in the conditional policy rule parameters. These aliases are dynamically replaced with corresponding values during the policy evaluation process. Each alias is prefixed with a `$` sign to denote its special function. + +### Supported Aliases + +1. **`$currentUser`**: + + - **Description**: This alias is replaced with the user entity reference for the user currently requesting access to the resource. + - **Example**: If the user "Tom" from the "default" namespace is requesting access, `$currentUser` will be replaced with `user:default/tom`. + +2. **`$ownerRefs`**: + - **Description**: This alias is replaced with ownership references, typically in the form of an array. The array usually contains the user entity reference and the user's parent group entity reference. + - **Example**: For a user "Tom" who belongs to "team-a", `$ownerRefs` will be replaced with `['user:default/tom', 'group:default/team-a']`. + +### Example of a Conditional Policy Object with Alias + +This condition should allow members of the `role:default/developer` to delete only their own catalogs and no others: + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/developer", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["delete"], + "conditions": { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["$currentUser"] + } + } +} +``` + ## Examples of Conditional Policies Below are a few examples that can be used on some of the Janus IDP plugins. These can help in determining how based to define conditional policies ### Keycloak plugin -```JSON +```json { "result": "CONDITIONAL", "roleEntityRef": "role:default/developer", @@ -303,7 +339,7 @@ Notice the use of the annotation `keycloak.org/realm` requires the value of ` { + describe('should replace "currentUser" aliases', () => { + it('should replace aliases in the string value', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'TEST', + resourceType: 'test-entity', + params: { + test: '$currentUser', + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'TEST', + resourceType: 'test-entity', + params: { + test: 'user:default/tim', + }, + }); + }); + }); + + it('should replace aliases in the string array', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }); + }); + + it('should replace aliases with criteria not', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + }); + }); + + it('should replace aliases with criteria anyOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with nested criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + describe('should replace "ownerRefs" aliases', () => { + it('should replace aliases without criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }); + }); + + it('should replace aliases with criteria not', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + }); + }); + + it('should replace aliases with criteria anyOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with nested criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + }); +}); diff --git a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts new file mode 100644 index 0000000000..e58d118ebc --- /dev/null +++ b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts @@ -0,0 +1,112 @@ +import { BackstageUserInfo } from '@backstage/backend-plugin-api'; +import { + PermissionCondition, + PermissionCriteria, + PermissionRuleParam, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; +import { JsonPrimitive } from '@backstage/types'; + +import { + CONDITION_ALIAS_SIGN, + ConditionalAliases, +} from '@janus-idp/backstage-plugin-rbac-common'; + +interface Predicate { + (item: T): boolean; +} + +function isOwnerRefsAlias(value: PermissionRuleParam): boolean { + const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.OWNER_REFS}`; + return value === alias; +} + +function isCurrentUserAlias(value: PermissionRuleParam): boolean { + const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.CURRENT_USER}`; + return value === alias; +} + +function replaceAliasWithValue< + K extends string, + V extends JsonPrimitive | JsonPrimitive[], +>( + params: Record | undefined, + key: K, + predicate: Predicate, + newValue: V, +): Record | undefined { + if (!params) { + return params; + } + + if (Array.isArray(params[key])) { + const oldValues = params[key] as JsonPrimitive[]; + const nonAliasValues: JsonPrimitive[] = []; + for (const oldValue of oldValues) { + const isAliasMatched = predicate(oldValue); + if (isAliasMatched) { + const newValues = Array.isArray(newValue) ? newValue : [newValue]; + nonAliasValues.push(...newValues); + } else { + nonAliasValues.push(oldValue); + } + } + return { ...params, [key]: nonAliasValues }; + } + + const oldValue = params[key] as JsonPrimitive; + const isAliasMatched = predicate(oldValue); + if (isAliasMatched && !Array.isArray(newValue)) { + return { ...params, [key]: newValue }; + } + + return params; +} + +export function replaceAliases( + conditions: PermissionCriteria< + PermissionCondition + >, + userInfo: BackstageUserInfo, +) { + if ('not' in conditions) { + replaceAliases(conditions.not, userInfo); + return; + } + if ('allOf' in conditions) { + for (const condition of conditions.allOf) { + replaceAliases(condition, userInfo); + } + return; + } + if ('anyOf' in conditions) { + for (const condition of conditions.anyOf) { + replaceAliases(condition, userInfo); + return; + } + } + + const params = ( + conditions as PermissionCondition + ).params; + if (params) { + for (const key of Object.keys(params)) { + let modifiedParams = replaceAliasWithValue( + params, + key, + isCurrentUserAlias, + userInfo.userEntityRef, + ); + + modifiedParams = replaceAliasWithValue( + modifiedParams, + key, + isOwnerRefsAlias, + userInfo.ownershipEntityRefs, + ); + + (conditions as PermissionCondition).params = + modifiedParams; + } + } +} diff --git a/plugins/rbac-backend/src/plugin.ts b/plugins/rbac-backend/src/plugin.ts index 3c30b804c6..68467fb857 100644 --- a/plugins/rbac-backend/src/plugin.ts +++ b/plugins/rbac-backend/src/plugin.ts @@ -42,6 +42,7 @@ export const rbacPlugin = createBackendPlugin({ permissions: coreServices.permissions, auth: coreServices.auth, httpAuth: coreServices.httpAuth, + userInfo: coreServices.userInfo, }, async init({ http, @@ -52,6 +53,7 @@ export const rbacPlugin = createBackendPlugin({ permissions, auth, httpAuth, + userInfo, }) { http.use( await PolicyBuilder.build( @@ -63,6 +65,7 @@ export const rbacPlugin = createBackendPlugin({ permissions, auth, httpAuth, + userInfo, }, { getPluginIds: () => diff --git a/plugins/rbac-backend/src/service/permission-policy.test.ts b/plugins/rbac-backend/src/service/permission-policy.test.ts index 6c0918bb88..e4d6905791 100644 --- a/plugins/rbac-backend/src/service/permission-policy.test.ts +++ b/plugins/rbac-backend/src/service/permission-policy.test.ts @@ -3,12 +3,14 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { mockServices } from '@backstage/backend-test-utils'; import { Entity } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; -import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { AuthorizeResult, createPermission, } from '@backstage/plugin-permission-common'; -import { PolicyQuery } from '@backstage/plugin-permission-node'; +import { + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; import { Adapter, @@ -153,7 +155,7 @@ describe('RBACPermissionPolicy Tests', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/guest'), + newPolicyQueryUser('user:default/guest'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -169,7 +171,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should allow create access to resource permission for user from csv file', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('catalog.entity.create'), - newIdentityResponse('user:default/guest'), + newPolicyQueryUser('user:default/guest'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -185,7 +187,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should allow deny access to resource permission for user:default/known_user', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource.deny'), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -205,7 +207,7 @@ describe('RBACPermissionPolicy Tests', () => { 'catalog-entity', 'update', ), - newIdentityResponse('user:default/guest'), + newPolicyQueryUser('user:default/guest'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -225,7 +227,7 @@ describe('RBACPermissionPolicy Tests', () => { 'catalog-entity', 'update', ), - newIdentityResponse('role:default/catalog-writer'), + newPolicyQueryUser('role:default/catalog-writer'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -379,7 +381,7 @@ describe('RBACPermissionPolicy Tests', () => { const decision = await rbacPolicy.handle( newPolicyQueryWithBasicPermission('test.some.resource'), - newIdentityResponse('user:default/user-old-1'), + newPolicyQueryUser('user:default/user-old-1'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -622,7 +624,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should deny access to basic permission for listed user with deny action', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource.deny'), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -637,7 +639,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should deny access to basic permission for unlisted user', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('unuser:default/known_user'), + newPolicyQueryUser('unuser:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -652,7 +654,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should deny access to basic permission for listed user deny and allow', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/duplicated'), + newPolicyQueryUser('user:default/duplicated'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -667,7 +669,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should allow access to basic permission for user listed on policy', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -682,7 +684,7 @@ describe('RBACPermissionPolicy Tests', () => { it('should deny access to undefined user', async () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse(), + newPolicyQueryUser(), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -704,7 +706,7 @@ describe('RBACPermissionPolicy Tests', () => { 'test-resource-deny', 'update', ), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForResourcedPermission( @@ -723,7 +725,7 @@ describe('RBACPermissionPolicy Tests', () => { 'test-resource', 'update', ), - newIdentityResponse('unuser:default/known_user'), + newPolicyQueryUser('unuser:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForResourcedPermission( @@ -742,7 +744,7 @@ describe('RBACPermissionPolicy Tests', () => { 'test-resource', 'update', ), - newIdentityResponse('user:default/duplicated'), + newPolicyQueryUser('user:default/duplicated'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForResourcedPermission( @@ -761,7 +763,7 @@ describe('RBACPermissionPolicy Tests', () => { 'test-resource', 'update', ), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -781,7 +783,7 @@ describe('RBACPermissionPolicy Tests', () => { 'test-resource', 'delete', ), - newIdentityResponse('user:default/known_user'), + newPolicyQueryUser('user:default/known_user'), ); expect(decision.result).toBe(AuthorizeResult.DENY); }); @@ -826,7 +828,7 @@ describe('RBACPermissionPolicy Tests', () => { perm.resource, perm.action, ), - newIdentityResponse('user:default/guest'), + newPolicyQueryUser('user:default/guest'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -988,7 +990,7 @@ describe('RBACPermissionPolicy Tests', () => { 'policy-entity', 'read', ), - newIdentityResponse('user:default/test_admin'), + newPolicyQueryUser('user:default/test_admin'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -1007,7 +1009,7 @@ describe('RBACPermissionPolicy Tests', () => { 'policy-entity', 'read', ), - newIdentityResponse('user:default/super_user'), + newPolicyQueryUser('user:default/super_user'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -1025,7 +1027,7 @@ describe('RBACPermissionPolicy Tests', () => { 'catalog-entity', 'delete', ), - newIdentityResponse('user:default/super_user'), + newPolicyQueryUser('user:default/super_user'), ); expect(decision2.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForResourcedPermission( @@ -1107,7 +1109,7 @@ describe('Policy checks for resourced permissions defined by name', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/tor'), + newPolicyQueryUser('user:default/tor'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); }); @@ -1134,7 +1136,7 @@ describe('Policy checks for resourced permissions defined by name', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/tor'), + newPolicyQueryUser('user:default/tor'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); }); @@ -1162,7 +1164,7 @@ describe('Policy checks for resourced permissions defined by name', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/tor'), + newPolicyQueryUser('user:default/tor'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1209,7 +1211,7 @@ describe('Policy checks for resourced permissions defined by name', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/tor'), + newPolicyQueryUser('user:default/tor'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1273,7 +1275,7 @@ describe('Policy checks for resourced permissions defined by name', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/tor'), + newPolicyQueryUser('user:default/tor'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1339,7 +1341,7 @@ describe('Policy checks for users and groups', () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/alice'), + newPolicyQueryUser('user:default/alice'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1367,7 +1369,7 @@ describe('Policy checks for users and groups', () => { catalogApi.getEntities.mockReturnValue({ items: [entityMock] }); const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/akira'), + newPolicyQueryUser('user:default/akira'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1395,7 +1397,7 @@ describe('Policy checks for users and groups', () => { catalogApi.getEntities.mockReturnValue({ items: [entityMock] }); const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/antey'), + newPolicyQueryUser('user:default/antey'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1421,7 +1423,7 @@ describe('Policy checks for users and groups', () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/julia'), + newPolicyQueryUser('user:default/julia'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1449,7 +1451,7 @@ describe('Policy checks for users and groups', () => { catalogApi.getEntities.mockReturnValue({ items: [entityMock] }); const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1477,7 +1479,7 @@ describe('Policy checks for users and groups', () => { catalogApi.getEntities.mockReturnValue({ items: [entityMock] }); const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource'), - newIdentityResponse('user:default/tom'), + newPolicyQueryUser('user:default/tom'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1519,7 +1521,7 @@ describe('Policy checks for users and groups', () => { const decision = await policy.handle( newPolicyQueryWithBasicPermission('test.resource.2'), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1554,7 +1556,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/alice'), + newPolicyQueryUser('user:default/alice'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1583,7 +1585,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/akira'), + newPolicyQueryUser('user:default/akira'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1612,7 +1614,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/antey'), + newPolicyQueryUser('user:default/antey'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1642,7 +1644,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/julia'), + newPolicyQueryUser('user:default/julia'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1674,7 +1676,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1706,7 +1708,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'read', ), - newIdentityResponse('user:default/tom'), + newPolicyQueryUser('user:default/tom'), ); expect(decision.result).toBe(AuthorizeResult.DENY); verifyAuditLogForNonResourcedPermission( @@ -1752,7 +1754,7 @@ describe('Policy checks for users and groups', () => { 'test-resource', 'create', ), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision.result).toBe(AuthorizeResult.ALLOW); verifyAuditLogForNonResourcedPermission( @@ -1838,7 +1840,7 @@ describe('Policy checks for conditional policies', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision).toStrictEqual({ pluginId: 'catalog', @@ -1858,6 +1860,66 @@ describe('Policy checks for conditional policies', () => { }); }); + it('should execute condition policy with current user alias', async () => { + const entityMock: Entity = { + apiVersion: 'v1', + kind: 'Group', + metadata: { + name: 'test-group', + namespace: 'default', + }, + spec: { + members: ['mike'], + }, + }; + catalogApi.getEntities.mockReturnValue({ items: [entityMock] }); + (conditionalStorage.filterConditions as jest.Mock).mockReturnValueOnce([ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }, + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', [ + 'user:default/mike', + 'group:default/team-a', + ]), + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/mike'], + }, + }, + ], + }, + }); + }); + it('should merge condition policies for user assigned to few roles', async () => { const entityMock: Entity = { apiVersion: 'v1', @@ -1923,7 +1985,7 @@ describe('Policy checks for conditional policies', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision).toStrictEqual({ pluginId: 'catalog', @@ -2000,7 +2062,7 @@ describe('Policy checks for conditional policies', () => { 'catalog-entity', 'read', ), - newIdentityResponse('user:default/mike'), + newPolicyQueryUser('user:default/mike'), ); expect(decision).toStrictEqual({ result: AuthorizeResult.DENY, @@ -2032,17 +2094,27 @@ function newPolicyQueryWithResourcePermission( return { permission: mockPermission }; } -function newIdentityResponse( +function newPolicyQueryUser( user?: string, -): BackstageIdentityResponse | undefined { + ownershipEntityRefs?: string[], +): PolicyQueryUser | undefined { if (user) { return { identity: { - ownershipEntityRefs: [], + ownershipEntityRefs: ownershipEntityRefs ?? [], type: 'user', userEntityRef: user, }, - token: '', + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: true, + expiresAt: new Date('2021-01-01T00:00:00Z'), + }, + info: { + userEntityRef: user, + ownershipEntityRefs: ownershipEntityRefs ?? [], + }, + token: 'token', }; } return undefined; diff --git a/plugins/rbac-backend/src/service/permission-policy.ts b/plugins/rbac-backend/src/service/permission-policy.ts index 61cd54f9c8..5960944e96 100644 --- a/plugins/rbac-backend/src/service/permission-policy.ts +++ b/plugins/rbac-backend/src/service/permission-policy.ts @@ -1,7 +1,9 @@ -import { LoggerService } from '@backstage/backend-plugin-api'; +import { + BackstageUserInfo, + LoggerService, +} from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core-plugin-api'; -import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { AuthorizeResult, ConditionalPolicyDecision, @@ -16,6 +18,7 @@ import { import { PermissionPolicy, PolicyQuery, + PolicyQueryUser, } from '@backstage/plugin-permission-node'; import { Knex } from 'knex'; @@ -37,6 +40,7 @@ import { RoleAuditInfo, RoleEvents, } from '../audit-log/audit-logger'; +import { replaceAliases } from '../conditional-aliases/alias-resolver'; import { ConditionalStorage } from '../database/conditional-storage'; import { RoleMetadataDao, @@ -290,10 +294,9 @@ export class RBACPermissionPolicy implements PermissionPolicy { async handle( request: PolicyQuery, - identityResp?: BackstageIdentityResponse | undefined, + user?: PolicyQueryUser, ): Promise { - const userEntityRef = - identityResp?.identity.userEntityRef ?? `user without entity`; + const userEntityRef = user?.info.userEntityRef ?? `user without entity`; let auditOptions = createPermissionEvaluationOptions( `Policy check for ${userEntityRef}`, @@ -306,7 +309,7 @@ export class RBACPermissionPolicy implements PermissionPolicy { let status = false; const action = toPermissionAction(request.permission.attributes); - if (!identityResp) { + if (!user) { const msg = evaluatePermMsg( userEntityRef, AuthorizeResult.DENY, @@ -329,11 +332,12 @@ export class RBACPermissionPolicy implements PermissionPolicy { const resourceType = request.permission.resourceType; // handle conditions if they are present - if (identityResp) { + if (user) { const conditionResult = await this.handleConditions( userEntityRef, request, roles, + user.info, ); if (conditionResult) { return conditionResult; @@ -416,6 +420,7 @@ export class RBACPermissionPolicy implements PermissionPolicy { userEntityRef: string, request: PolicyQuery, roles: string[], + userInfo: BackstageUserInfo, ): Promise { const permissionName = request.permission.name; const resourceType = (request.permission as ResourcePermission) @@ -471,6 +476,9 @@ export class RBACPermissionPolicy implements PermissionPolicy { >, }, }; + + replaceAliases(result.conditions, userInfo); + const msg = `Send condition to plugin with id ${pluginId} to evaluate permission ${permissionName} with resource type ${resourceType} and action ${action} for user ${userEntityRef}`; const auditOptions = createPermissionEvaluationOptions( msg, diff --git a/plugins/rbac-backend/src/service/policy-builder.test.ts b/plugins/rbac-backend/src/service/policy-builder.test.ts index ff8942fdc5..2c364914b1 100644 --- a/plugins/rbac-backend/src/service/policy-builder.test.ts +++ b/plugins/rbac-backend/src/service/policy-builder.test.ts @@ -1,5 +1,6 @@ import { getVoidLogger } from '@backstage/backend-common'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { LoggerService, UserInfoService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; @@ -72,6 +73,8 @@ jest.mock('./permission-policy', () => { }; }); +const userInfoServiceMock: UserInfoService = mockServices.userInfo(); + describe('PolicyBuilder', () => { const mockedAuthorize = jest.fn().mockImplementation(async () => [ { @@ -142,6 +145,7 @@ describe('PolicyBuilder', () => { discovery: mockDiscovery, identity: mockIdentityClient, permissions: mockPermissionEvaluator, + userInfo: userInfoServiceMock, }, backendPluginIDsProviderMock, ); @@ -178,6 +182,7 @@ describe('PolicyBuilder', () => { discovery: mockDiscovery, identity: mockIdentityClient, permissions: mockPermissionEvaluator, + userInfo: userInfoServiceMock, }, backendPluginIDsProviderMock, ); @@ -217,6 +222,7 @@ describe('PolicyBuilder', () => { discovery: mockDiscovery, identity: mockIdentityClient, permissions: mockPermissionEvaluator, + userInfo: userInfoServiceMock, }, pluginIdProvider, ); @@ -258,6 +264,7 @@ describe('PolicyBuilder', () => { discovery: mockDiscovery, identity: mockIdentityClient, permissions: mockPermissionEvaluator, + userInfo: userInfoServiceMock, }, pluginIdProvider, ); @@ -297,6 +304,7 @@ describe('PolicyBuilder', () => { discovery: mockDiscovery, identity: mockIdentityClient, permissions: mockPermissionEvaluator, + userInfo: userInfoServiceMock, }); expect(CasbinDBAdapterFactory).toHaveBeenCalled(); expect(mockEnforcer.loadPolicy).toHaveBeenCalled(); diff --git a/plugins/rbac-backend/src/service/policy-builder.ts b/plugins/rbac-backend/src/service/policy-builder.ts index cba77d25ae..e78af9a04b 100644 --- a/plugins/rbac-backend/src/service/policy-builder.ts +++ b/plugins/rbac-backend/src/service/policy-builder.ts @@ -7,6 +7,7 @@ import { AuthService, HttpAuthService, LoggerService, + UserInfoService, } from '@backstage/backend-plugin-api'; import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; @@ -40,6 +41,7 @@ export class PolicyBuilder { permissions: PermissionEvaluator; auth?: AuthService; httpAuth?: HttpAuthService; + userInfo: UserInfoService; }, pluginIdProvider: PluginIdProvider = { getPluginIds: () => [] }, ): Promise { @@ -108,6 +110,7 @@ export class PolicyBuilder { logger: env.logger, discovery: env.discovery, identity: env.identity, + userInfo: env.userInfo, policy: await RBACPermissionPolicy.build( env.logger, defAuditLog, diff --git a/plugins/rbac-common/src/types.ts b/plugins/rbac-common/src/types.ts index 952fa34043..b95c3efcc9 100644 --- a/plugins/rbac-common/src/types.ts +++ b/plugins/rbac-common/src/types.ts @@ -106,6 +106,13 @@ export type RoleConditionalPolicyDecision< permissionMapping: T[]; }; +export const ConditionalAliases = { + CURRENT_USER: 'currentUser', + OWNER_REFS: 'ownerRefs', +} as const; + +export const CONDITION_ALIAS_SIGN = '$'; + // UnauthorizedError should be uniformely used for authorization errors. export class UnauthorizedError extends NotAllowedError { constructor() { diff --git a/yarn.lock b/yarn.lock index 526c09f599..fac3c48608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14070,18 +14070,18 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== -"@typescript-eslint/types@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.16.1.tgz#bbab066276d18e398bc64067b23f1ce84dfc6d8c" - integrity sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ== +"@typescript-eslint/types@7.18.0": + version "7.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.18.0.tgz#b90a57ccdea71797ffffa0321e744f379ec838c9" + integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== "@typescript-eslint/typescript-estree@5.62.0", "@typescript-eslint/typescript-estree@6.21.0", "@typescript-eslint/typescript-estree@^7.3.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.1.tgz#9b145ba4fd1dde1986697e1ce57dc501a1736dd3" - integrity sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ== + version "7.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz#b5868d486c51ce8f312309ba79bdb9f331b37931" + integrity sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== dependencies: - "@typescript-eslint/types" "7.16.1" - "@typescript-eslint/visitor-keys" "7.16.1" + "@typescript-eslint/types" "7.18.0" + "@typescript-eslint/visitor-keys" "7.18.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -14132,12 +14132,12 @@ "@typescript-eslint/types" "6.21.0" eslint-visitor-keys "^3.4.1" -"@typescript-eslint/visitor-keys@7.16.1": - version "7.16.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.1.tgz#4287bcf44c34df811ff3bb4d269be6cfc7d8c74b" - integrity sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg== +"@typescript-eslint/visitor-keys@7.18.0": + version "7.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz#0564629b6124d67607378d0f0332a0495b25e7d7" + integrity sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== dependencies: - "@typescript-eslint/types" "7.16.1" + "@typescript-eslint/types" "7.18.0" eslint-visitor-keys "^3.4.3" "@uiw/codemirror-extensions-basic-setup@4.22.0": @@ -25469,13 +25469,20 @@ minimatch@^7.4.2, minimatch@^7.4.3: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.4: +minimatch@^9.0.0, minimatch@^9.0.1: version "9.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimatch@~0.2.12: version "0.2.14" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a"