From 03a678d96deeb1d42448e94ac95d735e61393a40 Mon Sep 17 00:00:00 2001 From: Oleksandr Andriienko Date: Wed, 31 Jan 2024 17:51:47 +0200 Subject: [PATCH] fix(rbac): split policies and roles by source (#1042) * fix(rbac): split policies by source Signed-off-by: Oleksandr Andriienko * fix(rbac): imporove code Rename location to source Add grouping validation for policy file. Don't allow to modify roles and permission policy from permission policy file. Fix some bugs. Signed-off-by: Oleksandr Andriienko * fix(rbac): add transactions for role and policies metadata Signed-off-by: Oleksandr Andriienko * fix(rbac): cleanup policies which gone from csv policies file Signed-off-by: Oleksandr Andriienko * fix(rbac): handle preexisting policies by adding a legacy source * fix(rbac): add unit tests for role-metadata.ts Signed-off-by: Oleksandr Andriienko * fix(rbac): fix rest api unit tests Signed-off-by: Oleksandr Andriienko * fix(rbac): fmt code Signed-off-by: Oleksandr Andriienko * fix(rbac): fix failing tests Signed-off-by: Oleksandr Andriienko * fix(rbac): update knex-mock-client to align with knex 3.0 from main Signed-off-by: Oleksandr Andriienko * fix(rbac): avoid nested transactions for sqlite3 Signed-off-by: Oleksandr Andriienko * fix(rbac): handle code review feedback. Signed-off-by: Oleksandr Andriienko * fix(rbac): handle code review feedback Move predefined policies validation from permission-policy.ts file to policies-validation.ts. Add validation for admin users defined in the application config file. Signed-off-by: Oleksandr Andriienko * fix(rbac): improve clean up csv file policies and add tests around that Signed-off-by: Oleksandr Andriienko * fix(rbac): handle code review feedback about has method for Set Signed-off-by: Oleksandr Andriienko * fix(rbac): don't allow to modify configuration permission policies Signed-off-by: Oleksandr Andriienko * fix(rbac): add more unit tests Signed-off-by: Oleksandr Andriienko * fix(rbac): add the ability to update legacy policies * fix(rbac): fix validation mistake Signed-off-by: Oleksandr Andriienko * fix(rbac): complete fix bug #1103 Complete fix bug, when after removing admin from app configuration, admin still present. Signed-off-by: Oleksandr Andriienko * fix(rbac): fix compilation and tests after rebase Signed-off-by: Oleksandr Andriienko * fix(rbac): fix legacy migration for admin group policies Signed-off-by: Oleksandr Andriienko * fix(rbac): fix sonarcloud issues * fix(rbac): fix migration issue with multiple admins * fix(rbac): remove console log * fix(rbac): fix bug with hanging transaction on sqlite3 Signed-off-by: Oleksandr Andriienko * fix(rbac): fix tests after rebase Signed-off-by: Oleksandr Andriienko --------- Signed-off-by: Oleksandr Andriienko Co-authored-by: Patrick Knight --- plugins/rbac-backend/config.d.ts | 1 + .../migrations/20231212224526_migrations.js | 65 ++ .../migrations/20231221113214_migrations.js | 53 + plugins/rbac-backend/package.json | 5 +- .../src/database/conditional-storage.ts | 22 - .../rbac-backend/src/database/migration.ts | 19 + .../database/policy-metadata-storage.test.ts | 281 ++++++ .../src/database/policy-metadata-storage.ts | 120 +++ .../src/database/role-metadata.test.ts | 534 ++++++++++ .../src/database/role-metadata.ts | 153 +++ plugins/rbac-backend/src/helper.ts | 37 + .../src/service/enforcer-delegate.ts | 598 +++++++++++ .../src/service/permission-policy.test.ts | 951 +++++++++++++++++- .../src/service/permission-policy.ts | 222 ++-- .../src/service/policies-rest-api.test.ts | 315 +++--- .../src/service/policies-rest-api.ts | 182 ++-- .../src/service/policies-validation.ts | 81 +- .../src/service/policy-builder.ts | 27 +- .../src/service/test/data/rbac-policy.csv | 2 + plugins/rbac-common/src/types.ts | 16 + yarn.lock | 41 +- 21 files changed, 3407 insertions(+), 318 deletions(-) create mode 100644 plugins/rbac-backend/migrations/20231212224526_migrations.js create mode 100644 plugins/rbac-backend/migrations/20231221113214_migrations.js create mode 100644 plugins/rbac-backend/src/database/migration.ts create mode 100644 plugins/rbac-backend/src/database/policy-metadata-storage.test.ts create mode 100644 plugins/rbac-backend/src/database/policy-metadata-storage.ts create mode 100644 plugins/rbac-backend/src/database/role-metadata.test.ts create mode 100644 plugins/rbac-backend/src/database/role-metadata.ts create mode 100644 plugins/rbac-backend/src/helper.ts create mode 100644 plugins/rbac-backend/src/service/enforcer-delegate.ts diff --git a/plugins/rbac-backend/config.d.ts b/plugins/rbac-backend/config.d.ts index 2e99c6de99..490302fc42 100644 --- a/plugins/rbac-backend/config.d.ts +++ b/plugins/rbac-backend/config.d.ts @@ -1,6 +1,7 @@ export interface Config { permission: { rbac: { + 'policies-csv-file'?: string; /** * Optional configuration for admins, can declare individual users and / or groups * @visibility frontend diff --git a/plugins/rbac-backend/migrations/20231212224526_migrations.js b/plugins/rbac-backend/migrations/20231212224526_migrations.js new file mode 100644 index 0000000000..4f45c733d5 --- /dev/null +++ b/plugins/rbac-backend/migrations/20231212224526_migrations.js @@ -0,0 +1,65 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); + let policies = []; + let groupPolicies = []; + + if (casbinDoesExist) { + policies = await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'p') + .then(listPolicies => { + const allPolicies = []; + for (const policy of listPolicies) { + const { v0, v1, v2, v3 } = policy; + allPolicies.push(`[${v0}, ${v1}, ${v2}, ${v3}]`); + } + return allPolicies; + }); + groupPolicies = await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'g') + .then(listGroupPolicies => { + const allGroupPolicies = []; + for (const groupPolicy of listGroupPolicies) { + const { v0, v1 } = groupPolicy; + allGroupPolicies.push(`[${v0}, ${v1}]`); + } + return allGroupPolicies; + }); + } + + await knex.schema + .createTable('policy-metadata', table => { + table.increments('id').primary(); + table.string('policy').primary(); + table.string('source'); + }) + .then(async () => { + for (const policy of policies) { + await knex + .table('policy-metadata') + .insert({ source: 'legacy', policy: policy }); + } + }) + .then(async () => { + for (const groupPolicy of groupPolicies) { + await knex + .table('policy-metadata') + .insert({ source: 'legacy', policy: groupPolicy }); + } + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('policy-metadata'); +}; diff --git a/plugins/rbac-backend/migrations/20231221113214_migrations.js b/plugins/rbac-backend/migrations/20231221113214_migrations.js new file mode 100644 index 0000000000..30b4c31125 --- /dev/null +++ b/plugins/rbac-backend/migrations/20231221113214_migrations.js @@ -0,0 +1,53 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); + let groupPolicies = []; + + if (casbinDoesExist) { + groupPolicies = await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'g') + .then(listGroupPolicies => { + const allGroupPolicies = []; + let rbacFlag = false; + for (const groupPolicy of listGroupPolicies) { + const { v1 } = groupPolicy; + if (v1 === 'role:default/rbac_admin') { + rbacFlag = true; + continue; + } + allGroupPolicies.push(v1); + } + if (rbacFlag) { + allGroupPolicies.push('role:default/rbac_admin'); + } + return allGroupPolicies; + }); + } + + await knex.schema + .createTable('role-metadata', table => { + table.increments('id').primary(); + table.string('roleEntityRef').primary(); + table.string('source'); + }) + .then(async () => { + for (const groupPolicy of groupPolicies) { + await knex + .table('role-metadata') + .insert({ source: 'legacy', roleEntityRef: groupPolicy }); + } + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('role-metadata'); +}; diff --git a/plugins/rbac-backend/package.json b/plugins/rbac-backend/package.json index 0f24787966..d92e107af2 100644 --- a/plugins/rbac-backend/package.json +++ b/plugins/rbac-backend/package.json @@ -24,7 +24,8 @@ }, "dependencies": { "@backstage/backend-common": "^0.19.8", - "@backstage/backend-plugin-api": "^0.5.4", + "@backstage/backend-plugin-api": "^0.6.6", + "@backstage/backend-test-utils": "^0.2.7", "@backstage/catalog-client": "^1.4.5", "@backstage/catalog-model": "^1.4.3", "@backstage/config": "^1.1.1", @@ -51,7 +52,7 @@ "@types/express": "4.17.20", "@types/node": "18.18.5", "@types/supertest": "2.0.16", - "knex-mock-client": "2.0.0", + "knex-mock-client": "2.0.1", "msw": "1.3.2", "supertest": "6.3.3" }, diff --git a/plugins/rbac-backend/src/database/conditional-storage.ts b/plugins/rbac-backend/src/database/conditional-storage.ts index 962d30d53d..2a1a9c4af2 100644 --- a/plugins/rbac-backend/src/database/conditional-storage.ts +++ b/plugins/rbac-backend/src/database/conditional-storage.ts @@ -1,7 +1,3 @@ -import { - PluginDatabaseManager, - resolvePackagePath, -} from '@backstage/backend-common'; import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; import { AuthorizeResult, @@ -11,10 +7,6 @@ import { import { Knex } from 'knex'; const CONDITIONAL_TABLE = 'policy-conditions'; -const migrationsDir = resolvePackagePath( - '@janus-idp/backstage-plugin-rbac-backend', // Package name - 'migrations', // Migrations directory -); interface ConditionalPolicyDecisionDAO { result: AuthorizeResult.CONDITIONAL; @@ -46,20 +38,6 @@ export interface ConditionalStorage { export class DataBaseConditionalStorage implements ConditionalStorage { public constructor(private readonly knex: Knex) {} - static async create( - databaseManager: PluginDatabaseManager, - ): Promise { - const knex = await databaseManager.getClient(); - - if (!databaseManager.migrations?.skip) { - await knex.migrate.latest({ - directory: migrationsDir, - }); - } - - return new DataBaseConditionalStorage(knex); - } - async getConditions( pluginId: string, resourceType: string, diff --git a/plugins/rbac-backend/src/database/migration.ts b/plugins/rbac-backend/src/database/migration.ts new file mode 100644 index 0000000000..732538fdb6 --- /dev/null +++ b/plugins/rbac-backend/src/database/migration.ts @@ -0,0 +1,19 @@ +import { + PluginDatabaseManager, + resolvePackagePath, +} from '@backstage/backend-common'; + +const migrationsDir = resolvePackagePath( + '@janus-idp/backstage-plugin-rbac-backend', // Package name + 'migrations', // Migrations directory +); + +export async function migrate(databaseManager: PluginDatabaseManager) { + const knex = await databaseManager.getClient(); + + if (!databaseManager.migrations?.skip) { + await knex.migrate.latest({ + directory: migrationsDir, + }); + } +} diff --git a/plugins/rbac-backend/src/database/policy-metadata-storage.test.ts b/plugins/rbac-backend/src/database/policy-metadata-storage.test.ts new file mode 100644 index 0000000000..96ed5e6bb0 --- /dev/null +++ b/plugins/rbac-backend/src/database/policy-metadata-storage.test.ts @@ -0,0 +1,281 @@ +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; + +import * as Knex from 'knex'; +import { createTracker, MockClient } from 'knex-mock-client'; + +import { PermissionPolicyMetadata } from '@janus-idp/backstage-plugin-rbac-common'; + +import { policyToString } from '../helper'; +import { migrate } from './migration'; +import { + DataBasePolicyMetadataStorage, + PermissionPolicyMetadataDao, + POLICY_METADATA_TABLE, +} from './policy-metadata-storage'; + +describe('policy-metadata-db-table', () => { + const policy = ['role:default/team-a', 'catalog-entity', 'read', 'allow']; + const policyStr = policyToString(policy); + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], + }); + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const databaseManagerMock: PluginDatabaseManager = { + getClient: jest.fn(() => { + return Promise.resolve(knex); + }), + migrations: { skip: false }, + }; + await migrate(databaseManagerMock); + return { + knex, + db: new DataBasePolicyMetadataStorage(knex), + }; + } + + describe('findPolicyMetadataBySource', () => { + it.each(databases.eachSupportedId())( + 'should return found metadata by source', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(POLICY_METADATA_TABLE).insert({ + policy: policyStr, + source: 'rest', + }); + + const trx = await knex.transaction(); + let metadata: PermissionPolicyMetadata[]; + try { + metadata = await db.findPolicyMetadataBySource('rest', trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual({ + id: 1, + source: 'rest', + policy: policyStr, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return an empty array', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + const trx = await knex.transaction(); + let metadata: PermissionPolicyMetadata[]; + try { + metadata = await db.findPolicyMetadataBySource('rest', trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + expect(metadata.length).toEqual(0); + }, + ); + }); + + describe('findPolicyMetadata', () => { + it.each(databases.eachSupportedId())( + 'should return undefined', + async databasesId => { + const { db } = await createDatabase(databasesId); + + const metadata = await db.findPolicyMetadata(policy); + expect(metadata).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(POLICY_METADATA_TABLE).insert({ + policy: policyStr, + source: 'rest', + }); + + const metadata = await db.findPolicyMetadata(policy); + expect(metadata).not.toBeUndefined(); + expect(metadata).toEqual({ source: 'rest' }); + }, + ); + }); + + describe('createPolicyMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully create new policy metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const trx = await knex.transaction(); + let id; + try { + id = await db.createPolicyMetadata('rest', policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + const metadata = await knex( + POLICY_METADATA_TABLE, + ).where('id', id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual({ + id: 1, + policy: '[role:default/team-a, catalog-entity, read, allow]', + source: 'rest', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw conflict error', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(POLICY_METADATA_TABLE).insert({ + policy: policyStr, + source: 'rest', + }); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createPolicyMetadata('rest', policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `A metadata for policy '${policyStr}' has already been stored`, + ); + }, + ); + + it('should throw failed to create metadata error, because inserted result is undefined', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(POLICY_METADATA_TABLE).response(undefined); + tracker.on.insert(POLICY_METADATA_TABLE).response(undefined); + + const db = new DataBasePolicyMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createPolicyMetadata('rest', policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to create the policy metadata: '{"source":"rest","policy":"[role:default/team-a, catalog-entity, read, allow]"}'.`, + ); + }); + + it('should throw an error on insert metadata operaton', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(POLICY_METADATA_TABLE).response(undefined); + tracker.on + .insert(POLICY_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBasePolicyMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createPolicyMetadata('rest', policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); + + describe('removePolicyMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully delete metadata', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await knex(POLICY_METADATA_TABLE).insert({ + policy: policyStr, + source: 'rest', + }); + + const trx = await knex.transaction(); + try { + await db.removePolicyMetadata(policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + } + + const metadata = await knex( + POLICY_METADATA_TABLE, + ).where('id', 1); + expect(metadata.length).toEqual(0); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to delete metadata, because nothing to delete', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.removePolicyMetadata(policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `A metadata for policy '${policyStr}' was not found`, + ); + }, + ); + + it('should throw an error on delete metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(POLICY_METADATA_TABLE).response({ + policy: policyStr, + source: 'rest', + id: 1, + }); + tracker.on + .delete(POLICY_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBasePolicyMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.removePolicyMetadata(policy, trx); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); +}); diff --git a/plugins/rbac-backend/src/database/policy-metadata-storage.ts b/plugins/rbac-backend/src/database/policy-metadata-storage.ts new file mode 100644 index 0000000000..b1a73b0ae6 --- /dev/null +++ b/plugins/rbac-backend/src/database/policy-metadata-storage.ts @@ -0,0 +1,120 @@ +import { ConflictError, NotFoundError } from '@backstage/errors'; + +import { Knex } from 'knex'; + +import { + PermissionPolicyMetadata, + Source, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { policyToString } from '../helper'; + +export const POLICY_METADATA_TABLE = 'policy-metadata'; + +export interface PermissionPolicyMetadataDao extends PermissionPolicyMetadata { + id: number; + policy: string; +} + +export interface PolicyMetadataStorage { + findPolicyMetadataBySource( + source: string, + trx?: Knex.Transaction, + ): Promise; + findPolicyMetadata( + policy: string[], + trx?: Knex.Transaction, + ): Promise; + createPolicyMetadata( + source: Source, + policy: string[], + trx: Knex.Transaction, + ): Promise; + removePolicyMetadata(policy: string[], trx: Knex.Transaction): Promise; +} + +export class DataBasePolicyMetadataStorage implements PolicyMetadataStorage { + constructor(private readonly knex: Knex) {} + + async findPolicyMetadataBySource( + source: string, + trx: Knex.Transaction, + ): Promise { + const db = trx || this.knex; + return await db?.table(POLICY_METADATA_TABLE).where('source', source); + } + + async findPolicyMetadata( + policy: string[], + trx?: Knex.Transaction, + ): Promise { + const policyMetadataDao = await this.findPolicyMetadataDao(policy, trx); + if (policyMetadataDao) { + return this.daoToMetadata(policyMetadataDao); + } + return undefined; + } + + private async findPolicyMetadataDao( + policy: string[], + trx?: Knex.Transaction, + ): Promise { + const db = trx || this.knex; + return await db + ?.table(POLICY_METADATA_TABLE) + .where('policy', policyToString(policy)) + // policy should be unique. + .first(); + } + + async createPolicyMetadata( + source: Source, + policy: string[], + trx: Knex.Transaction, + ): Promise { + const stringPolicy = policyToString(policy); + if (await this.findPolicyMetadataDao(policy, trx)) { + throw new ConflictError( + `A metadata for policy '${stringPolicy}' has already been stored`, + ); + } + + const metadataDao = { source, policy: stringPolicy }; + const result = await trx + .table(POLICY_METADATA_TABLE) + .insert(metadataDao) + .returning('id'); + if (result && result?.length > 0) { + return result[0].id; + } + + throw new Error( + `Failed to create the policy metadata: '${JSON.stringify(metadataDao)}'.`, + ); + } + + async removePolicyMetadata( + policy: string[], + trx: Knex.Transaction, + ): Promise { + const metadataDao = await this.findPolicyMetadataDao(policy, trx); + if (!metadataDao) { + throw new NotFoundError( + `A metadata for policy '${policyToString(policy)}' was not found`, + ); + } + + await trx + .table(POLICY_METADATA_TABLE) + .delete() + .whereIn('id', [metadataDao.id]); + } + + private daoToMetadata( + dao: PermissionPolicyMetadataDao, + ): PermissionPolicyMetadata { + return { + source: dao.source, + }; + } +} diff --git a/plugins/rbac-backend/src/database/role-metadata.test.ts b/plugins/rbac-backend/src/database/role-metadata.test.ts new file mode 100644 index 0000000000..eac6a6247a --- /dev/null +++ b/plugins/rbac-backend/src/database/role-metadata.test.ts @@ -0,0 +1,534 @@ +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; + +import * as Knex from 'knex'; +import { createTracker, MockClient } from 'knex-mock-client'; + +import { migrate } from './migration'; +import { + DataBaseRoleMetadataStorage, + ROLE_METADATA_TABLE, + RoleMetadataDao, +} from './role-metadata'; + +describe('role-metadata-db-table', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], + }); + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const databaseManagerMock: PluginDatabaseManager = { + getClient: jest.fn(() => { + return Promise.resolve(knex); + }), + migrations: { skip: false }, + }; + await migrate(databaseManagerMock); + return { + knex, + db: new DataBaseRoleMetadataStorage(knex), + }; + } + + describe('findRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should return undefined', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const trx = await knex.transaction(); + try { + const roleMetadata = await db.findRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + expect(roleMetadata).toBeUndefined(); + } catch (err) { + await trx.rollback(); + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }); + + const trx = await knex.transaction(); + try { + const roleMetadata = await db.findRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + expect(roleMetadata).toEqual({ source: 'rest' }); + } catch (err) { + await trx.rollback(); + throw err; + } + }, + ); + }); + + describe('createRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully create new role metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + const trx = await knex.transaction(); + let id; + try { + id = await db.createRoleMetadata( + { source: 'configuration' }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + id, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual({ + roleEntityRef: 'role:default/some-super-important-role', + id: 1, + source: 'configuration', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw conflict error', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + }); + + const trx = await knex.transaction(); + await expect(async () => { + try { + await db.createRoleMetadata( + { source: 'configuration' }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role role:default/some-super-important-role has already been stored`, + ); + }, + ); + + it('should throw failed to create metadata error, because inserted result is an empty array.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on.insert(ROLE_METADATA_TABLE).response([]); + + const db = new DataBaseRoleMetadataStorage(knex); + const trx = await knex.transaction(); + + await expect( + db.createRoleMetadata( + { source: 'configuration' }, + 'role:default/some-super-important-role', + trx, + ), + ).rejects.toThrow( + `Failed to create the role metadata: '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration"}'.`, + ); + }); + + it('should throw failed to create metadata error, because inserted result is undefined.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on.insert(ROLE_METADATA_TABLE).response(undefined); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createRoleMetadata( + { source: 'configuration' }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to create the role metadata: '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration"}'.`, + ); + }); + + it('should throw an error on insert metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on + .insert(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createRoleMetadata( + { source: 'configuration' }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); + + describe('updateRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully update role metadata from legacy source to new value', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'legacy', + }); + + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual({ + source: 'rest', + roleEntityRef: 'role:default/some-super-important-role', + id: 1, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update role metadata source to new value, because source is not legacy', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow(`The RoleMetadata.source field is 'read-only'`); + }, + ); + + it.each(databases.eachSupportedId())( + 'should successfully update role metadata with the new name', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + }); + + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual({ + source: 'configuration', + roleEntityRef: 'role:default/important-role', + id: 1, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update role metadata, because role metadata was not found', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role 'role:default/some-super-important-role' was not found`, + ); + }, + ); + + it('should throw failed to update metadata error, because update result is an empty array.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on.update(ROLE_METADATA_TABLE).response([]); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration"}'.`, + ); + }); + + it('should throw failed to update metadata error, because update result is undefined.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on.update(ROLE_METADATA_TABLE).response(undefined); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration"}'.`, + ); + }); + + it('should throw on insert metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on + .update(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); + + describe('removeRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully delete role metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'legacy', + }); + + const trx = await knex.transaction(); + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(0); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to delete role metadata, because nothing to delete', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + const trx = await knex.transaction(); + + await expect(async () => { + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role 'role:default/some-super-important-role' was not found`, + ); + }, + ); + + it('should throw an error on delete metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on + .delete(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); +}); diff --git a/plugins/rbac-backend/src/database/role-metadata.ts b/plugins/rbac-backend/src/database/role-metadata.ts new file mode 100644 index 0000000000..d13bb46448 --- /dev/null +++ b/plugins/rbac-backend/src/database/role-metadata.ts @@ -0,0 +1,153 @@ +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; + +import { Knex } from 'knex'; + +import { RoleMetadata } from '@janus-idp/backstage-plugin-rbac-common'; + +export const ROLE_METADATA_TABLE = 'role-metadata'; + +export interface RoleMetadataDao extends RoleMetadata { + id?: number; + roleEntityRef: string; +} + +export interface RoleMetadataStorage { + findRoleMetadata( + roleEntityRef: string, + trx?: Knex.Transaction, + ): Promise; + createRoleMetadata( + roleMetadata: RoleMetadata, + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise; + updateRoleMetadata( + roleMetadata: RoleMetadataDao, + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise; + removeRoleMetadata( + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise; +} + +export class DataBaseRoleMetadataStorage implements RoleMetadataStorage { + constructor(private readonly knex: Knex) {} + + async findRoleMetadata( + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise { + const roleMetadataDao = await this.findRoleMetadataDao(roleEntityRef, trx); + if (roleMetadataDao) { + return this.daoToMetadata(roleMetadataDao); + } + return undefined; + } + + private async findRoleMetadataDao( + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise { + const db = trx || this.knex; + return await db + .table(ROLE_METADATA_TABLE) + .where('roleEntityRef', roleEntityRef) + // roleEntityRef should be unique. + .first(); + } + + async createRoleMetadata( + roleMetadata: RoleMetadata, + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise { + if (await this.findRoleMetadataDao(roleEntityRef, trx)) { + throw new ConflictError( + `A metadata for role ${roleEntityRef} has already been stored`, + ); + } + + const metadataDao = this.metadataToDao(roleMetadata, roleEntityRef); + const result = await trx(ROLE_METADATA_TABLE) + .insert(metadataDao) + .returning<[{ id: number }]>('id'); + if (result && result?.length > 0) { + return result[0].id; + } + + throw new Error( + `Failed to create the role metadata: '${JSON.stringify(metadataDao)}'.`, + ); + } + + async updateRoleMetadata( + newRoleMetadataDao: RoleMetadataDao, + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise { + const currentMetadataDao = await this.findRoleMetadataDao( + roleEntityRef, + trx, + ); + + if (!currentMetadataDao) { + throw new NotFoundError( + `A metadata for role '${roleEntityRef}' was not found`, + ); + } + + if ( + currentMetadataDao.source !== 'legacy' && + currentMetadataDao.source !== newRoleMetadataDao.source + ) { + throw new InputError(`The RoleMetadata.source field is 'read-only'.`); + } + + const result = await trx(ROLE_METADATA_TABLE) + .where('id', currentMetadataDao.id) + .update(newRoleMetadataDao) + .returning('id'); + + if (!result || result.length === 0) { + throw new Error( + `Failed to update the role metadata '${JSON.stringify( + currentMetadataDao, + )}' with new value: '${JSON.stringify(newRoleMetadataDao)}'.`, + ); + } + } + + async removeRoleMetadata( + roleEntityRef: string, + trx: Knex.Transaction, + ): Promise { + const metadataDao = await this.findRoleMetadataDao(roleEntityRef, trx); + if (!metadataDao) { + throw new NotFoundError( + `A metadata for role '${roleEntityRef}' was not found`, + ); + } + + await trx(ROLE_METADATA_TABLE) + .delete() + .whereIn('id', [metadataDao.id!]); + } + + private daoToMetadata(dao: RoleMetadataDao): RoleMetadata { + return { + source: dao.source, + }; + } + + private metadataToDao( + roleMetadata: RoleMetadata, + roleEntityRef: string, + ): RoleMetadataDao { + return { + roleEntityRef, + source: roleMetadata.source, + }; + } +} diff --git a/plugins/rbac-backend/src/helper.ts b/plugins/rbac-backend/src/helper.ts new file mode 100644 index 0000000000..d27ec52d2f --- /dev/null +++ b/plugins/rbac-backend/src/helper.ts @@ -0,0 +1,37 @@ +import { difference } from 'lodash'; + +import { Source } from '@janus-idp/backstage-plugin-rbac-common'; + +import { EnforcerDelegate } from './service/enforcer-delegate'; + +export function policyToString(policy: string[]): string { + return `[${policy.join(', ')}]`; +} + +export function policiesToString(policies: string[][]): string { + const policiesString = policies + .map(policy => policyToString(policy)) + .join(','); + return `[${policiesString}]`; +} + +export function metadataStringToPolicy(policy: string): string[] { + return policy.replace('[', '').replace(']', '').split(', '); +} + +export async function removeTheDifference( + originalGroup: string[], + addedGroup: string[], + source: Source, + roleName: string, + enf: EnforcerDelegate, +): Promise { + originalGroup.sort((a, b) => a.localeCompare(b)); + addedGroup.sort((a, b) => a.localeCompare(b)); + const missing = difference(originalGroup, addedGroup); + + for (const missingRole of missing) { + const role = [missingRole, roleName]; + await enf.removeGroupingPolicy(role, source, true); + } +} diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.ts b/plugins/rbac-backend/src/service/enforcer-delegate.ts new file mode 100644 index 0000000000..35a485473d --- /dev/null +++ b/plugins/rbac-backend/src/service/enforcer-delegate.ts @@ -0,0 +1,598 @@ +import { NotAllowedError, NotFoundError } from '@backstage/errors'; + +import { Enforcer } from 'casbin'; +import { Knex } from 'knex'; + +import { + PermissionPolicyMetadata, + Source, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { + PermissionPolicyMetadataDao, + PolicyMetadataStorage, +} from '../database/policy-metadata-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { policiesToString, policyToString } from '../helper'; + +export class EnforcerDelegate { + constructor( + private readonly enforcer: Enforcer, + private readonly policyMetadataStorage: PolicyMetadataStorage, + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly knex: Knex, + ) {} + + async hasPolicy(...policy: string[]): Promise { + return await this.enforcer.hasPolicy(...policy); + } + + async hasGroupingPolicy(...policy: string[]): Promise { + return await this.enforcer.hasGroupingPolicy(...policy); + } + + async getPolicy(): Promise { + return await this.enforcer.getPolicy(); + } + + async getGroupingPolicy(): Promise { + return await this.enforcer.getGroupingPolicy(); + } + + async getFilteredPolicy( + fieldIndex: number, + ...filter: string[] + ): Promise { + return await this.enforcer.getFilteredPolicy(fieldIndex, ...filter); + } + + async getFilteredGroupingPolicy( + fieldIndex: number, + ...filter: string[] + ): Promise { + return await this.enforcer.getFilteredGroupingPolicy(fieldIndex, ...filter); + } + + async addPolicy( + policy: string[], + source: Source, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + await this.policyMetadataStorage.createPolicyMetadata( + source, + policy, + trx, + ); + const ok = await this.enforcer.addPolicy(...policy); + if (!ok) { + throw new Error(`failed to create policy ${policyToString(policy)}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addPolicies( + policies: string[][], + source: Source, + externalTrx: Knex.Transaction, + ): Promise { + const trx = externalTrx || (await this.knex.transaction()); + try { + for (const policy of policies) { + await this.policyMetadataStorage.createPolicyMetadata( + source, + policy, + trx, + ); + } + const ok = await this.enforcer.addPolicies(policies); + if (!ok) { + throw new Error( + `Failed to store policies ${policiesToString(policies)}`, + ); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addGroupingPolicy( + policy: string[], + source: Source, + externalTrx?: Knex.Transaction, + isUpdate?: boolean, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + const entityRef = policy[1]; + let metadata; + + if (entityRef.startsWith(`role:`)) { + metadata = await this.roleMetadataStorage.findRoleMetadata( + entityRef, + trx, + ); + } + + try { + await this.policyMetadataStorage.createPolicyMetadata( + source, + policy, + trx, + ); + + if (!metadata && !isUpdate) { + await this.roleMetadataStorage.createRoleMetadata( + { source }, + entityRef, + trx, + ); + } + + const ok = await this.enforcer.addGroupingPolicy(...policy); + if (!ok) { + throw new Error(`failed to create policy ${policyToString(policy)}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addGroupingPolicies( + policies: string[][], + source: Source, + externalTrx?: Knex.Transaction, + isUpdate?: boolean, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + for (const policy of policies) { + const entityRef = policy[1]; + let metadata; + + if (entityRef.startsWith(`role:`)) { + metadata = await this.roleMetadataStorage.findRoleMetadata( + entityRef, + trx, + ); + } + + await this.policyMetadataStorage.createPolicyMetadata( + source, + policy, + trx, + ); + + if (!metadata && !isUpdate) { + await this.roleMetadataStorage.createRoleMetadata( + { source }, + entityRef, + trx, + ); + } + } + const ok = await this.enforcer.addGroupingPolicies(policies); + if (!ok) { + throw new Error( + `Failed to store policies ${policiesToString(policies)}`, + ); + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async updateGroupingPolicies( + oldRole: string[][], + newRole: string[][], + source: Source, + allowToDeleteCSVFilePolicy?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + const newRoleName = newRole.at(0)?.at(1)!; + const oldRoleName = oldRole.at(0)?.at(1)!; + try { + await this.roleMetadataStorage.updateRoleMetadata( + { source: source, roleEntityRef: newRoleName }, + oldRoleName, + trx, + ); + await this.removeGroupingPolicies( + oldRole, + source, + allowToDeleteCSVFilePolicy, + true, + trx, + ); + await this.addGroupingPolicies(newRole, source, trx, true); + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async updatePolicies( + oldPolicies: string[][], + newPolicies: string[][], + source: Source, + allowToDeleteCSVFilePolicy?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + await this.removePolicies( + oldPolicies, + source, + allowToDeleteCSVFilePolicy, + trx, + ); + await this.addPolicies(newPolicies, source, trx); + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async removePolicy( + policy: string[], + source: Source, + allowToDeleteCSVFilePolicy?: boolean, + externalTrx?: Knex.Transaction, + ) { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + await this.checkIfPolicyModifiable( + policy, + source, + trx, + allowToDeleteCSVFilePolicy, + ); + await this.policyMetadataStorage.removePolicyMetadata(policy, trx); + const ok = await this.enforcer.removePolicy(...policy); + if (!ok) { + throw new Error(`fail to delete policy ${policy}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async removePolicies( + policies: string[][], + source: Source, + allowToDeleCSVFilePolicy?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + for (const policy of policies) { + await this.checkIfPolicyModifiable( + policy, + source, + trx, + allowToDeleCSVFilePolicy, + ); + await this.policyMetadataStorage.removePolicyMetadata(policy, trx); + } + const ok = await this.enforcer.removePolicies(policies); + if (!ok) { + throw new Error( + `Failed to delete policies ${policiesToString(policies)}`, + ); + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async removeGroupingPolicy( + policy: string[], + source: Source, + isUpdate?: boolean, + allowToDeleCSVFilePolicy?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + const roleEntity = policy[1]; + + try { + await this.checkIfPolicyModifiable( + policy, + source, + trx, + allowToDeleCSVFilePolicy, + ); + await this.policyMetadataStorage.removePolicyMetadata(policy, trx); + if (!isUpdate) { + await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); + } + const ok = await this.enforcer.removeGroupingPolicy(...policy); + if (!ok) { + throw new Error(`Failed to delete policy ${policyToString(policy)}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async removeGroupingPolicies( + policies: string[][], + source: Source, + allowToDeleteCSVFilePolicy?: boolean, + isUpdate?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + try { + for (const policy of policies) { + const roleEntity = policy[1]; + await this.checkIfPolicyModifiable( + policy, + source, + trx, + allowToDeleteCSVFilePolicy, + ); + if (!isUpdate) { + await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); + } + await this.policyMetadataStorage.removePolicyMetadata(policy, trx); + } + + const ok = await this.enforcer.removeGroupingPolicies(policies); + if (!ok) { + throw new Error( + `Failed to delete grouping policies: ${policiesToString(policies)}`, + ); + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addOrUpdatePolicy( + policy: string[], + source: Source, + isCSV: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + try { + if (!(await this.enforcer.hasPolicy(...policy))) { + await this.addPolicy(policy, source, trx); + } else if (await this.hasFilteredPolicyMetadata(policy, 'legacy', trx)) { + await this.removePolicy(policy, source, isCSV, trx); + await this.addPolicy(policy, source, trx); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addOrUpdateGroupingPolicy( + groupPolicy: string[], + source: Source, + isCSV?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + try { + if (!(await this.hasGroupingPolicy(...groupPolicy))) { + await this.addGroupingPolicy(groupPolicy, source, trx); + } else if ( + await this.hasFilteredPolicyMetadata(groupPolicy, 'legacy', trx) + ) { + await this.roleMetadataStorage.updateRoleMetadata( + { source: source, roleEntityRef: groupPolicy.at(1)! }, + groupPolicy.at(1)!, + trx, + ); + await this.removeGroupingPolicy(groupPolicy, source, true, isCSV, trx); + await this.addGroupingPolicy(groupPolicy, source, trx, true); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addOrUpdateGroupingPolicies( + groupPolicies: string[][], + source: Source, + isCSV?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + try { + for (const groupPolicy of groupPolicies) { + if (!(await this.hasGroupingPolicy(...groupPolicy))) { + await this.addGroupingPolicy(groupPolicy, source, trx); + } else if ( + await this.hasFilteredPolicyMetadata(groupPolicy, 'legacy', trx) + ) { + await this.roleMetadataStorage.updateRoleMetadata( + { source: source, roleEntityRef: groupPolicy.at(1)! }, + groupPolicy.at(1)!, + trx, + ); + await this.removeGroupingPolicy( + groupPolicy, + source, + true, + isCSV, + trx, + ); + await this.addGroupingPolicy(groupPolicy, source, trx, true); + } + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async enforce( + entityRef: string, + resourceType: string, + action: string, + ): Promise { + return await this.enforcer.enforce(entityRef, resourceType, action); + } + + async getMetadata(policy: string[]): Promise { + const metadata = + await this.policyMetadataStorage.findPolicyMetadata(policy); + if (!metadata) { + throw new NotFoundError(`A metadata for policy ${policy} was not found`); + } + return metadata; + } + + private async checkIfPolicyModifiable( + policy: string[], + policySource: Source, + trx: Knex.Transaction, + allowToModifyCSVFilePolicy?: boolean, + ) { + const metadata = await this.policyMetadataStorage.findPolicyMetadata( + policy, + trx, + ); + if (!metadata) { + throw new NotFoundError( + `A metadata for policy '${policyToString(policy)}' was not found`, + ); + } + if (metadata.source === 'csv-file' && !allowToModifyCSVFilePolicy) { + throw new NotAllowedError( + `policy '${policyToString( + policy, + )}' can be modified or deleted only with help of 'policies-csv-file'`, + ); + } + if ( + metadata?.source === 'configuration' && + policySource !== 'configuration' + ) { + throw new Error( + `Error: Attempted to modify an immutable pre-defined policy '${policyToString( + policy, + )}'. + This policy cannot be altered directly. If you need to make changes, consider removing the associated RBAC admin '${ + policy[0] + }' using the application configuration.`, + ); + } + } + + async getFilteredPolicyMetadata( + source: Source, + ): Promise { + return await this.policyMetadataStorage.findPolicyMetadataBySource(source); + } + + async hasFilteredPolicyMetadata( + policy: string[], + source: Source, + externalTrx?: Knex.Transaction, + ): Promise { + const metadata = await this.policyMetadataStorage.findPolicyMetadata( + policy, + externalTrx, + ); + + if (metadata?.source === source) { + return true; + } + + return false; + } + + async getImplicitPermissionsForUser(user: string): Promise { + return this.enforcer.getImplicitPermissionsForUser(user); + } +} diff --git a/plugins/rbac-backend/src/service/permission-policy.test.ts b/plugins/rbac-backend/src/service/permission-policy.test.ts index fca0d92194..33109f2468 100644 --- a/plugins/rbac-backend/src/service/permission-policy.test.ts +++ b/plugins/rbac-backend/src/service/permission-policy.test.ts @@ -1,4 +1,5 @@ import { getVoidLogger, TokenManager } from '@backstage/backend-common'; +import { DatabaseService } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; @@ -11,15 +12,31 @@ import { PolicyQuery } from '@backstage/plugin-permission-node'; import { Adapter, Enforcer, + FileAdapter, Model, newEnforcer, newModelFromString, StringAdapter, } from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; import { Logger } from 'winston'; +import { + PermissionPolicyMetadata, + RoleMetadata, + Source, +} from '@janus-idp/backstage-plugin-rbac-common'; + import { resolve } from 'path'; +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { + PermissionPolicyMetadataDao, + PolicyMetadataStorage, +} from '../database/policy-metadata-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { EnforcerDelegate } from './enforcer-delegate'; import { MODEL } from './permission-model'; import { RBACPermissionPolicy } from './permission-policy'; import { BackstageRoleManager } from './role-manager'; @@ -58,6 +75,35 @@ const conditionalStorage = { updateCondition: jest.fn().mockImplementation(), }; +const roleMetadataStorageMock: RoleMetadataStorage = { + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'csv-file' }; + }, + ), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), +}; + +const policyMetadataStorageMock: PolicyMetadataStorage = { + findPolicyMetadataBySource: jest + .fn() + .mockImplementation( + async (_source: Source): Promise => { + return []; + }, + ), + findPolicyMetadata: jest.fn().mockImplementation(), + createPolicyMetadata: jest.fn().mockImplementation(), + removePolicyMetadata: jest.fn().mockImplementation(), +}; + async function createEnforcer( theModel: Model, adapter: Adapter, @@ -88,12 +134,21 @@ describe('RBACPermissionPolicy Tests', () => { logger, tokenManagerMock, ); + const knex = Knex.knex({ client: MockClient }); + const enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageMock, + roleMetadataStorageMock, + knex, + ); const policy = await RBACPermissionPolicy.build( logger, config, conditionalStorage, - enf, + enfDelegate, + roleMetadataStorageMock, + knex, ); expect(policy).not.toBeNull(); @@ -103,12 +158,8 @@ describe('RBACPermissionPolicy Tests', () => { let policy: RBACPermissionPolicy; beforeEach(async () => { - const adapter = new StringAdapter( - ` - p, user:default/known_user, test.resource.deny, use, allow - `, - ); const csvPermFile = resolve(__dirname, './test/data/rbac-policy.csv'); + const adapter = new FileAdapter(csvPermFile); const config = new ConfigReader({ permission: { rbac: { @@ -125,11 +176,22 @@ describe('RBACPermissionPolicy Tests', () => { tokenManagerMock, ); + const knex = Knex.knex({ client: MockClient }); + // policyMetadataStorageMock.findPolicyMetadataBySource + const enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageMock, + roleMetadataStorageMock, + knex, + ); + policy = await RBACPermissionPolicy.build( logger, config, conditionalStorage, - enf, + enfDelegate, + roleMetadataStorageMock, + knex, ); catalogApi.getEntities.mockReturnValue({ items: [] }); @@ -193,6 +255,640 @@ describe('RBACPermissionPolicy Tests', () => { }); }); + describe('Policy checks for clean up old policies for csv file', () => { + const logger = getVoidLogger(); + + const dbManagerMock: DatabaseService = { + getClient: jest.fn().mockImplementation(), + }; + + const csvPermFile = resolve(__dirname, './test/data/rbac-policy.csv'); + const config = new ConfigReader({ + permission: { + rbac: { + 'policies-csv-file': csvPermFile, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }); + const configWithoutPolicyFile = new ConfigReader({ + permission: { + rbac: {}, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }); + + const policyMetadataStorage: PolicyMetadataStorage = { + findPolicyMetadataBySource: jest.fn().mockImplementation(), + findPolicyMetadata: jest + .fn() + .mockImplementation(async (): Promise => { + return Promise.resolve({ source: 'csv-file' }); + }), + createPolicyMetadata: jest.fn().mockImplementation(), + removePolicyMetadata: jest.fn().mockImplementation(), + }; + + beforeEach(() => { + (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); + (policyMetadataStorage.removePolicyMetadata as jest.Mock).mockReset(); + }); + + async function createEnforcerWithStoredPolicies( + storedPolicies: string[][], + storedGroupPolicies: string[][], + ): Promise { + const sqliteInMemoryAdapter = await new CasbinDBAdapterFactory( + config, + dbManagerMock, + ).createAdapter(); + const enf = await createEnforcer( + newModelFromString(MODEL), + sqliteInMemoryAdapter, + logger, + tokenManagerMock, + ); + await enf.addGroupingPolicies(storedGroupPolicies); + await enf.addPolicies(storedPolicies); + + return enf; + } + + async function createRBACPolicy( + enf: Enforcer, + attachPolicyFile: boolean = true, + ): Promise { + const conf = attachPolicyFile ? config : configWithoutPolicyFile; + const knex = Knex.knex({ client: MockClient }); + const enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorage, + roleMetadataStorageMock, + knex, + ); + + catalogApi.getEntities.mockReturnValue({ items: [] }); + + return await RBACPermissionPolicy.build( + logger, + conf, + conditionalStorage, + enfDelegate, + roleMetadataStorageMock, + knex, + ); + } + + it('should cleanup old group policies and metadata after re-attach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: '[user:default/user-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 1, + policy: '[group:default/team-a-old-1, role:default/old-role]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', // stored role + 'role:default/catalog-writer', // role from csv file + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], // stored group policy + ['user:default/guest', 'role:default/catalog-writer'], // group policy from csv file + ]); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', // stored role + 'role:default/catalog-writer', // role from csv file + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + // policies from csv file + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 2, + ); + + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-1', 'role:default/old-role'], + expect.anything(), + ); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old policies and metadata after re-attach policy file', async () => { + const storedGroupPolicies = [ + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: + '[role:default/old-role, test.some.resource, use, allow]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', // stored role + 'role:default/catalog-writer', // role from csv file + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], // stored group policy + ['user:default/guest', 'role:default/catalog-writer'], // group policy from csv file + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + // policies from csv file + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 1, + ); + + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + expect.anything(), + ); + + // role metadata should not be removed + expect( + roleMetadataStorageMock.removeRoleMetadata, + ).not.toHaveBeenCalledWith('role:default/old-role', expect.anything()); + }); + + it('should cleanup old policies and group policies and metadata after re-attach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['user:default/user-old-2', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + ['group:default/team-a-old-2', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: '[user:default/user-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 1, + policy: '[user:default/user-old-2, role:default/old-role]', + source: 'csv-file', + }, + { + id: 2, + policy: '[group:default/team-a-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 3, + policy: '[group:default/team-a-old-2, role:default/old-role]', + source: 'csv-file', + }, + { + id: 4, + policy: + '[role:default/old-role, test.some.resource, use, allow]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', // stored role + 'role:default/catalog-writer', // role from csv file + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], // stored group policy + ['user:default/guest', 'role:default/catalog-writer'], // group policy from csv file + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + // policies from csv file + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 5, + ); + + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-2', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-2', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + expect.anything(), + ); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old group policies and metadata after detach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: '[user:default/user-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 1, + policy: '[group:default/team-a-old-1, role:default/old-role]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', + 'role:default/catalog-writer', // stored role + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], + ['user:default/guest', 'role:default/catalog-writer'], // stored group policy + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 2, + ); + + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-1', 'role:default/old-role'], + expect.anything(), + ); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old policies after detach policy file', async () => { + const storedGroupPolicies = [ + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: + '[role:default/old-role, test.some.resource, use, allow]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', + 'role:default/catalog-writer', // stored role + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], + ['user:default/guest', 'role:default/catalog-writer'], // stored group policy + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 1, + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + expect.anything(), + ); + }); + + it('should cleanup old policies and group policies and metadata after detach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['user:default/user-old-2', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + ['group:default/team-a-old-2', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + policyMetadataStorage.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'csv-file') { + return [ + { + id: 0, + policy: '[user:default/user-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 1, + policy: '[user:default/user-old-2, role:default/old-role]', + source: 'csv-file', + }, + { + id: 2, + policy: '[group:default/team-a-old-1, role:default/old-role]', + source: 'csv-file', + }, + { + id: 3, + policy: '[group:default/team-a-old-2, role:default/old-role]', + source: 'csv-file', + }, + { + id: 4, + policy: + '[role:default/old-role, test.some.resource, use, allow]', + source: 'csv-file', + }, + ]; + } + return []; + }, + ); + + const enf = await createEnforcerWithStoredPolicies( + storedPolicies, + storedGroupPolicies, + ); + await createRBACPolicy(enf); + + expect(await enf.getAllRoles()).toEqual([ + 'role:default/some-role', + 'role:default/catalog-writer', // stored role + ]); + + expect(await enf.getGroupingPolicy()).toEqual([ + ['user:default/tester', 'role:default/some-role'], + ['user:default/guest', 'role:default/catalog-writer'], // stored group policy + ]); + + expect(await enf.getPolicy()).toEqual([ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['user:default/guest', 'catalog-entity', 'read', 'allow'], + ['user:default/guest', 'catalog.entity.create', 'use', 'allow'], + ['user:default/known_user', 'test.resource.deny', 'use', 'allow'], + ]); + + // policy metadata should to be removed + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledTimes( + 5, + ); + + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['user:default/user-old-2', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-1', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['group:default/team-a-old-2', 'role:default/old-role'], + expect.anything(), + ); + expect(policyMetadataStorage.removePolicyMetadata).toHaveBeenCalledWith( + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + expect.anything(), + ); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + }); describe('Policy checks for users', () => { let policy: RBACPermissionPolicy; @@ -230,11 +926,21 @@ describe('RBACPermissionPolicy Tests', () => { tokenManagerMock, ); + const knex = Knex.knex({ client: MockClient }); + const enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageMock, + roleMetadataStorageMock, + knex, + ); + policy = await RBACPermissionPolicy.build( logger, config, conditionalStorage, - enf, + enfDelegate, + roleMetadataStorageMock, + knex, ); catalogApi.getEntities.mockReturnValue({ items: [] }); @@ -392,10 +1098,215 @@ describe('RBACPermissionPolicy Tests', () => { } }); }); + + describe('Policy checks from config file', () => { + let policy: RBACPermissionPolicy; + let enfDelegate: EnforcerDelegate; + let enf: Enforcer; + const roleMetadataStorageTest: RoleMetadataStorage = { + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'legacy' }; + }, + ), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + }; + + const policyMetadataStorageTest: PolicyMetadataStorage = { + findPolicyMetadataBySource: jest + .fn() + .mockImplementation( + async (_source: Source): Promise => { + return []; + }, + ), + findPolicyMetadata: jest.fn().mockImplementation(), + createPolicyMetadata: jest.fn().mockImplementation(), + removePolicyMetadata: jest.fn().mockImplementation(), + }; + + const adminRole = 'role:default/rbac_admin'; + const groupPolicy = [ + ['user:default/test_admin', 'role:default/rbac_admin'], + ]; + const permissions = [ + ['role:default/rbac_admin', 'policy-entity', 'read', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'create', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'delete', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'update', 'allow'], + ['role:default/rbac_admin', 'catalog-entity', 'read', 'allow'], + ]; + const oldGroupPolicy = [ + 'user:default/old_admin', + 'role:default/rbac_admin', + ]; + + const adapter = new StringAdapter( + `p, user:default/known_user, test-resource, update, allow`, + ); + const admins = new Array<{ name: string }>(); + admins.push({ name: 'user:default/test_admin' }); + const config = new ConfigReader({ + permission: { + rbac: { + admin: { + users: admins, + }, + }, + }, + }); + const theModel = newModelFromString(MODEL); + const logger = getVoidLogger(); + + const knex = Knex.knex({ client: MockClient }); + catalogApi.getEntities.mockReturnValue({ items: [] }); + + beforeEach(async () => { + policyMetadataStorageTest.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'configuration') { + return [ + { + id: 0, + policy: '[user:default/old_admin, role:default/rbac_admin]', + source: 'configuration', + }, + { + id: 1, + policy: '[user:default/test_admin, role:default/rbac_admin]', + source: 'configuration', + }, + ]; + } + return []; + }, + ); + + policyMetadataStorageTest.findPolicyMetadata = jest + .fn() + .mockImplementation( + async ( + _policy: string[], + _trx: Knex.Knex.Transaction, + ): Promise => { + const test: PermissionPolicyMetadata = { + source: 'configuration', + }; + return test; + }, + ); + + enf = await createEnforcer(theModel, adapter, logger, tokenManagerMock); + + enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageTest, + roleMetadataStorageTest, + knex, + ); + + await enfDelegate.addGroupingPolicy(oldGroupPolicy, 'configuration'); + + policy = await RBACPermissionPolicy.build( + logger, + config, + conditionalStorage, + enfDelegate, + roleMetadataStorageTest, + knex, + ); + }); + + it('should build the admin permissions', async () => { + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + expect(enfRole).toEqual(groupPolicy); + expect(enfPermission).toEqual(permissions); + }); + + it('should build and update a legacy admin permission', async () => { + roleMetadataStorageTest.findRoleMetadata = jest + .fn() + .mockImplementationOnce( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'legacy' }; + }, + ); + + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + + expect(enfRole).toEqual(groupPolicy); + expect(enfPermission).toEqual(permissions); + expect(roleMetadataStorageTest.removeRoleMetadata).toHaveBeenCalled(); + expect(roleMetadataStorageTest.createRoleMetadata).toHaveBeenCalled(); + }); + + it('should allow read access to resource permission for user from config file', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'policy.entity.read', + 'policy-entity', + 'read', + ), + newIdentityResponse('user:default/test_admin'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + }); + + it('should remove users that are no longer in the config file', async () => { + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + expect(enfRole).toEqual(groupPolicy); + expect(enfRole).not.toContain(oldGroupPolicy); + expect(enfPermission).toEqual(permissions); + }); + }); }); // Notice: There is corner case, when "resourced" permission policy can be defined not by resource type, but by name. describe('Policy checks for resourced permissions defined by name', () => { + const roleMetadataStorageTest: RoleMetadataStorage = { + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'rest' }; + }, + ), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + }; + const policyMetadataStorageTest: PolicyMetadataStorage = { + findPolicyMetadataBySource: jest + .fn() + .mockImplementation( + async (_source: Source): Promise => { + return []; + }, + ), + findPolicyMetadata: jest.fn().mockImplementation(), + createPolicyMetadata: jest.fn().mockImplementation(), + removePolicyMetadata: jest.fn().mockImplementation(), + }; + let enfDelegate: EnforcerDelegate; + async function createRBACPolicy( policyContent: string, ): Promise { @@ -410,11 +1321,21 @@ describe('Policy checks for resourced permissions defined by name', () => { tokenManagerMock, ); + const knex = Knex.knex({ client: MockClient }); + enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageTest, + roleMetadataStorageTest, + knex, + ); + return await RBACPermissionPolicy.build( logger, config, conditionalStorage, - enf, + enfDelegate, + roleMetadataStorageMock, + knex, ); } it('should allow access to resourced permission assigned by name', async () => { @@ -649,11 +1570,21 @@ describe('Policy checks for users and groups', () => { tokenManagerMock, ); + const knex = Knex.knex({ client: MockClient }); + const enfDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageMock, + roleMetadataStorageMock, + knex, + ); + policy = await RBACPermissionPolicy.build( logger, config, conditionalStorage, - enf, + enfDelegate, + roleMetadataStorageMock, + knex, ); catalogApi.getEntities.mockReset(); diff --git a/plugins/rbac-backend/src/service/permission-policy.ts b/plugins/rbac-backend/src/service/permission-policy.ts index 24d3997416..329471ffe1 100644 --- a/plugins/rbac-backend/src/service/permission-policy.ts +++ b/plugins/rbac-backend/src/service/permission-policy.ts @@ -12,34 +12,97 @@ import { } from '@backstage/plugin-permission-node'; import { Enforcer, FileAdapter, newEnforcer, newModelFromString } from 'casbin'; +import { Knex } from 'knex'; import { Logger } from 'winston'; import { ConditionalStorage } from '../database/conditional-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { metadataStringToPolicy, removeTheDifference } from '../helper'; +import { EnforcerDelegate } from './enforcer-delegate'; import { MODEL } from './permission-model'; -import { validateEntityReference } from './policies-validation'; - -const useAdmins = async (admins: Config[], enf: Enforcer) => { - const adminRoleName = 'role:default/rbac_admin'; - admins.flatMap(async localConfig => { - const name = localConfig.getString('name'); - const adminRole = [name, adminRoleName]; - if (!(await enf.hasGroupingPolicy(...adminRole))) { - await enf.addGroupingPolicy(...adminRole); +import { + validateAllPredefinedPolicies, + validateEntityReference, +} from './policies-validation'; + +const adminRoleName = 'role:default/rbac_admin'; + +const useAdmins = async ( + admins: Config[], + enf: EnforcerDelegate, + roleMetadataStorage: RoleMetadataStorage, + knex: Knex, +) => { + const groupPoliciesToCompare: string[] = []; + const addedGroupPolicies = new Map(); + + for (const admin of admins) { + const entityRef = admin.getString('name'); + validateEntityReference(entityRef); + + addedGroupPolicies.set(entityRef, adminRoleName); + } + + const adminRoleMeta = + await roleMetadataStorage.findRoleMetadata(adminRoleName); + + const trx = await knex.transaction(); + try { + if (!adminRoleMeta) { + await roleMetadataStorage.createRoleMetadata( + { source: 'configuration' }, + adminRoleName, + trx, + ); + } else if (adminRoleMeta.source === 'legacy') { + await roleMetadataStorage.removeRoleMetadata(adminRoleName, trx); + await roleMetadataStorage.createRoleMetadata( + { source: 'configuration' }, + adminRoleName, + trx, + ); + } + + await trx.commit(); + } catch (error) { + await trx.rollback(error); + throw error; + } + + await enf.addOrUpdateGroupingPolicies( + Array.from(addedGroupPolicies.entries()), + 'configuration', + false, + ); + + const configPoliciesMetadata = + await enf.getFilteredPolicyMetadata('configuration'); + + for (const policyMetadata of configPoliciesMetadata) { + if (metadataStringToPolicy(policyMetadata.policy).length === 2) { + const stringPolicy = metadataStringToPolicy(policyMetadata.policy); + groupPoliciesToCompare.push(stringPolicy.at(0)!); } - }); - const adminReadPermission = [adminRoleName, 'policy-entity', 'read', 'allow']; - if (!(await enf.hasPolicy(...adminReadPermission))) { - await enf.addPolicy(...adminReadPermission); } + + await removeTheDifference( + groupPoliciesToCompare, + Array.from(addedGroupPolicies.keys()), + 'configuration', + adminRoleName, + enf, + ); + + const adminReadPermission = [adminRoleName, 'policy-entity', 'read', 'allow']; + await enf.addOrUpdatePolicy(adminReadPermission, 'configuration', false); + const adminCreatePermission = [ adminRoleName, 'policy-entity', 'create', 'allow', ]; - if (!(await enf.hasPolicy(...adminCreatePermission))) { - await enf.addPolicy(...adminCreatePermission); - } + await enf.addOrUpdatePolicy(adminCreatePermission, 'configuration', false); const adminDeletePermission = [ adminRoleName, @@ -47,9 +110,7 @@ const useAdmins = async (admins: Config[], enf: Enforcer) => { 'delete', 'allow', ]; - if (!(await enf.hasPolicy(...adminDeletePermission))) { - await enf.addPolicy(...adminDeletePermission); - } + await enf.addOrUpdatePolicy(adminDeletePermission, 'configuration', false); const adminUpdatePermission = [ adminRoleName, @@ -57,9 +118,7 @@ const useAdmins = async (admins: Config[], enf: Enforcer) => { 'update', 'allow', ]; - if (!(await enf.hasPolicy(...adminUpdatePermission))) { - await enf.addPolicy(...adminUpdatePermission); - } + await enf.addOrUpdatePolicy(adminUpdatePermission, 'configuration', false); // needed for rbac frontend. const adminCatalogReadPermission = [ @@ -68,54 +127,81 @@ const useAdmins = async (admins: Config[], enf: Enforcer) => { 'read', 'allow', ]; - if (!(await enf.hasPolicy(...adminCatalogReadPermission))) { - await enf.addPolicy(...adminCatalogReadPermission); + await enf.addOrUpdatePolicy( + adminCatalogReadPermission, + 'configuration', + false, + ); +}; + +const removedOldPermissionPoliciesFileData = async ( + enf: EnforcerDelegate, + fileEnf?: Enforcer, +) => { + const tempEnforcer = + fileEnf ?? (await newEnforcer(newModelFromString(MODEL))); + const oldFilePolicies = new Set(); + const policiesMetadata = await enf.getFilteredPolicyMetadata('csv-file'); + for (const policyMetadata of policiesMetadata) { + oldFilePolicies.add(metadataStringToPolicy(policyMetadata.policy)); + } + + const policiesToDelete: string[][] = []; + const groupPoliciesToDelete: string[][] = []; + for (const oldFilePolicy of oldFilePolicies) { + if ( + oldFilePolicy.length === 2 && + !(await tempEnforcer.hasGroupingPolicy(...oldFilePolicy)) + ) { + groupPoliciesToDelete.push(oldFilePolicy); + } else if ( + oldFilePolicy.length > 2 && + !(await tempEnforcer.hasPolicy(...oldFilePolicy)) + ) { + policiesToDelete.push(oldFilePolicy); + } + } + + if (groupPoliciesToDelete.length > 0) { + await enf.removeGroupingPolicies(groupPoliciesToDelete, 'csv-file', true); + } + if (policiesToDelete.length > 0) { + await enf.removePolicies(policiesToDelete, 'csv-file', true); } }; -const addPredefinedPoliciesAndGroupPolicies = async ( +const addPermissionPoliciesFileData = async ( preDefinedPoliciesFile: string, - enf: Enforcer, + enf: EnforcerDelegate, + roleMetadataStorage: RoleMetadataStorage, ) => { const fileEnf = await newEnforcer( newModelFromString(MODEL), new FileAdapter(preDefinedPoliciesFile), ); const policies = await fileEnf.getPolicy(); - for (const policy of policies) { - const err = validateEntityReference(policy[0]); - if (err) { - throw new Error( - `Failed to validate policy from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, - ); - } + const groupPolicies = await fileEnf.getGroupingPolicy(); - if (!(await enf.hasPolicy(...policy))) { - await enf.addPolicy(...policy); - } + await validateAllPredefinedPolicies( + Array.from(policies), + Array.from(groupPolicies), + preDefinedPoliciesFile, + roleMetadataStorage, + ); + + await removedOldPermissionPoliciesFileData(enf, fileEnf); + + for (const policy of policies) { + await enf.addOrUpdatePolicy(policy, 'csv-file', true); } - const groupPolicies = await fileEnf.getGroupingPolicy(); + for (const groupPolicy of groupPolicies) { - let err = validateEntityReference(groupPolicy[0]); - if (err) { - throw new Error( - `Failed to validate group policy from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, - ); - } - err = validateEntityReference(groupPolicy[1], true); - if (err) { - throw new Error( - `Failed to validate group policy from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, - ); - } - if (!(await enf.hasGroupingPolicy(...groupPolicy))) { - await enf.addGroupingPolicy(...groupPolicy); - } + await enf.addOrUpdateGroupingPolicy(groupPolicy, 'csv-file', false); } }; export class RBACPermissionPolicy implements PermissionPolicy { - private readonly enforcer: Enforcer; + private readonly enforcer: EnforcerDelegate; private readonly logger: Logger; private readonly conditionStorage: ConditionalStorage; @@ -123,7 +209,9 @@ export class RBACPermissionPolicy implements PermissionPolicy { logger: Logger, configApi: ConfigApi, conditionalStorage: ConditionalStorage, - enf: Enforcer, + enforcerDelegate: EnforcerDelegate, + roleMetadataStorage: RoleMetadataStorage, + knex: Knex, ): Promise { const adminUsers = configApi.getOptionalConfigArray( 'permission.rbac.admin.users', @@ -133,19 +221,33 @@ export class RBACPermissionPolicy implements PermissionPolicy { 'permission.rbac.policies-csv-file', ); - if (policiesFile) { - await addPredefinedPoliciesAndGroupPolicies(policiesFile, enf); + if (adminUsers && adminUsers.length > 0) { + await useAdmins(adminUsers, enforcerDelegate, roleMetadataStorage, knex); + } else { + logger.warn( + 'There are no admins configured for the RBAC-backend plugin. The plugin may not work properly.', + ); } - if (adminUsers) { - useAdmins(adminUsers, enf); + if (policiesFile) { + await addPermissionPoliciesFileData( + policiesFile, + enforcerDelegate, + roleMetadataStorage, + ); + } else { + await removedOldPermissionPoliciesFileData(enforcerDelegate); } - return new RBACPermissionPolicy(enf, logger, conditionalStorage); + return new RBACPermissionPolicy( + enforcerDelegate, + logger, + conditionalStorage, + ); } private constructor( - enforcer: Enforcer, + enforcer: EnforcerDelegate, logger: Logger, conditionStorage: ConditionalStorage, ) { diff --git a/plugins/rbac-backend/src/service/policies-rest-api.test.ts b/plugins/rbac-backend/src/service/policies-rest-api.test.ts index 4af3d6def9..b40017f75b 100644 --- a/plugins/rbac-backend/src/service/policies-rest-api.test.ts +++ b/plugins/rbac-backend/src/service/policies-rest-api.test.ts @@ -7,18 +7,24 @@ import { ConditionalPolicyDecision, } from '@backstage/plugin-permission-common'; -import { Enforcer } from 'casbin'; import express from 'express'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; import request from 'supertest'; import { + PermissionPolicyMetadata, policyEntityCreatePermission, policyEntityDeletePermission, policyEntityReadPermission, policyEntityUpdatePermission, Role, + RoleMetadata, + Source, } from '@janus-idp/backstage-plugin-rbac-common'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { EnforcerDelegate } from './enforcer-delegate'; import { RBACPermissionPolicy } from './permission-policy'; import { PluginMetadataResponseSerializedRule, @@ -46,29 +52,27 @@ jest.mock('@backstage/plugin-auth-node', () => ({ getBearerTokenFromAuthorizationHeader: () => 'token', })); -const mockEnforcer: Partial = { - addPolicies: jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }), - addGroupingPolicies: jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }), +const mockEnforcer: Partial = { hasPolicy: jest .fn() .mockImplementation(async (..._param: string[]): Promise => { return false; }), + hasGroupingPolicy: jest .fn() .mockImplementation(async (..._param: string[]): Promise => { return false; }), - loadPolicy: jest.fn().mockImplementation(async () => {}), - enableAutoSave: jest.fn().mockImplementation((_enable: boolean) => {}), + + getPolicy: jest.fn().mockImplementation((): Promise => { + return Promise.resolve([[]]); + }), + + getGroupingPolicy: jest.fn().mockImplementation((): Promise => { + return Promise.resolve([[]]); + }), + getFilteredPolicy: jest .fn() .mockImplementation( @@ -85,17 +89,57 @@ const mockEnforcer: Partial = { return [['user:default/permission_admin', 'role:default/rbac_admin']]; }, ), + + addPolicy: jest.fn().mockImplementation(), + + addOrUpdatePolicy: jest.fn().mockImplementation(), + + addPolicies: jest.fn().mockImplementation(), + + addGroupingPolicy: jest.fn().mockImplementation(), + + addGroupingPolicies: jest.fn().mockImplementation(), + + removePolicy: jest.fn().mockImplementation(), + + removePolicies: jest.fn().mockImplementation(), + + removeGroupingPolicy: jest.fn().mockImplementation(), + + removeGroupingPolicies: jest.fn().mockImplementation(), + + getMetadata: jest.fn().mockImplementation(async () => { + const metadata: PermissionPolicyMetadata = { source: 'rest' }; + return Promise.resolve(metadata); + }), + + getFilteredPolicyMetadata: jest.fn().mockImplementation(() => { + return []; + }), + + hasFilteredPolicyMetadata: jest.fn().mockImplementation(() => { + return Promise.resolve(false); + }), + + updatePolicies: jest.fn().mockImplementation(), + + addOrUpdateGroupingPolicy: jest.fn().mockImplementation(), + + updateGroupingPolicies: jest.fn().mockImplementation(), }; -jest.mock('casbin', () => { - const actualCasbin = jest.requireActual('casbin'); - return { - ...actualCasbin, - newEnforcer: jest.fn((): Promise> => { - return Promise.resolve(mockEnforcer); - }), - }; -}); +const roleMetadataStorageMock: RoleMetadataStorage = { + findRoleMetadata: jest + .fn() + .mockImplementation( + async (_roleEntityRef: string): Promise => { + return { source: 'rest' }; + }, + ), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), +}; const conditionalStorage = { getConditions: jest.fn().mockImplementation(), @@ -152,6 +196,14 @@ describe('REST policies api', () => { return false; }); + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (_roleEntityRef: string): Promise => { + return { source: 'rest' }; + }, + ); + const config = new ConfigReader({ backend: { database: { @@ -166,6 +218,8 @@ describe('REST policies api', () => { getExternalBaseUrl: jest.fn().mockImplementation(), }; + const knex = Knex.knex({ client: MockClient }); + const options: RouterOptions = { config: config, logger, @@ -175,7 +229,9 @@ describe('REST policies api', () => { logger, config, conditionalStorage, - mockEnforcer as Enforcer, + mockEnforcer as EnforcerDelegate, + roleMetadataStorageMock, + knex, ), }; @@ -189,12 +245,13 @@ describe('REST policies api', () => { mockIdentityClient, mockPermissionEvaluator, options, - mockEnforcer as Enforcer, + mockEnforcer as EnforcerDelegate, config, logger, mockDiscovery, conditionalStorage, backendPluginIDsProviderMock, + roleMetadataStorageMock, ); const router = await server.serve(); app = express().use(router); @@ -390,10 +447,10 @@ describe('REST policies api', () => { }); it('should not be created permission policy caused some unexpected error', async () => { - mockEnforcer.addPolicies = jest + mockEnforcer.addOrUpdatePolicy = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; + .mockImplementation(async (): Promise => { + throw new Error(`Failed to add policies`); }); const result = await request(app) @@ -467,6 +524,9 @@ describe('REST policies api', () => { permission: 'policy-entity', policy: 'create', effect: 'allow', + metadata: { + source: 'rest', + }, }, ]); }); @@ -527,12 +587,18 @@ describe('REST policies api', () => { permission: 'policy-entity', policy: 'create', effect: 'allow', + metadata: { + source: 'rest', + }, }, { entityReference: 'user:default/guest', permission: 'policy-entity', policy: 'read', effect: 'allow', + metadata: { + source: 'rest', + }, }, ]); }); @@ -556,6 +622,9 @@ describe('REST policies api', () => { permission: 'policy-entity', policy: 'read', effect: 'allow', + metadata: { + source: 'rest', + }, }, ]); }); @@ -658,7 +727,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(404); expect(result.body.error).toEqual({ name: 'NotFoundError', - message: '', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, }); }); @@ -670,8 +739,8 @@ describe('REST policies api', () => { }); mockEnforcer.removePolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; + .mockImplementation(async (..._param: string[]): Promise => { + throw new Error('Fail to delete policy'); }); const result = await request(app) @@ -687,7 +756,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(500); expect(result.body.error).toEqual({ name: 'Error', - message: 'Unexpected error', + message: 'Fail to delete policy', }); }); @@ -927,7 +996,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(404); expect(result.body.error).toEqual({ name: 'NotFoundError', - message: '', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, }); }); @@ -954,7 +1023,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(404); expect(result.body.error).toEqual({ name: 'NotFoundError', - message: '', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, }); }); @@ -986,7 +1055,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(409); expect(result.body.error).toEqual({ name: 'ConflictError', - message: '', + message: `Policy '[user:default/permission_admin, policy-entity, write, allow]' has been already stored`, }); }); @@ -1103,10 +1172,10 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removePolicies = jest + mockEnforcer.updatePolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; + .mockImplementation(async (): Promise => { + throw new Error('Fail to remove policy'); }); const result = await request(app) @@ -1131,7 +1200,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(500); expect(result.body.error).toEqual({ name: 'Error', - message: 'Unexpected error', + message: 'Fail to remove policy', }); }); @@ -1144,16 +1213,13 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addPolicies = jest + mockEnforcer.updatePolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Fail to add policy'); + }, + ); const result = await request(app) .put('/policies/user/default/permission_admin') @@ -1177,7 +1243,7 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(500); expect(result.body.error).toEqual({ name: 'Error', - message: 'Unexpected error', + message: 'Fail to add policy', }); }); @@ -1190,16 +1256,7 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); + mockEnforcer.updatePolicies = jest.fn().mockImplementation(); const result = await request(app) .put('/policies/user/default/permission_admin') @@ -1386,10 +1443,16 @@ describe('REST policies api', () => { { memberReferences: ['group:default/test'], name: 'role:default/test', + metadata: { + source: 'rest', + }, }, { memberReferences: ['group:default/team_a'], name: 'role:default/team_a', + metadata: { + source: 'rest', + }, }, ]); }); @@ -1435,6 +1498,9 @@ describe('REST policies api', () => { { memberReferences: ['user:default/permission_admin'], name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + }, }, ]); }); @@ -1589,10 +1655,10 @@ describe('REST policies api', () => { }); it('should not be created role caused some unexpected error', async () => { - mockEnforcer.addGroupingPolicies = jest + mockEnforcer.addOrUpdateGroupingPolicy = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; + .mockImplementation(async (): Promise => { + throw new Error('Fail to create new policy'); }); const result = await request(app) @@ -1603,6 +1669,10 @@ describe('REST policies api', () => { }); expect(result.statusCode).toBe(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Fail to create new policy', + }); }); it('should fail to create role - duplicate', async () => { @@ -1696,6 +1766,11 @@ describe('REST policies api', () => { }); it('should fail to update role - old role not found', async () => { + mockEnforcer.hasPolicy = jest + .fn() + .mockImplementation(async (..._policy: string[]): Promise => { + return false; + }); const result = await request(app) .put('/roles/role/default/rbac_admin') .send({ @@ -1711,7 +1786,8 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(404); expect(result.body.error).toEqual({ name: 'NotFoundError', - message: '', + message: + 'Member reference: user:default/permission_admin was not found for role role:default/rbac_admin', }); }); @@ -1761,6 +1837,39 @@ describe('REST policies api', () => { expect(result.statusCode).toEqual(204); }); + it('should fail to update policy - role metadata could not be found', async () => { + mockEnforcer.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return undefined; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `Unable to find metadata for role:default/rbac_admin`, + }); + }); + it('should fail to update role - unable to remove oldRole', async () => { mockEnforcer.hasGroupingPolicy = jest .fn() @@ -1770,10 +1879,10 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest + mockEnforcer.updateGroupingPolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; + .mockImplementation(async (): Promise => { + throw new Error('Unexpected error'); }); const result = await request(app) @@ -1804,16 +1913,13 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addGroupingPolicies = jest + mockEnforcer.updateGroupingPolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Unexpected error'); + }, + ); const result = await request(app) .put('/roles/role/default/rbac_admin') @@ -1843,16 +1949,7 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); + mockEnforcer.updateGroupingPolicies = jest.fn().mockImplementation(); const result = await request(app) .put('/roles/role/default/rbac_admin') @@ -1881,16 +1978,7 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); + mockEnforcer.updateGroupingPolicies = jest.fn().mockImplementation(); const result = await request(app) .put('/roles/role/default/rbac_admin') @@ -1916,16 +2004,7 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); + mockEnforcer.updateGroupingPolicies = jest.fn().mockImplementation(); const result = await request(app) .put('/roles/role/default/rbac_admin') @@ -1954,16 +2033,7 @@ describe('REST policies api', () => { } return true; }); - mockEnforcer.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - mockEnforcer.addGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); + mockEnforcer.updateGroupingPolicies = jest.fn().mockImplementation(); const result = await request(app) .put('/roles/role/default/rbac_admin') @@ -2101,9 +2171,11 @@ describe('REST policies api', () => { }); mockEnforcer.removeGroupingPolicies = jest .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Unexpected error'); + }, + ); const result = await request(app) .delete( @@ -2215,7 +2287,7 @@ describe('REST policies api', () => { }); describe('transformRoleArray', () => { - it('should combine two roles together that are similar', () => { + it('should combine two roles together that are similar', async () => { const roles = [ ['group:default/test', 'role:default/test'], ['user:default/test', 'role:default/test'], @@ -2225,10 +2297,13 @@ describe('REST policies api', () => { { memberReferences: ['group:default/test', 'user:default/test'], name: 'role:default/test', + metadata: { + source: 'rest', + }, }, ]; - const transformedRoles = server.transformRoleArray(...roles); + const transformedRoles = await server.transformRoleArray(...roles); expect(transformedRoles).toStrictEqual(expectedResult); }); }); diff --git a/plugins/rbac-backend/src/service/policies-rest-api.ts b/plugins/rbac-backend/src/service/policies-rest-api.ts index 26b004d014..5ee09b8320 100644 --- a/plugins/rbac-backend/src/service/policies-rest-api.ts +++ b/plugins/rbac-backend/src/service/policies-rest-api.ts @@ -23,7 +23,6 @@ import { } from '@backstage/plugin-permission-common'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; -import { Enforcer } from 'casbin'; import { Router } from 'express'; import { Request } from 'express-serve-static-core'; import { isEmpty, isEqual } from 'lodash'; @@ -42,6 +41,9 @@ import { } from '@janus-idp/backstage-plugin-rbac-common'; import { ConditionalStorage } from '../database/conditional-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { policyToString } from '../helper'; +import { EnforcerDelegate } from './enforcer-delegate'; import { PluginPermissionMetadataCollector } from './plugin-endpoints'; import { validateEntityReference, @@ -55,12 +57,13 @@ export class PolicesServer { private readonly identity: IdentityApi, private readonly permissions: PermissionEvaluator, private readonly options: RouterOptions, - private readonly enforcer: Enforcer, + private readonly enforcer: EnforcerDelegate, private readonly config: Config, private readonly logger: Logger, private readonly discovery: PluginEndpointDiscovery, private readonly conditionalStorage: ConditionalStorage, private readonly pluginIdProvider: PluginIdProvider, + private readonly roleMetadata: RoleMetadataStorage, ) {} private async authorize( @@ -134,7 +137,7 @@ export class PolicesServer { policies = await this.enforcer.getPolicy(); } - response.json(this.transformPolicyArray(...policies)); + response.json(await this.transformPolicyArray(...policies)); }); router.get( @@ -152,7 +155,7 @@ export class PolicesServer { const policy = await this.enforcer.getFilteredPolicy(0, entityRef); if (policy.length !== 0) { - response.json(this.transformPolicyArray(...policy)); + response.json(await this.transformPolicyArray(...policy)); } else { throw new NotFoundError(); // 404 } @@ -183,10 +186,7 @@ export class PolicesServer { const processedPolicies = await this.processPolicies(policyRaw, true); - const isRemoved = await this.enforcer.removePolicies(processedPolicies); - if (!isRemoved) { - throw new Error('Unexpected error'); // 500 - } + await this.enforcer.removePolicies(processedPolicies, 'rest'); response.status(204).end(); }, ); @@ -208,10 +208,10 @@ export class PolicesServer { const processedPolicies = await this.processPolicies(policyRaw); - const isAdded = await this.enforcer.addPolicies(processedPolicies); - if (!isAdded) { - throw new Error('Unexpected error'); // 500 + for (const policy of processedPolicies) { + await this.enforcer.addOrUpdatePolicy(policy, 'rest', false); } + response.status(201).end(); }); @@ -276,19 +276,12 @@ export class PolicesServer { 'new policy', ); - // enforcer.updatePolicy(oldPolicyPermission, newPolicyPermission) was not implemented - // for ORMTypeAdapter. - // So, let's compensate this combination delete + create. - const isRemoved = - await this.enforcer.removePolicies(processedOldPolicy); - if (!isRemoved) { - throw new Error('Unexpected error'); // 500 - } - - const isAdded = await this.enforcer.addPolicies(processedNewPolicy); - if (!isAdded) { - throw new Error('Unexpected error'); - } + await this.enforcer.updatePolicies( + processedOldPolicy, + processedNewPolicy, + 'rest', + false, + ); response.status(200).end(); }, @@ -307,7 +300,7 @@ export class PolicesServer { const roles = await this.enforcer.getGroupingPolicy(); - response.json(this.transformRoleArray(...roles)); + response.json(await this.transformRoleArray(...roles)); }); router.get('/roles/:kind/:namespace/:name', async (request, response) => { @@ -326,7 +319,7 @@ export class PolicesServer { ); if (role.length !== 0) { - response.json(this.transformRoleArray(...role)); + response.json(await this.transformRoleArray(...role)); } else { throw new NotFoundError(); // 404 } @@ -352,7 +345,10 @@ export class PolicesServer { const roles = this.transformRoleToArray(roleRaw); for (const role of roles) { - if (await this.enforcer.hasGroupingPolicy(...role)) { + if ( + (await this.enforcer.hasGroupingPolicy(...role)) && + !(await this.enforcer.hasFilteredPolicyMetadata(role, 'legacy')) + ) { throw new ConflictError(); // 409 } const roleString = JSON.stringify(role); @@ -368,11 +364,10 @@ export class PolicesServer { } } - const isAdded = await this.enforcer.addGroupingPolicies(roles); - - if (!isAdded) { - throw new Error('Unexpected error'); // 500 + for (const role of roles) { + await this.enforcer.addOrUpdateGroupingPolicy(role, 'rest', false); } + response.status(201).end(); }); @@ -387,12 +382,12 @@ export class PolicesServer { } const roleEntityRef = this.getEntityReference(request, true); - const oldRoleRaw = request.body.oldRole; + const oldRoleRaw: Role = request.body.oldRole; if (!oldRoleRaw) { throw new InputError(`'oldRole' object must be present`); // 400 } - const newRoleRaw = request.body.newRole; + const newRoleRaw: Role = request.body.newRole; if (!newRoleRaw) { throw new InputError(`'newRole' object must be present`); // 400 } @@ -413,6 +408,7 @@ export class PolicesServer { const oldRole = this.transformRoleToArray(oldRoleRaw); const newRole = this.transformRoleToArray(newRoleRaw); + // todo shell we allow newRole with an empty array?... for (const role of newRole) { const hasRole = oldRole.some(element => { @@ -445,7 +441,9 @@ export class PolicesServer { uniqueItems.clear(); for (const role of oldRole) { if (!(await this.enforcer.hasGroupingPolicy(...role))) { - throw new NotFoundError(); // 404 + throw new NotFoundError( + `Member reference: ${role[0]} was not found for role ${roleEntityRef}`, + ); // 404 } const roleString = JSON.stringify(role); @@ -460,26 +458,35 @@ export class PolicesServer { } } - // enforcer.updateGroupingPolicy(oldRole, newRole) was not implemented - // for ORMTypeAdapter. - // So, let's compensate this combination delete + create. - const isRemoved = await this.enforcer.removeGroupingPolicies(oldRole); - if (!isRemoved) { - throw new Error('Unexpected error'); // 500 + const metadata = await this.roleMetadata.findRoleMetadata(roleEntityRef); + if (!metadata) { + throw new NotFoundError(`Unable to find metadata for ${roleEntityRef}`); } - - const isAdded = await this.enforcer.addGroupingPolicies(newRole); - if (!isAdded) { - throw new Error('Unexpected error'); + if (metadata.source === 'csv-file') { + throw new Error( + `Role ${roleEntityRef} can be modified only using csv policy file.`, + ); + } + if (metadata.source === 'configuration') { + throw new Error( + `Role ${roleEntityRef} can be modified only using application config`, + ); } + await this.enforcer.updateGroupingPolicies( + oldRole, + newRole, + 'rest', + false, + ); + response.status(200).end(); }); router.delete( '/roles/:kind/:namespace/:name', async (request, response) => { - let roles = []; + let roleMembers = []; const decision = await this.authorize(request, { permission: policyEntityDeletePermission, }); @@ -494,25 +501,35 @@ export class PolicesServer { const memberReferences = this.getFirstQuery( request.query.memberReferences!, ); - - roles.push([memberReferences, roleEntityRef]); + roleMembers.push([memberReferences, roleEntityRef]); } else { - roles = await this.enforcer.getFilteredGroupingPolicy( + roleMembers = await this.enforcer.getFilteredGroupingPolicy( 1, roleEntityRef, ); } - for (const role of roles) { + for (const role of roleMembers) { if (!(await this.enforcer.hasGroupingPolicy(...role))) { throw new NotFoundError(); // 404 } } - const isRemoved = await this.enforcer.removeGroupingPolicies(roles); - if (!isRemoved) { - throw new Error('Unexpected error'); // 500 + const metadata = + await this.roleMetadata.findRoleMetadata(roleEntityRef); + if (metadata?.source === 'csv-file') { + throw new Error( + `Role ${roleEntityRef} can be modified only using csv policy file.`, + ); + } + if (metadata?.source === 'configuration') { + throw new Error( + `Pre-defined role ${roleEntityRef} is reserved and can not be modified.`, + ); } + + await this.enforcer.removeGroupingPolicies(roleMembers, 'rest', false); + response.status(204).end(); }, ); @@ -655,14 +672,26 @@ export class PolicesServer { return entityRef; } - transformPolicyArray(...policies: string[][]): RoleBasedPolicy[] { - return policies.map((p: string[]) => { + async transformPolicyArray( + ...policies: string[][] + ): Promise { + const roleBasedPolices: RoleBasedPolicy[] = []; + for (const p of policies) { const [entityReference, permission, policy, effect] = p; - return { entityReference, permission, policy, effect }; - }); + const metadata = await this.enforcer.getMetadata(p); + roleBasedPolices.push({ + entityReference, + permission, + policy, + effect, + metadata, + }); + } + + return roleBasedPolices; } - transformRoleArray(...roles: string[][]): Role[] { + async transformRoleArray(...roles: string[][]): Promise { const combinedRoles: { [key: string]: string[] } = {}; roles.forEach(([value, role]) => { @@ -673,10 +702,15 @@ export class PolicesServer { } }); - const result: Role[] = Object.entries(combinedRoles).map( - ([role, value]) => { - return { memberReferences: value, name: role }; - }, + const result: Role[] = await Promise.all( + Object.entries(combinedRoles).map(async ([role, value]) => { + const metadata = await this.roleMetadata.findRoleMetadata(role); + return Promise.resolve({ + memberReferences: value, + name: role, + metadata, + }); + }), ); return result; } @@ -737,20 +771,32 @@ export class PolicesServer { const err = validatePolicy(policy); if (err) { throw new InputError( - `Invalid ${errorMessage || 'policy'} definition. Cause: ${ + `Invalid ${errorMessage ?? 'policy'} definition. Cause: ${ err.message }`, ); // 400 } const transformedPolicy = this.transformPolicyToArray(policy); - if (isOld && !(await this.enforcer.hasPolicy(...transformedPolicy))) { - throw new NotFoundError(); // 404 - } - - if (!isOld && (await this.enforcer.hasPolicy(...transformedPolicy))) { - throw new ConflictError(); // 409 + throw new NotFoundError( + `Policy '${policyToString(transformedPolicy)}' not found`, + ); // 404 + } + + if ( + !isOld && + (await this.enforcer.hasPolicy(...transformedPolicy)) && + !(await this.enforcer.hasFilteredPolicyMetadata( + transformedPolicy, + 'legacy', + )) + ) { + throw new ConflictError( + `Policy '${policyToString( + transformedPolicy, + )}' has been already stored`, + ); // 409 } // We want to ensure that there are not duplicate permission policies diff --git a/plugins/rbac-backend/src/service/policies-validation.ts b/plugins/rbac-backend/src/service/policies-validation.ts index 5cd3864f9e..134ed12896 100644 --- a/plugins/rbac-backend/src/service/policies-validation.ts +++ b/plugins/rbac-backend/src/service/policies-validation.ts @@ -1,7 +1,13 @@ import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { Role, RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; +import { + Role, + RoleBasedPolicy, + Source, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { RoleMetadataStorage } from '../database/role-metadata'; export function validatePolicy(policy: RoleBasedPolicy): Error | undefined { const err = validateEntityReference(policy.entityReference); @@ -101,3 +107,76 @@ export function validateEntityReference( return undefined; } + +async function validateGroupingPolicy( + groupPolicy: string[], + preDefinedPoliciesFile: string, + roleMetadataStorage: RoleMetadataStorage, + source: Source, +) { + if (groupPolicy.length !== 2) { + throw new Error(`Group policy should has length 2`); + } + + const member = groupPolicy[0]; + let err = validateEntityReference(member); + if (err) { + throw new Error( + `Failed to validate group policy ${groupPolicy} from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, + ); + } + const parent = groupPolicy[1]; + err = validateEntityReference(parent); + if (err) { + throw new Error( + `Failed to validate group policy ${groupPolicy} from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, + ); + } + if (member.startsWith(`role:`)) { + throw new Error( + `Group policy is invalid: ${groupPolicy}. rbac-backend plugin doesn't support role inheritance.`, + ); + } + if (member.startsWith(`group:`) && parent.startsWith(`group:`)) { + throw new Error( + `Group policy is invalid: ${groupPolicy}. Group inheritance information could be provided only with help of Catalog API.`, + ); + } + if (member.startsWith(`user:`) && parent.startsWith(`group:`)) { + throw new Error( + `Group policy is invalid: ${groupPolicy}. User membership information could be provided only with help of Catalog API.`, + ); + } + + const metadata = await roleMetadataStorage.findRoleMetadata(parent); + if (metadata && metadata.source !== source && metadata.source !== 'legacy') { + throw new Error( + `You could not add user or group to the role created with source ${metadata.source}`, + ); + } +} + +export async function validateAllPredefinedPolicies( + policies: string[][], + groupPolicies: string[][], + preDefinedPoliciesFile: string, + roleMetadataStorage: RoleMetadataStorage, +): Promise { + for (const policy of policies) { + const err = validateEntityReference(policy[0]); + if (err) { + throw new Error( + `Failed to validate policy from file ${preDefinedPoliciesFile}. Cause: ${err.message}`, + ); + } + } + + for (const groupPolicy of groupPolicies) { + await validateGroupingPolicy( + groupPolicy, + preDefinedPoliciesFile, + roleMetadataStorage, + `csv-file`, + ); + } +} diff --git a/plugins/rbac-backend/src/service/policy-builder.ts b/plugins/rbac-backend/src/service/policy-builder.ts index a8b42b48b8..3b45984970 100644 --- a/plugins/rbac-backend/src/service/policy-builder.ts +++ b/plugins/rbac-backend/src/service/policy-builder.ts @@ -16,6 +16,10 @@ import { Logger } from 'winston'; import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; import { DataBaseConditionalStorage } from '../database/conditional-storage'; +import { migrate } from '../database/migration'; +import { DataBasePolicyMetadataStorage } from '../database/policy-metadata-storage'; +import { DataBaseRoleMetadataStorage } from '../database/role-metadata'; +import { EnforcerDelegate } from './enforcer-delegate'; import { MODEL } from './permission-model'; import { RBACPermissionPolicy } from './permission-policy'; import { PolicesServer } from './policies-rest-api'; @@ -58,6 +62,7 @@ export class PolicyBuilder { './model/rbac-policy.csv', ), ); + env.logger.info('rbac backend plugin uses only file storage'); } const enf = await newEnforcer(newModelFromString(MODEL), adapter); @@ -74,8 +79,19 @@ export class PolicyBuilder { enf.enableAutoBuildRoleLinks(false); await enf.buildRoleLinks(); - const conditionStorage = - await DataBaseConditionalStorage.create(databaseManager); + await migrate(databaseManager); + const knex = await databaseManager.getClient(); + + const conditionStorage = new DataBaseConditionalStorage(knex); + + const policyMetadataStorage = new DataBasePolicyMetadataStorage(knex); + const roleMetadataStorage = new DataBaseRoleMetadataStorage(knex); + const enforcerDelegate = new EnforcerDelegate( + enf, + policyMetadataStorage, + roleMetadataStorage, + knex, + ); const options: RouterOptions = { config: env.config, @@ -86,7 +102,9 @@ export class PolicyBuilder { env.logger, env.config, conditionStorage, - enf, + enforcerDelegate, + roleMetadataStorage, + knex, ), }; @@ -107,12 +125,13 @@ export class PolicyBuilder { env.identity, env.permissions, options, - enf, + enforcerDelegate, env.config, env.logger, env.discovery, conditionStorage, pluginIdProvider, + roleMetadataStorage, ); return server.serve(); } diff --git a/plugins/rbac-backend/src/service/test/data/rbac-policy.csv b/plugins/rbac-backend/src/service/test/data/rbac-policy.csv index e17dcca8e6..aa39b3b3a2 100644 --- a/plugins/rbac-backend/src/service/test/data/rbac-policy.csv +++ b/plugins/rbac-backend/src/service/test/data/rbac-policy.csv @@ -4,3 +4,5 @@ g, user:default/guest, role:default/catalog-writer p, role:default/catalog-writer, catalog-entity, update, allow p, user:default/guest, catalog-entity, read, allow p, user:default/guest, catalog.entity.create, use, allow + +p, user:default/known_user, test.resource.deny, use, allow \ No newline at end of file diff --git a/plugins/rbac-common/src/types.ts b/plugins/rbac-common/src/types.ts index b4fbba8272..6565623865 100644 --- a/plugins/rbac-common/src/types.ts +++ b/plugins/rbac-common/src/types.ts @@ -1,7 +1,22 @@ +export type Source = + | 'rest' // created via REST API + | 'csv-file' // created via policies-csv-file with defined path in the application configuration + | 'configuration' // created from application configuration + | 'legacy'; // preexisting policies + +export type PermissionPolicyMetadata = { + source: Source; +}; + +export type RoleMetadata = { + source: Source; +}; + export type Policy = { permission?: string; policy?: string; effect?: string; + metadata?: PermissionPolicyMetadata; }; export type RoleBasedPolicy = Policy & { @@ -11,6 +26,7 @@ export type RoleBasedPolicy = Policy & { export type Role = { memberReferences: string[]; name: string; + metadata?: RoleMetadata; }; export type UpdatePolicy = { diff --git a/yarn.lock b/yarn.lock index e0edf8765a..9d18278431 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3324,7 +3324,7 @@ winston "^3.2.1" winston-transport "^4.5.0" -"@backstage/backend-common@0.19.8", "@backstage/backend-common@^0.19.4", "@backstage/backend-common@^0.19.8": +"@backstage/backend-common@0.19.8", "@backstage/backend-common@^0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@backstage/backend-common/-/backend-common-0.19.8.tgz#df4cb4826edc8b60a74d34904eca349d913c257f" integrity sha512-MGHjuq35fX5fy7LVMUs6tIFeE9Hx1Ok8mrFxP15WbRWwSjHoXmEzjsQQzuw1xSviEHWupOAW7DevO+oZ5zgy1g== @@ -3563,20 +3563,6 @@ express "^4.17.1" knex "^2.0.0" -"@backstage/backend-plugin-api@^0.5.4": - version "0.5.4" - resolved "https://registry.yarnpkg.com/@backstage/backend-plugin-api/-/backend-plugin-api-0.5.4.tgz#de750d44a37d827605bf813e7a126de6457a3bd0" - integrity sha512-ehTRoDsTlCXHNyb850FaoBTMlAfXoRH7do6uot+C/kB50z+nqRB9fRwtHahWK3fKXb0G/r/fk5h5RTmvN5j7bw== - dependencies: - "@backstage/backend-tasks" "^0.5.4" - "@backstage/config" "^1.0.8" - "@backstage/plugin-auth-node" "^0.2.16" - "@backstage/plugin-permission-common" "^0.7.7" - "@backstage/types" "^1.1.0" - "@types/express" "^4.17.6" - express "^4.17.1" - knex "^2.0.0" - "@backstage/backend-plugin-api@^0.6.8": version "0.6.8" resolved "https://registry.yarnpkg.com/@backstage/backend-plugin-api/-/backend-plugin-api-0.6.8.tgz#05f45db35c0eec191d2eb36df74c29c43c55ddab" @@ -3633,7 +3619,7 @@ lodash "^4.17.21" winston "^3.2.1" -"@backstage/backend-tasks@^0.5.11", "@backstage/backend-tasks@^0.5.4": +"@backstage/backend-tasks@^0.5.11": version "0.5.11" resolved "https://registry.yarnpkg.com/@backstage/backend-tasks/-/backend-tasks-0.5.11.tgz#16f54701a19ca3c9734ea5525d46d54c11799c7b" integrity sha512-GWHCpBjeEBmxprv7ckiQklHU0R8SYQOEfbKqZtX8sv98uXI6HRAJc8Ze2iMyRJPst8FqN5gcF4/mKXQgRbEJiw== @@ -4779,20 +4765,6 @@ zod "^3.21.4" zod-to-json-schema "^3.21.4" -"@backstage/plugin-auth-node@^0.2.16": - version "0.2.19" - resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.2.19.tgz#bae4befc72ad79a4a805a2e847f6d68f75dc0195" - integrity sha512-E/GuS0OzcrauE5b4ODKXOT59EEuCaNHSWnBeerXlxoqBaGSVdXodOjpfmcU7sxMHmJvT0W04G4gOR9Jc+VPLEw== - dependencies: - "@backstage/backend-common" "^0.19.4" - "@backstage/config" "^1.0.8" - "@backstage/errors" "^1.2.1" - "@types/express" "*" - express "^4.17.1" - jose "^4.6.0" - node-fetch "^2.6.7" - winston "^3.2.1" - "@backstage/plugin-auth-node@^0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@backstage/plugin-auth-node/-/plugin-auth-node-0.4.2.tgz#2101fa8c2fc640dc2c4ca964a5d749ebed9f072e" @@ -5374,7 +5346,7 @@ yn "^4.0.0" zod "^3.21.4" -"@backstage/plugin-permission-common@0.7.9", "@backstage/plugin-permission-common@^0.7.7", "@backstage/plugin-permission-common@^0.7.9": +"@backstage/plugin-permission-common@0.7.9", "@backstage/plugin-permission-common@^0.7.9": version "0.7.9" resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-common/-/plugin-permission-common-0.7.9.tgz#ea4401b7160f3f3f2cc075b691d1594d9560183c" integrity sha512-8/yrybvyEYkSkSnk/7NMNjqBkgvl0yj1VI8jJydYgIBoZj93V7qsaYfGEfpf1Af0NYDoTgPS2vI4lz0jB1RMKg== @@ -24891,6 +24863,13 @@ knex-mock-client@2.0.0: dependencies: lodash.clonedeep "^4.5.0" +knex-mock-client@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/knex-mock-client/-/knex-mock-client-2.0.1.tgz#b72c14d177c6bef6f019edfad7a5c1d5308dfcde" + integrity sha512-tSNaht+aquo6SgEhwPYidfYWVS2IUbF2ZNu4Ye0mScGTmyS+ZTJzpAkogpUIDwiZbkjIg/bvsopRDM/Lbdt+Mw== + dependencies: + lodash.clonedeep "^4.5.0" + knex@2.4.2, knex@^2.0.0, knex@^2.3.0: version "2.4.2" resolved "https://registry.yarnpkg.com/knex/-/knex-2.4.2.tgz#a34a289d38406dc19a0447a78eeaf2d16ebedd61"