diff --git a/plugins/rbac-backend/package.json b/plugins/rbac-backend/package.json index 93dd21b917..53807a37ee 100644 --- a/plugins/rbac-backend/package.json +++ b/plugins/rbac-backend/package.json @@ -39,6 +39,8 @@ "@janus-idp/backstage-plugin-rbac-common": "1.4.2", "@janus-idp/backstage-plugin-rbac-node": "1.1.1", "casbin": "^5.27.1", + "chokidar": "^3.6.0", + "csv-parse": "^5.5.5", "express": "^4.18.2", "express-promise-router": "^4.1.1", "knex": "^3.0.0", diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policies-actions.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policies-actions.csv deleted file mode 100644 index 6f7291622b..0000000000 --- a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policies-actions.csv +++ /dev/null @@ -1,3 +0,0 @@ -p, role:default/catalog-writer, catalog.entity.create, use, allow - -p, role:default/catalog-writer, catalog.entity.create, use, deny diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policy.csv index 90a0f92f09..181fb0868b 100644 --- a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policy.csv +++ b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/duplicate-policy.csv @@ -7,3 +7,6 @@ p, role:default/catalog-writer, catalog.entity.create, use, allow p, role:default/catalog-writer, catalog.entity.create, use, allow p, role:default/catalog-writer, catalog-entity, delete, allow + +p, role:default/duplication-effect, catalog-entity, update, allow +p, role:default/duplication-effect, catalog-entity, update, deny diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/entityref-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/entityref-policy.csv deleted file mode 100644 index 77040c9af4..0000000000 --- a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/entityref-policy.csv +++ /dev/null @@ -1 +0,0 @@ -g, user:default/, role:default/catalog-deleter diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/error-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/error-policy.csv new file mode 100644 index 0000000000..556bbfc6a6 --- /dev/null +++ b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/error-policy.csv @@ -0,0 +1,4 @@ +g, user:default/, role:default/catalog-deleter +g, user:default/test, role:default/ +p, role:default/, catalog.entity.create, use, allow +p, role:default/test, catalog.entity.create, delete, temp diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/permission-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/permission-policy.csv deleted file mode 100644 index ab2d78e065..0000000000 --- a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/permission-policy.csv +++ /dev/null @@ -1 +0,0 @@ -p, role:default/, catalog.entity.create, use, allow diff --git a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/role-entityref-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/role-entityref-policy.csv deleted file mode 100644 index cb1153fddb..0000000000 --- a/plugins/rbac-backend/src/__fixtures__/data/invalid-csv/role-entityref-policy.csv +++ /dev/null @@ -1 +0,0 @@ -g, user:default/test, role:default/ diff --git a/plugins/rbac-backend/src/__fixtures__/data/valid-csv/simple-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/valid-csv/simple-policy.csv new file mode 100644 index 0000000000..15c68d545a --- /dev/null +++ b/plugins/rbac-backend/src/__fixtures__/data/valid-csv/simple-policy.csv @@ -0,0 +1,2 @@ +g, user:default/guest, role:default/catalog-writer +p, role:default/catalog-writer, catalog-entity, update, allow diff --git a/plugins/rbac-backend/src/__fixtures__/data/valid-csv/updated-rbac-policy.csv b/plugins/rbac-backend/src/__fixtures__/data/valid-csv/updated-rbac-policy.csv deleted file mode 100644 index 7d02f304ac..0000000000 --- a/plugins/rbac-backend/src/__fixtures__/data/valid-csv/updated-rbac-policy.csv +++ /dev/null @@ -1,10 +0,0 @@ -g, user:default/guest, role:default/catalog-writer -g, user:default/guest, role:default/catalog-updater - -g, user:default/guest, role:default/catalog-tester - -p, role:default/catalog-writer, catalog-entity, update, allow -p, role:default/catalog-writer, catalog.entity.create, use, deny -p, role:default/catalog-deleter, catalog-entity, delete, allow - -p, role:default/catalog-writer, catalog.entity.delete, delete, allow diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts new file mode 100644 index 0000000000..3883d243e9 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts @@ -0,0 +1,619 @@ +import { DatabaseService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} 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 { policyToString } from '../helper'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { CSVFileWatcher } from './csv-file-watcher'; + +const catalogApi = { + getEntityAncestors: jest.fn().mockImplementation(), + getLocationById: jest.fn().mockImplementation(), + getEntities: jest.fn().mockImplementation(), + getEntitiesByRefs: jest.fn().mockImplementation(), + queryEntities: jest.fn().mockImplementation(), + getEntityByRef: jest.fn().mockImplementation(), + refreshEntity: jest.fn().mockImplementation(), + getEntityFacets: jest.fn().mockImplementation(), + addLocation: jest.fn().mockImplementation(), + getLocationByRef: jest.fn().mockImplementation(), + removeLocationById: jest.fn().mockImplementation(), + removeEntityByUid: jest.fn().mockImplementation(), + validateEntity: jest.fn().mockImplementation(), + getLocationByEntity: jest.fn().mockImplementation(), +}; + +const loggerMock: any = { + warn: jest.fn().mockImplementation(), + debug: 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( + async ( + _policy: string[], + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'csv-file' }; + }, + ), + createPolicyMetadata: jest.fn().mockImplementation(), + removePolicyMetadata: jest.fn().mockImplementation(), +}; + +const dbManagerMock: DatabaseService = { + getClient: jest.fn().mockImplementation(), +}; + +const mockAuthService = mockServices.auth(); + +const currentPermissionPolicies = [ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], + ['role:default/catalog-writer', 'catalog.entity.create', 'use', 'allow'], + ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], + ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], +]; + +const currentRoles = [ + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/guest', 'role:default/catalog-reader'], + ['user:default/guest', 'role:default/catalog-deleter'], + ['user:default/known_user', 'role:default/known_role'], +]; + +const legacyPermission = [ + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', +]; + +const legacyRole = ['user:default/guest', 'role:default/catalog-writer']; + +describe('CSVFileWatcher', () => { + let csvFileWatcher: CSVFileWatcher; + let enforcerDelegate: EnforcerDelegate; + let csvFileName: string; + + let enfAddPolicySpy: jest.SpyInstance, string[], any>; + let enfRemovePolicySpy: jest.SpyInstance, string[], any>; + let enfAddGroupingSpy: jest.SpyInstance, string[], any>; + let enfRemoveGroupingSpy: jest.SpyInstance, string[], any>; + + beforeEach(async () => { + csvFileName = resolve( + __dirname, + './../__fixtures__/data/valid-csv/rbac-policy.csv', + ); + + const config = newConfigReader(); + + const adapter = await new CasbinDBAdapterFactory( + config, + dbManagerMock, + ).createAdapter(); + + const stringModel = newModelFromString(MODEL); + const enf = await createEnforcer(stringModel, adapter, loggerMock); + + const knex = Knex.knex({ client: MockClient }); + + enforcerDelegate = new EnforcerDelegate( + enf, + policyMetadataStorageMock, + roleMetadataStorageMock, + knex, + ); + + enfAddPolicySpy = jest.spyOn(enf, 'addPolicy'); + enfRemovePolicySpy = jest.spyOn(enf, 'removePolicy'); + enfAddGroupingSpy = jest.spyOn(enf, 'addGroupingPolicy'); + enfRemoveGroupingSpy = jest.spyOn(enf, 'removeGroupingPolicy'); + + csvFileWatcher = new CSVFileWatcher( + enforcerDelegate, + loggerMock, + roleMetadataStorageMock, + ); + }); + + afterEach(() => { + (loggerMock.warn as jest.Mock).mockReset(); + }); + + describe('initialize', () => { + beforeEach(() => { + policyMetadataStorageMock.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (_source: Source): Promise => { + return []; + }, + ); + + policyMetadataStorageMock.findPolicyMetadata = jest + .fn() + .mockImplementation( + async ( + _policy: string[], + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'csv-file' }; + }, + ); + }); + + it('should be able to add permission policies during initialization', async () => { + await csvFileWatcher.initialize(csvFileName, false); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual(currentPermissionPolicies); + }); + + it('should be able to add roles during initialization', async () => { + await csvFileWatcher.initialize(csvFileName, false); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual(currentRoles); + }); + + it('should be able to update legacy permission policies during initialization', async () => { + policyMetadataStorageMock.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'legacy') { + return [ + { + id: 0, + policy: + '[role:default/catalog-writer, catalog-entity, update, allow]', + source: 'legacy', + }, + ]; + } + return []; + }, + ); + + policyMetadataStorageMock.findPolicyMetadata = jest + .fn() + .mockImplementation( + async ( + policy: string[], + _trx: Knex.Knex.Transaction, + ): Promise => { + if (policyToString(policy) === policyToString(legacyPermission)) { + return { source: 'legacy' }; + } + return { source: 'csv-file' }; + }, + ); + + const permissionPolicies = [ + ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], + [ + 'role:default/catalog-writer', + 'catalog.entity.create', + 'use', + 'allow', + ], + ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], + ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ]; + + await enforcerDelegate.addPolicy(legacyPermission, 'legacy'); + + await csvFileWatcher.initialize(csvFileName, false); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfRemovePolicySpy).toHaveBeenCalledWith(...legacyPermission); + + expect(enfAddPolicySpy).toHaveBeenCalledWith(...legacyPermission); + + expect(enfPolicies).toStrictEqual(permissionPolicies); + }); + + it('should be able to update legacy roles during initialization', async () => { + policyMetadataStorageMock.findPolicyMetadataBySource = jest + .fn() + .mockImplementation( + async (source: Source): Promise => { + if (source === 'legacy') { + return [ + { + id: 0, + policy: '[user:default/guest, role:default/catalog-writer]', + source: 'legacy', + }, + ]; + } + return []; + }, + ); + + policyMetadataStorageMock.findPolicyMetadata = jest + .fn() + .mockImplementation( + async ( + policy: string[], + _trx: Knex.Knex.Transaction, + ): Promise => { + if (policyToString(policy) === policyToString(legacyRole)) { + return { source: 'legacy' }; + } + return { source: 'csv-file' }; + }, + ); + + const roles = [ + ['user:default/guest', 'role:default/catalog-reader'], + ['user:default/guest', 'role:default/catalog-deleter'], + ['user:default/known_user', 'role:default/known_role'], + ['user:default/guest', 'role:default/catalog-writer'], + ]; + + await enforcerDelegate.addGroupingPolicy(legacyRole, { + roleEntityRef: legacyRole[1], + source: 'legacy', + }); + + await csvFileWatcher.initialize(csvFileName, false); + + const enfPolicies = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRemoveGroupingSpy).toHaveBeenCalledWith(...legacyRole); + + expect(enfAddGroupingSpy).toHaveBeenCalledWith(...legacyRole); + + expect(enfPolicies).toStrictEqual(roles); + }); + + // Failing tests + it('should fail to add duplicate policies', async () => { + csvFileName = resolve( + __dirname, + './../__fixtures__/data/invalid-csv/duplicate-policy.csv', + ); + + const duplicatePolicy = [ + 'role:default/catalog-writer', + 'catalog.entity.create', + 'use', + 'allow', + ]; + const duplicateRole = [ + 'user:default/guest', + 'role:default/catalog-deleter', + ]; + + const duplicatePolicyWithDifferentEffect = [ + 'role:default/duplication-effect', + 'catalog-entity', + 'update', + ]; + + await csvFileWatcher.initialize(csvFileName, false); + + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 1, + `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 2, + `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 3, + `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 4, + `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 5, + `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 6, + `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, + ); + }); + + it('should fail to add policies with errors', async () => { + csvFileName = resolve( + __dirname, + './../__fixtures__/data/invalid-csv/error-policy.csv', + ); + + const entityRoleError = ['user:default/', 'role:default/catalog-deleter']; + const roleError = ['user:default/test', 'role:default/']; + + const roleErrorPolicy = [ + 'role:default/', + 'catalog.entity.create', + 'use', + 'allow', + ]; + const allowErrorPolicy = [ + 'role:default/test', + 'catalog.entity.create', + 'delete', + 'temp', + ]; + + await csvFileWatcher.initialize(csvFileName, false); + + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 1, + `Failed to validate policy from file ${csvFileName}. Cause: Entity reference "${roleErrorPolicy[0]}" was not on the form [:][/]`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 2, + `Failed to validate policy from file ${csvFileName}. Cause: 'effect' has invalid value: '${allowErrorPolicy[3]}'. It should be: 'allow' or 'deny'`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 3, + `Failed to validate group policy ${entityRoleError} from file ${csvFileName}. Cause: Entity reference "${entityRoleError[0]}" was not on the form [:][/]`, + ); + expect(loggerMock.warn).toHaveBeenNthCalledWith( + 4, + `Failed to validate group policy ${roleError} from file ${csvFileName}. Cause: Entity reference "${roleError[1]}" was not on the form [:][/]`, + ); + }); + }); + + describe('onChange', () => { + beforeEach(async () => { + csvFileName = resolve( + __dirname, + './../__fixtures__/data/valid-csv/simple-policy.csv', + ); + await csvFileWatcher.initialize(csvFileName, false); + }); + + it('should add new permission policies on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'delete', + 'allow', + ], + ]; + + const policies = [ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'delete', 'allow'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual(policies); + }); + + it('should add new roles on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ['g', 'user:default/test', 'role:default/catalog-writer'], + ]; + + const roles = [ + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/test', 'role:default/catalog-writer'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual(roles); + }); + + it('should remove old permission policies on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual([]); + }); + + it('should remove old roles on change', async () => { + const addContents = [ + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual([]); + }); + + it('should do nothing if there is no change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfRoles).toStrictEqual([ + ['user:default/guest', 'role:default/catalog-writer'], + ]); + expect(enfPolicies).toStrictEqual([ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ]); + }); + }); +}); + +async function createEnforcer( + theModel: Model, + adapter: Adapter, + log: Logger, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const config = newConfigReader(); + + const rm = new BackstageRoleManager( + catalogApi, + log, + catalogDBClient, + config, + mockAuthService, + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} + +function newConfigReader( + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, +): ConfigReader { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + return new ConfigReader({ + permission: { + rbac: { + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }); +} diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts new file mode 100644 index 0000000000..1890c93421 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts @@ -0,0 +1,397 @@ +import { Enforcer, FileAdapter, newEnforcer, newModelFromString } from 'casbin'; +import chokidar from 'chokidar'; +import { parse } from 'csv-parse/sync'; +import { difference } from 'lodash'; +import { Logger } from 'winston'; + +import fs from 'fs'; + +import { RoleMetadataStorage } from '../database/role-metadata'; +import { + metadataStringToPolicy, + policyToString, + transformArrayToPolicy, +} from '../helper'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { + checkForDuplicateGroupPolicies, + checkForDuplicatePolicies, + validateGroupingPolicy, + validatePolicy, +} from '../service/policies-validation'; + +export const CSV_PERMISSION_POLICY_FILE_AUTHOR = 'csv permission policy file'; + +type CSVFilePolicies = { + addedPolicies: string[][]; + addedGroupPolicies: string[][]; + removedPolicies: string[][]; + removedGroupPolicies: string[][]; +}; + +export class CSVFileWatcher { + private currentContent: string[][]; + private csvFilePolicies: CSVFilePolicies; + private csvFileName: string; + constructor( + private readonly enforcer: EnforcerDelegate, + private readonly logger: Logger, + private readonly roleMetadataStorage: RoleMetadataStorage, + ) { + this.csvFileName = ''; + this.currentContent = []; + this.csvFilePolicies = { + addedPolicies: [], + addedGroupPolicies: [], + removedPolicies: [], + removedGroupPolicies: [], + }; + } + + /** + * getCurrentContents reads the current contents of the CSV file. + * @returns The current contents of the CSV file. + */ + getCurrentContents(): string { + return fs.readFileSync(this.csvFileName, 'utf-8'); + } + + /** + * parse is used to parse the current contents of the CSV file. + * @returns The CSV file parsed into a string[][]. + */ + parse(): string[][] { + const content = this.getCurrentContents(); + const parser = parse(content, { + skip_empty_lines: true, + relax_column_count: true, + trim: true, + }); + + return parser; + } + + /** + * watchFile initializes the file watcher and sets it to begin watching for changes. + */ + watchFile(): void { + const watcher = chokidar.watch(this.csvFileName); + watcher.on('change', async path => { + this.logger.info(`file ${path} has changed`); + await this.onChange(); + }); + } + + /** + * initialize will initialize the CSV file by loading all of the permission policies and roles into + * the enforcer. + * First, we will remove all roles and permission policies if they do not exist in the temporary file enforcer. + * Next, we will add all roles and permission polices if they are new to the CSV file + * Finally, we will set the file to be watched if allow reload is set + * @param csvFileName The name of the csvFile + * @param allowReload Whether or not we will allow reloads of the CSV file + */ + async initialize( + csvFileName: string | undefined, + allowReload: boolean, + ): Promise { + let content: string[][] = []; + // If the file is set load the file contents + if (csvFileName) { + this.csvFileName = csvFileName; + content = this.parse(); + } + + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new FileAdapter(this.csvFileName), + ); + + // Check for any old policies that will need to be removed by checking if + // the policy no longer exists in the temp enforcer (csv file) + const policiesToRemove = + await this.enforcer.getFilteredPolicyMetadata('csv-file'); + + for (const policy of policiesToRemove) { + const convertedPolicy = metadataStringToPolicy(policy.policy); + if ( + convertedPolicy.length === 2 && + !(await tempEnforcer.hasGroupingPolicy(...convertedPolicy)) + ) { + this.csvFilePolicies.removedGroupPolicies.push(convertedPolicy); + } else if ( + convertedPolicy.length > 2 && + !(await tempEnforcer.hasPolicy(...convertedPolicy)) + ) { + this.csvFilePolicies.removedPolicies.push(convertedPolicy); + } + } + + // Check for any new policies that need to be added by checking if + // the policy does not currently exist in the enforcer + const policiesToAdd = await tempEnforcer.getPolicy(); + const groupPoliciesToAdd = await tempEnforcer.getGroupingPolicy(); + + for (const policy of policiesToAdd) { + if (!(await this.enforcer.hasPolicy(...policy))) { + this.csvFilePolicies.addedPolicies.push(policy); + } + } + + for (const groupPolicy of groupPoliciesToAdd) { + if (!(await this.enforcer.hasGroupingPolicy(...groupPolicy))) { + this.csvFilePolicies.addedGroupPolicies.push(groupPolicy); + } + } + + // Check for policies that might need to be updated + // This will involve removing legacy policies if they exist in both the + // temp enforcer (csv file) and the enforcer + // We will then add them back with the new source + const policiesToUpdate = + await this.enforcer.getFilteredPolicyMetadata('legacy'); + + for (const policy of policiesToUpdate) { + const convertedPolicy = metadataStringToPolicy(policy.policy); + if ( + convertedPolicy.length === 2 && + (await tempEnforcer.hasGroupingPolicy(...convertedPolicy)) && + (await this.enforcer.hasGroupingPolicy(...convertedPolicy)) + ) { + this.csvFilePolicies.addedGroupPolicies.push(convertedPolicy); + } else if ( + convertedPolicy.length > 2 && + (await tempEnforcer.hasPolicy(...convertedPolicy)) && + (await this.enforcer.hasPolicy(...convertedPolicy)) + ) { + this.csvFilePolicies.addedPolicies.push(convertedPolicy); + } + } + + // We pass current here because this is during initialization and it has not changed yet + await this.updatePolicies(content, tempEnforcer); + + if (allowReload && csvFileName) { + this.watchFile(); + } + } + + /** + * onChange is called whenever there is a change to the CSV file. + * It will parse the current and new contents of the CSV file and process the roles and permission policies present. + * Afterwards, it will find the difference between the current and new contents of the CSV file + * and sort them into added / removed, permission policies / roles. + * It will finally call updatePolicies with the new content. + */ + async onChange(): Promise { + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new FileAdapter(this.csvFileName), + ); + + const newContent = this.parse(); + const currentFlatContent = this.currentContent.flatMap(data => { + return policyToString(data); + }); + const newFlatContent = newContent.flatMap(data => { + return policyToString(data); + }); + + const diffRemoved = difference(currentFlatContent, newFlatContent); // policy was removed + const diffAdded = difference(newFlatContent, currentFlatContent); // policy was added + + if (diffRemoved.length === 0 && diffAdded.length === 0) { + return; + } + + diffRemoved.forEach(policy => { + const convertedPolicy = metadataStringToPolicy(policy); + if (convertedPolicy[0] === 'p') { + convertedPolicy.splice(0, 1); + this.csvFilePolicies.removedPolicies.push(convertedPolicy); + } else if (convertedPolicy[0] === 'g') { + convertedPolicy.splice(0, 1); + this.csvFilePolicies.removedGroupPolicies.push(convertedPolicy); + } + }); + + diffAdded.forEach(policy => { + const convertedPolicy = metadataStringToPolicy(policy); + if (convertedPolicy[0] === 'p') { + convertedPolicy.splice(0, 1); + this.csvFilePolicies.addedPolicies.push(convertedPolicy); + } else if (convertedPolicy[0] === 'g') { + convertedPolicy.splice(0, 1); + this.csvFilePolicies.addedGroupPolicies.push(convertedPolicy); + } + }); + + await this.updatePolicies(newContent, tempEnforcer); + } + + /** + * updatePolicies is used to update all of the permission policies and roles within a CSV file. + * It will check the number of added and removed permissions policies and roles and call the appropriate + * methods for these. + * It will also update the current contents of the CSV file to the most recent + * @param newContent The new content present in the CSV file + * @param tempEnforcer Temporary enforcer for checking for duplicates when adding policies + */ + async updatePolicies( + newContent: string[][], + tempEnforcer: Enforcer, + ): Promise { + this.currentContent = newContent; + + if (this.csvFilePolicies.addedPolicies.length > 0) + await this.addPermissionPolicies(tempEnforcer); + if (this.csvFilePolicies.removedPolicies.length > 0) + await this.removePermissionPolicies(); + if (this.csvFilePolicies.addedGroupPolicies.length > 0) + await this.addRoles(tempEnforcer); + if (this.csvFilePolicies.removedGroupPolicies.length > 0) + await this.removeRoles(); + } + + /** + * addPermissionPolicies will add the new permission policies that are present in the CSV file. + * We will attempt to validate the permission policy and log any warnings that are encountered. + * If a warning is encountered, we will skip adding the permission policy to the enforcer. + * @param tempEnforcer Temporary enforcer for checking for duplicates when adding policies + */ + async addPermissionPolicies(tempEnforcer: Enforcer): Promise { + for (const policy of this.csvFilePolicies.addedPolicies) { + let err = validatePolicy(transformArrayToPolicy(policy)); + if (err) { + this.logger.warn( + `Failed to validate policy from file ${this.csvFileName}. Cause: ${err.message}`, + ); + continue; + } + + err = await checkForDuplicatePolicies( + tempEnforcer, + policy, + this.csvFileName, + ); + if (err) { + this.logger.warn(err.message); + continue; + } + try { + await this.enforcer.addOrUpdatePolicy(policy, 'csv-file', true); + } catch (e) { + this.logger.warn( + `Failed to add or update policy ${policy} after modification ${this.csvFileName}. Cause: ${e}`, + ); + } + } + + this.csvFilePolicies.addedPolicies = []; + } + + /** + * removePermissionPolicies will remove the permission policies that are no longer present in the CSV file. + */ + async removePermissionPolicies(): Promise { + try { + await this.enforcer.removePolicies( + this.csvFilePolicies.removedPolicies, + 'csv-file', + true, + ); + } catch (e) { + this.logger.warn( + `Failed to remove policies ${JSON.stringify( + this.csvFilePolicies.removedPolicies, + )} after modification ${this.csvFileName}. Cause: ${e}`, + ); + } + this.csvFilePolicies.removedPolicies = []; + } + + /** + * addRoles will add the new roles that are present in the CSV file. + * We will attempt to validate the role and log any warnings that are encountered. + * If a warning is encountered, we will skip adding the role to the enforcer. + * @param tempEnforcer Temporary enforcer for checking for duplicates when adding policies + */ + async addRoles(tempEnforcer: Enforcer): Promise { + for (const groupPolicy of this.csvFilePolicies.addedGroupPolicies) { + let err = await validateGroupingPolicy( + groupPolicy, + this.csvFileName, + this.roleMetadataStorage, + 'csv-file', + ); + if (err) { + this.logger.warn(err.message); + continue; + } + + err = await checkForDuplicateGroupPolicies( + tempEnforcer, + groupPolicy, + this.csvFileName, + ); + if (err) { + this.logger.warn(err.message); + continue; + } + + try { + await this.enforcer.addOrUpdateGroupingPolicy( + groupPolicy, + { + source: 'csv-file', + roleEntityRef: groupPolicy[1], + author: CSV_PERMISSION_POLICY_FILE_AUTHOR, + modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, + }, + true, + ); + } catch (e) { + this.logger.warn( + `Failed to add or update group policy ${groupPolicy} after modification ${this.csvFileName}. Cause: ${e}`, + ); + } + } + this.csvFilePolicies.addedGroupPolicies = []; + } + + /** + * removeRoles will remove the roles that are no longer present in the CSV file. + * If the role exists with multiple groups and or users, we will update it role information. + * Otherwise, we will remove the role completely. + */ + async removeRoles(): Promise { + for (const groupPolicy of this.csvFilePolicies.removedGroupPolicies) { + // this requires knowledge of whether or not it is an update + const isUpdate = await this.enforcer.getFilteredGroupingPolicy( + 1, + groupPolicy[1], + ); + + // Need to update the time + try { + await this.enforcer.removeGroupingPolicy( + groupPolicy, + { + source: 'csv-file', + roleEntityRef: groupPolicy[1], + author: CSV_PERMISSION_POLICY_FILE_AUTHOR, + modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, + }, + isUpdate.length > 1, + true, + ); + } catch (e) { + this.logger.warn( + `Failed to remove group policy ${groupPolicy} after modification ${this.csvFileName}. Cause: ${e}`, + ); + } + } + this.csvFilePolicies.removedGroupPolicies = []; + } +} diff --git a/plugins/rbac-backend/src/file-permissions/csv.test.ts b/plugins/rbac-backend/src/file-permissions/csv.test.ts deleted file mode 100644 index 21fdb30d19..0000000000 --- a/plugins/rbac-backend/src/file-permissions/csv.test.ts +++ /dev/null @@ -1,1021 +0,0 @@ -import { mockServices } from '@backstage/backend-test-utils'; -import { ConfigReader } from '@backstage/config'; - -import { - Adapter, - Enforcer, - FileAdapter, - Model, - newEnforcer, - newModelFromString, - StringAdapter, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; -import { isEqual } from 'lodash'; -import { Logger } from 'winston'; - -import { - PermissionPolicyMetadata, - RoleMetadata, - Source, -} from '@janus-idp/backstage-plugin-rbac-common'; - -import { resolve } from 'path'; - -import { - PermissionPolicyMetadataDao, - PolicyMetadataStorage, -} from '../database/policy-metadata-storage'; -import { RoleMetadataStorage } from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { - addPermissionPoliciesFileData, - loadFilteredGroupingPoliciesFromCSV, - loadFilteredPoliciesFromCSV, -} from './csv'; - -const catalogApi = { - getEntityAncestors: jest.fn().mockImplementation(), - getLocationById: jest.fn().mockImplementation(), - getEntities: jest.fn().mockImplementation(), - getEntitiesByRefs: jest.fn().mockImplementation(), - queryEntities: jest.fn().mockImplementation(), - getEntityByRef: jest.fn().mockImplementation(), - refreshEntity: jest.fn().mockImplementation(), - getEntityFacets: jest.fn().mockImplementation(), - addLocation: jest.fn().mockImplementation(), - getLocationByRef: jest.fn().mockImplementation(), - removeLocationById: jest.fn().mockImplementation(), - removeEntityByUid: jest.fn().mockImplementation(), - validateEntity: jest.fn().mockImplementation(), - getLocationByEntity: 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( - async ( - _policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - const test: PermissionPolicyMetadata = { - source: 'csv-file', - }; - return test; - }, - ), - createPolicyMetadata: jest.fn().mockImplementation(), - removePolicyMetadata: jest.fn().mockImplementation(), -}; - -const loggerMock: any = { - warn: jest.fn().mockImplementation(), - debug: jest.fn().mockImplementation(), -}; - -const mockAuthService = mockServices.auth(); - -async function createEnforcer( - theModel: Model, - adapter: Adapter, - log: Logger, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const config = newConfigReader(); - - const rm = new BackstageRoleManager( - catalogApi, - log, - catalogDBClient, - config, - mockAuthService, - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} - -describe('CSV file', () => { - let enfAddPolicySpy: jest.SpyInstance, string[], any>; - let enfRemovePolicySpy: jest.SpyInstance, string[], any>; - let enfAddGroupingSpy: jest.SpyInstance, string[], any>; - let enfRemoveGroupingSpy: jest.SpyInstance, string[], any>; - - const policyFilter: string[] = [ - 'user:default/guest', - 'catalog.entity.create', - 'use', - ]; - - describe('Loading filtered policies from a CSV file', () => { - let csvPermFile: string; - let enf: Enforcer; - let enfDelegate: EnforcerDelegate; - let knex: Knex.Knex; - beforeEach(async () => { - loggerMock.warn = jest.fn().mockImplementation(); - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - _policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - const test: PermissionPolicyMetadata = { - source: 'csv-file', - }; - return test; - }, - ); - - csvPermFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - const adapter = new FileAdapter(csvPermFile); - - const stringModel = newModelFromString(MODEL); - enf = await createEnforcer(stringModel, adapter, loggerMock); - - knex = Knex.knex({ client: MockClient }); - - enfDelegate = new EnforcerDelegate( - enf, - policyMetadataStorageMock, - roleMetadataStorageMock, - knex, - ); - - enfAddPolicySpy = jest.spyOn(enf, 'addPolicy'); - enfRemovePolicySpy = jest.spyOn(enf, 'removePolicy'); - enfAddGroupingSpy = jest.spyOn(enf, 'addGroupingPolicy'); - enfRemoveGroupingSpy = jest.spyOn(enf, 'removeGroupingPolicy'); - }); - - afterEach(() => { - (loggerMock.warn as jest.Mock).mockReset(); - (policyMetadataStorageMock.findPolicyMetadata as jest.Mock).mockReset(); - }); - - it('should update a policy that has changed in the file (allow -> deny)', async () => { - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const updatedPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'deny', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - expect(await enfDelegate.hasPolicy(...updatedPolicy)).toBe(false); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddPolicySpy).toHaveBeenCalledWith(...updatedPolicy); - expect(enfRemovePolicySpy).toHaveBeenCalledWith(...originalPolicy); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(false); - expect(await enfDelegate.hasPolicy(...updatedPolicy)).toBe(true); - }); - - it('should update a policy that has changed in the file (deny -> allow)', async () => { - const tempFilter: string[] = [ - policyFilter[0], - 'catalog-entity', - 'delete', - ]; - const originalPolicy = [ - 'role:default/catalog-deleter', - 'catalog-entity', - 'delete', - 'deny', - ]; - const updatedPolicy = [ - 'role:default/catalog-deleter', - 'catalog-entity', - 'delete', - 'allow', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - expect(await enfDelegate.hasPolicy(...updatedPolicy)).toBe(false); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - tempFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddPolicySpy).toHaveBeenCalledWith(...updatedPolicy); - expect(enfRemovePolicySpy).toHaveBeenCalledWith(...originalPolicy); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(false); - expect(await enfDelegate.hasPolicy(...updatedPolicy)).toBe(true); - }); - - it('should add a policy that is new in the file', async () => { - const tempFilter: string[] = [ - policyFilter[0], - 'catalog.entity.delete', - 'delete', - ]; - const newPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.delete', - 'delete', - 'allow', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...newPolicy)).toBe(false); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - tempFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddPolicySpy).toHaveBeenCalledWith(...newPolicy); - - expect(await enfDelegate.hasPolicy(...newPolicy)).toBe(true); - }); - - it('should remove a policy that is no longer in the file', async () => { - const tempFilter: string[] = [policyFilter[0], 'catalog-entity', 'read']; - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog-entity', - 'read', - 'allow', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - tempFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfRemovePolicySpy).toHaveBeenCalledWith(...originalPolicy); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(false); - }); - - it('should do nothing if there is no change', async () => { - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const originalPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - - await loadFilteredPoliciesFromCSV( - originalPolicyFile, - enfDelegate, - policyFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddPolicySpy).toHaveBeenCalledTimes(0); - expect(enfRemovePolicySpy).toHaveBeenCalledTimes(0); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - }); - - // Validation tests - it('should fail to update a policy that has changed in the file, entityRef error', async () => { - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const updatedPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'deny', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/permission-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - expect(await enfDelegate.hasPolicy(...updatedPolicy)).toBe(false); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter, - loggerMock, - policyMetadataStorageMock, - ); - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate policy from file ${updatedPolicyFile}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to update a policy that has changed in the file, duplicate error', async () => { - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/duplicate-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 1, - `Duplicate policy: ${originalPolicy} found in the file ${updatedPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 2, - `Duplicate policy: ${originalPolicy} found in the file ${updatedPolicyFile}`, - ); - }); - - it('should fail to update a policy that has changed in the file, duplicate error different actions', async () => { - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/duplicate-policies-actions.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 1, - `Duplicate policy: ${originalPolicy.at(0)}, ${originalPolicy.at( - 1, - )}, ${originalPolicy.at( - 2, - )} with different actions found with the source csv-file`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 2, - `Duplicate policy: ${originalPolicy.at(0)}, ${originalPolicy.at( - 1, - )}, ${originalPolicy.at( - 2, - )} with different actions found with the source csv-file`, - ); - }); - - it('should fail to update a policy that has changed in the file, duplicate error different source', async () => { - const tempFilter: string[] = [ - policyFilter[0], - 'catalog-entity', - 'delete', - ]; - const originalPolicy = [ - 'role:default/catalog-writer', - 'catalog-entity', - 'delete', - 'allow', - ]; - - await enfDelegate.addPolicy(originalPolicy, 'rest'); - - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - if (isEqual(policy, originalPolicy)) { - return { source: 'rest' }; - } - return { source: 'csv-file' }; - }, - ); - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/duplicate-policy.csv', - ); - - expect(await enfDelegate.hasPolicy(...originalPolicy)).toBe(true); - - await loadFilteredPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - tempFilter, - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenCalledWith( - `Duplicate policy: ${originalPolicy.at(0)}, ${originalPolicy.at( - 1, - )}, ${originalPolicy.at(2)} found with the source rest`, - ); - }); - }); - - describe('Loading filtered grouping policies from a CSV file', () => { - let csvPermFile: string; - let enf: Enforcer; - let enfDelegate: EnforcerDelegate; - beforeEach(async () => { - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - _policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - const test: PermissionPolicyMetadata = { - source: 'csv-file', - }; - return test; - }, - ); - - csvPermFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - const adapter = new FileAdapter(csvPermFile); - - const stringModel = newModelFromString(MODEL); - enf = await createEnforcer(stringModel, adapter, loggerMock); - - const knex = Knex.knex({ client: MockClient }); - - enfDelegate = new EnforcerDelegate( - enf, - policyMetadataStorageMock, - roleMetadataStorageMock, - knex, - ); - - enfAddPolicySpy = jest.spyOn(enf, 'addPolicy'); - enfRemovePolicySpy = jest.spyOn(enf, 'removePolicy'); - enfAddGroupingSpy = jest.spyOn(enf, 'addGroupingPolicy'); - enfRemoveGroupingSpy = jest.spyOn(enf, 'removeGroupingPolicy'); - }); - - afterEach(() => { - (loggerMock.warn as jest.Mock).mockReset(); - (policyMetadataStorageMock.findPolicyMetadata as jest.Mock).mockReset(); - }); - - it('should update a policy that has changed in the file', async () => { - const originalPolicy = [ - 'user:default/guest', - 'role:default/catalog-reader', - ]; - const updatedPolicy = [ - 'user:default/guest', - 'role:default/catalog-updater', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe(true); - expect(await enfDelegate.hasGroupingPolicy(...updatedPolicy)).toBe(false); - - await loadFilteredGroupingPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddGroupingSpy).toHaveBeenCalledWith(...updatedPolicy); - expect(enfRemoveGroupingSpy).toHaveBeenCalledWith(...originalPolicy); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe( - false, - ); - expect(await enfDelegate.hasGroupingPolicy(...updatedPolicy)).toBe(true); - }); - - it('should add a role that is new in the file', async () => { - const newPolicy = ['user:default/guest', 'role:default/catalog-tester']; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...newPolicy)).toBe(false); - - await loadFilteredGroupingPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddGroupingSpy).toHaveBeenCalledWith(...newPolicy); - - expect(await enfDelegate.hasGroupingPolicy(...newPolicy)).toBe(true); - }); - - it('should remove a policy that is no longer in the file', async () => { - const originalPolicy = [ - 'user:default/guest', - 'role:default/catalog-deleter', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/updated-rbac-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe(true); - - await loadFilteredGroupingPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfRemoveGroupingSpy).toHaveBeenCalledWith(...originalPolicy); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe( - false, - ); - }); - - it('should do nothing if there is no change', async () => { - const originalPolicy = [ - 'user:default/guest', - 'role:default/catalog-writer', - ]; - const originalPolicyFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe(true); - - await loadFilteredGroupingPoliciesFromCSV( - originalPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(enfAddGroupingSpy).toHaveBeenCalledTimes(0); - expect(enfRemoveGroupingSpy).toHaveBeenCalledTimes(0); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe(true); - }); - - // Validation tests - it('should fail to update a policy that has changed in the file, user entityRef error', async () => { - const originalPolicy = [ - 'user:default/guest', - 'role:default/catalog-deleter', - ]; - const updatedPolicy = [ - 'user:default/test', - 'role:default/catalog-deleter', - ]; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/entityref-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...originalPolicy)).toBe(true); - expect(await enfDelegate.hasGroupingPolicy(...updatedPolicy)).toBe(false); - - await loadFilteredGroupingPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate role from file ${updatedPolicyFile}. Cause: Entity reference "user:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to update a policy that has changed in the file, role entityRef error', async () => { - const newUser = 'user:default/test'; - const newPolicy = ['user:default/test', 'role:default/catalog-reader']; - - const updatedPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/role-entityref-policy.csv', - ); - - expect(await enfDelegate.hasGroupingPolicy(...newPolicy)).toBe(false); - - await loadFilteredGroupingPoliciesFromCSV( - updatedPolicyFile, - enfDelegate, - newUser, - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate role from file ${updatedPolicyFile}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to update a policy that has changed in the file, duplicate error with and without different sources', async () => { - const duplicateCSV = [ - 'user:default/guest', - 'role:default/catalog-deleter', - ]; - const duplicateRest = [ - 'user:default/guest', - 'role:default/catalog-updater', - ]; - - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - if (isEqual(policy, duplicateRest)) { - return { source: 'rest' }; - } - return { source: 'csv-file' }; - }, - ); - - await enfDelegate.addGroupingPolicy(duplicateRest, { - source: 'rest', - roleEntityRef: duplicateRest[1], - }); - - const errorPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/duplicate-policy.csv', - ); - - await loadFilteredGroupingPoliciesFromCSV( - errorPolicyFile, - enfDelegate, - policyFilter[0], - loggerMock, - policyMetadataStorageMock, - ); - - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 1, - `Duplicate role: ${duplicateCSV} found in the file ${errorPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 2, - `Duplicate role: ${duplicateCSV} found in the file ${errorPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 3, - `Duplicate role: ${duplicateRest[0]}, ${duplicateRest[1]} found with the source rest`, - ); - }); - }); - - describe('Loading policies from a CSV file', () => { - let csvPermFile: string; - let enf: Enforcer; - let enfDelegate: EnforcerDelegate; - - beforeEach(async () => { - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - _policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - return { source: 'csv-file' }; - }, - ); - - const adapter = new StringAdapter( - ` - p, user:default/known_user, test.resource.deny, use, allow - `, - ); - csvPermFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - - const stringModel = newModelFromString(MODEL); - enf = await createEnforcer(stringModel, adapter, loggerMock); - - const knex = Knex.knex({ client: MockClient }); - - enfDelegate = new EnforcerDelegate( - enf, - policyMetadataStorageMock, - roleMetadataStorageMock, - knex, - ); - }); - - afterEach(() => { - (loggerMock.warn as jest.Mock).mockReset(); - (policyMetadataStorageMock.findPolicyMetadata as jest.Mock).mockReset(); - }); - - it('should add policies from the CSV file', async () => { - const test = [ - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ]; - await addPermissionPoliciesFileData( - csvPermFile, - enfDelegate, - roleMetadataStorageMock, - loggerMock, - ); - - expect(await enfDelegate.hasPolicy(...test)).toBe(true); - }); - - // Validation tests - it('should fail to add policies from the CSV file, user entityRef group error', async () => { - const errorPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/entityref-policy.csv', - ); - - await addPermissionPoliciesFileData( - errorPolicyFile, - enfDelegate, - roleMetadataStorageMock, - loggerMock, - ); - - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate group policy user:default/,role:default/catalog-deleter from file ${errorPolicyFile}. Cause: Entity reference "user:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to add policies from the CSV file, role entityRef group error', async () => { - const errorPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/role-entityref-policy.csv', - ); - - await addPermissionPoliciesFileData( - errorPolicyFile, - enfDelegate, - roleMetadataStorageMock, - loggerMock, - ); - - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate group policy user:default/test,role:default/ from file ${errorPolicyFile}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to add policies from the CSV file, role entityRef permission policy error', async () => { - const errorPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/permission-policy.csv', - ); - - await addPermissionPoliciesFileData( - errorPolicyFile, - enfDelegate, - roleMetadataStorageMock, - loggerMock, - ); - expect(loggerMock.warn).toHaveBeenCalledWith( - `Failed to validate policy from file ${errorPolicyFile}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - }); - - it('should fail to add policies from the CSV file, duplicate permission policies in CSV and in enforcer', async () => { - const duplicatePolicyCSV = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - - const duplicateRoleCSV = [ - 'user:default/guest', - 'role:default/catalog-deleter', - ]; - - const duplicatePolicyEnforcer = [ - 'role:default/catalog-writer', - 'catalog-entity', - 'delete', - 'allow', - ]; - - const duplicateRoleEnforcer = [ - 'user:default/guest', - 'role:default/catalog-updater', - ]; - - policyMetadataStorageMock.findPolicyMetadata = jest - .fn() - .mockImplementation( - async ( - policy: string[], - _trx: Knex.Knex.Transaction, - ): Promise => { - if ( - isEqual(policy, duplicatePolicyEnforcer) || - isEqual(policy, duplicateRoleEnforcer) - ) { - return { source: 'rest' }; - } - return { source: 'csv-file' }; - }, - ); - - await enfDelegate.addPolicy(duplicatePolicyEnforcer, 'rest'); - await enfDelegate.addGroupingPolicy(duplicateRoleEnforcer, { - source: 'rest', - roleEntityRef: duplicateRoleEnforcer[1], - }); - - const errorPolicyFile = resolve( - __dirname, - './../__fixtures__/data/invalid-csv/duplicate-policy.csv', - ); - - await addPermissionPoliciesFileData( - errorPolicyFile, - enfDelegate, - roleMetadataStorageMock, - loggerMock, - ); - - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 1, - `Duplicate policy: ${duplicatePolicyEnforcer} found in the file ${errorPolicyFile}, originates from source: rest`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 2, - `Duplicate policy: ${duplicatePolicyCSV} found in the file ${errorPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 3, - `Duplicate policy: ${duplicatePolicyCSV} found in the file ${errorPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 4, - `Duplicate role: ${duplicateRoleCSV} found in the file ${errorPolicyFile}`, - ); - expect(loggerMock.warn).toHaveBeenNthCalledWith( - 5, - `Duplicate role: ${duplicateRoleCSV} found in the file ${errorPolicyFile}`, - ); - }); - }); -}); - -function newConfigReader( - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, -): ConfigReader { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - return new ConfigReader({ - permission: { - rbac: { - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }); -} diff --git a/plugins/rbac-backend/src/file-permissions/csv.ts b/plugins/rbac-backend/src/file-permissions/csv.ts deleted file mode 100644 index c5bbec6c69..0000000000 --- a/plugins/rbac-backend/src/file-permissions/csv.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { Enforcer, FileAdapter, newEnforcer, newModelFromString } from 'casbin'; -import { isEqual } from 'lodash'; -import { Logger } from 'winston'; - -import { PolicyMetadataStorage } from '../database/policy-metadata-storage'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { metadataStringToPolicy, transformArrayToPolicy } from '../helper'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { - checkForDuplicateGroupPolicies, - checkForDuplicatePolicies, - validateAllPredefinedPolicies, - validateEntityReference, - validatePolicy, -} from '../service/policies-validation'; - -export const CSV_PERMISSION_POLICY_FILE_AUTHOR = 'csv permission policy file'; - -const addPolicy = async ( - policy: string[], - enf: EnforcerDelegate, - policyMetadataStorage: PolicyMetadataStorage, - logger: Logger, -): Promise => { - const source = await policyMetadataStorage?.findPolicyMetadata(policy); - - if (!(await enf.hasPolicy(...policy))) { - await enf.addPolicy(policy, 'csv-file'); - } else if (source?.source !== 'csv-file') { - logger.warn( - `Duplicate policy: ${policy[0]}, ${policy[1]}, ${policy[2]} found with the source ${source?.source}`, - ); - } -}; - -const removeEnforcerPolicies = async ( - enforcerPolicies: string[][], - tempEnf: Enforcer, - enf: EnforcerDelegate, - policyMetadataStorage: PolicyMetadataStorage, -): Promise => { - for (const policy of enforcerPolicies) { - const enfPolicySource = - await policyMetadataStorage?.findPolicyMetadata(policy); - if ( - !(await tempEnf.hasPolicy(...policy)) && - enfPolicySource?.source === 'csv-file' - ) { - await enf.removePolicy(policy, 'csv-file', true); - } - } -}; - -const catchRoleIssues = ( - roleIssues: string[][], - policyFile: string, - logger: Logger, -): void => { - for (const role of roleIssues) { - const err = validateEntityReference(role[0]); - if (err) { - logger.warn( - `Failed to validate role from file ${policyFile}. Cause: ${err.message}`, - ); - } - } -}; - -export const loadFilteredPoliciesFromCSV = async ( - policyFile: string, - enf: EnforcerDelegate, - policyFilter: string[], - logger: Logger, - policyMetadataStorage: PolicyMetadataStorage, - fileEnf?: Enforcer, -) => { - const tempEnforcer = - fileEnf ?? - (await newEnforcer(newModelFromString(MODEL), new FileAdapter(policyFile))); - - const roles = await enf.getFilteredGroupingPolicy(0, policyFilter[0]); - - const policies: string[][] = []; - let enforcerPolicies: string[][] = []; - - for (const role of roles) { - const policyByRole = await tempEnforcer.getFilteredPolicy( - 0, - role[1], - policyFilter[1], - policyFilter[2], - ); - policies.push(...policyByRole); - const enforcerPolicy = await enf.getFilteredPolicy( - 0, - role[1], - policyFilter[1], - policyFilter[2], - ); - enforcerPolicies.push(...enforcerPolicy); - } - - if (isEqual(policies, enforcerPolicies)) { - return; - } - - if (policies.length === 0) { - policies.push( - ...(await tempEnforcer.getFilteredPolicy( - 1, - policyFilter[1], - policyFilter[2], - )), - ); - } - - await removeEnforcerPolicies( - enforcerPolicies, - tempEnforcer, - enf, - policyMetadataStorage, - ); - - for (const policy of policies) { - const err = validatePolicy(transformArrayToPolicy(policy)); - if (err) { - logger.warn( - `Failed to validate policy from file ${policyFile}. Cause: ${err.message}`, - ); - continue; - } - - const duplicateError = await checkForDuplicatePolicies( - tempEnforcer, - policy, - policyFile, - ); - if (duplicateError) { - logger.warn(duplicateError.message); - } - - const effectFlipPolicy = [ - policy[0], - policy[1], - policy[2], - policy[3] === 'deny' ? 'allow' : 'deny', - ]; - - const flipSource = - await policyMetadataStorage?.findPolicyMetadata(effectFlipPolicy); - const isDupFlipPolicy = await enf.hasPolicy(...effectFlipPolicy); - const isFileFlipPolicy = await tempEnforcer.hasPolicy(...effectFlipPolicy); - const isCSVSource = flipSource?.source === 'csv-file'; - - if ( - (isDupFlipPolicy && !isCSVSource) || - (isFileFlipPolicy && isCSVSource) - ) { - logger.warn( - `Duplicate policy: ${policy[0]}, ${policy[1]}, ${policy[2]} with different actions found with the source ${flipSource?.source}`, - ); - continue; - } - - if (isDupFlipPolicy && isCSVSource) { - await enf.removePolicy(effectFlipPolicy, 'csv-file', true); - - enforcerPolicies = enforcerPolicies.filter( - policyCheck => !isEqual(policyCheck, effectFlipPolicy), - ); - } - - await addPolicy(policy, enf, policyMetadataStorage, logger); - } -}; - -export const loadFilteredGroupingPoliciesFromCSV = async ( - policyFile: string, - enf: EnforcerDelegate, - entityRef: string, - logger: Logger, - policyMetadataStorage: PolicyMetadataStorage, - fileEnf?: Enforcer, -) => { - const tempEnforcer = - fileEnf ?? - (await newEnforcer(newModelFromString(MODEL), new FileAdapter(policyFile))); - const roleIssues: string[][] = []; - - const roles = await tempEnforcer.getFilteredGroupingPolicy(0, entityRef); - const enforcerRoles = await enf.getFilteredGroupingPolicy(0, entityRef); - - if (isEqual(roles, enforcerRoles)) { - return; - } - - for (const role of roles) { - const duplicateError = await checkForDuplicateGroupPolicies( - tempEnforcer, - role, - policyFile, - ); - - if (duplicateError) { - logger.warn(duplicateError.message); - } - - const roleEntityRef = role[1]; - const err = validateEntityReference(roleEntityRef); - if (err) { - logger.warn( - `Failed to validate role from file ${policyFile}. Cause: ${err.message}`, - ); - continue; - } - - const roleSource = await policyMetadataStorage?.findPolicyMetadata(role); - - // Role exists in the file but not the enforcer - if (!(await enf.hasGroupingPolicy(...role))) { - await enf.addOrUpdateGroupingPolicy(role, { - roleEntityRef, - source: 'csv-file', - author: CSV_PERMISSION_POLICY_FILE_AUTHOR, - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - }); - } else if (roleSource?.source !== 'csv-file') { - logger.warn( - `Duplicate role: ${role[0]}, ${role[1]} found with the source ${roleSource?.source}`, - ); - continue; - } - } - - for (const role of enforcerRoles) { - // This is to catch stray issues with roles that have problems with their users - roleIssues.push( - ...(await tempEnforcer.getFilteredGroupingPolicy(1, role[1])), - ); - - // Role exists in the enforcer but not the file - const enfRoleSource = await policyMetadataStorage?.findPolicyMetadata(role); - - if ( - !(await tempEnforcer.hasGroupingPolicy(...role)) && - enfRoleSource?.source === 'csv-file' - ) { - await enf.removeGroupingPolicy( - role, - { - roleEntityRef: role[1], - source: 'csv-file', - author: CSV_PERMISSION_POLICY_FILE_AUTHOR, - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - }, - true, - true, - ); - } - } - - // Role Issues was meant to catch things with messed up users, - catchRoleIssues(roleIssues, policyFile, logger); -}; - -export const loadFilteredCSV = async ( - policyFile: string, - enf: EnforcerDelegate, - policyFilter: string[], - logger: Logger, - policyMetadataStorage: PolicyMetadataStorage, -) => { - const fileEnf = await newEnforcer( - newModelFromString(MODEL), - new FileAdapter(policyFile), - ); - - await loadFilteredPoliciesFromCSV( - policyFile, - enf, - policyFilter, - logger, - policyMetadataStorage, - fileEnf, - ); - await loadFilteredGroupingPoliciesFromCSV( - policyFile, - enf, - policyFilter[0], - logger, - policyMetadataStorage, - fileEnf, - ); -}; - -export 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', - CSV_PERMISSION_POLICY_FILE_AUTHOR, - true, - ); - } - if (policiesToDelete.length > 0) { - await enf.removePolicies(policiesToDelete, 'csv-file', true); - } -}; - -export const addPermissionPoliciesFileData = async ( - preDefinedPoliciesFile: string, - enf: EnforcerDelegate, - roleMetadataStorage: RoleMetadataStorage, - logger: Logger, -) => { - const fileEnf = await newEnforcer( - newModelFromString(MODEL), - new FileAdapter(preDefinedPoliciesFile), - ); - const policies = await fileEnf.getPolicy(); - const groupPolicies = await fileEnf.getGroupingPolicy(); - - const validationError = await validateAllPredefinedPolicies( - policies, - groupPolicies, - preDefinedPoliciesFile, - roleMetadataStorage, - enf, - ); - if (validationError) { - logger.warn(validationError.message); - } - - await removedOldPermissionPoliciesFileData(enf, fileEnf); - - for (const policy of policies) { - const duplicateError = await checkForDuplicatePolicies( - fileEnf, - policy, - preDefinedPoliciesFile, - ); - if (duplicateError) { - logger.warn(duplicateError.message); - } - await enf.addOrUpdatePolicy(policy, 'csv-file', true); - } - - for (const groupPolicy of groupPolicies) { - const duplicateError = await checkForDuplicateGroupPolicies( - fileEnf, - groupPolicy, - preDefinedPoliciesFile, - ); - - if (duplicateError) { - logger.warn(duplicateError.message); - } - const metadata: RoleMetadataDao = { - roleEntityRef: groupPolicy[1], - source: 'csv-file', - author: CSV_PERMISSION_POLICY_FILE_AUTHOR, - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - }; - await enf.addOrUpdateGroupingPolicy(groupPolicy, metadata, false); - } -}; diff --git a/plugins/rbac-backend/src/role-manager/role-manager.ts b/plugins/rbac-backend/src/role-manager/role-manager.ts index f1fea91f58..147e016b0b 100644 --- a/plugins/rbac-backend/src/role-manager/role-manager.ts +++ b/plugins/rbac-backend/src/role-manager/role-manager.ts @@ -188,7 +188,6 @@ export class BackstageRoleManager implements RoleManager { this.auth, this.maxDepth, ); - await memo.getAllGroups(); await memo.buildUserGraph(memo); memo.debugNodesAndEdges(this.log, name); const userAndParentGroups = memo.getNodes(); diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.test.ts b/plugins/rbac-backend/src/service/enforcer-delegate.test.ts index 2a187673d8..101f98d910 100644 --- a/plugins/rbac-backend/src/service/enforcer-delegate.test.ts +++ b/plugins/rbac-backend/src/service/enforcer-delegate.test.ts @@ -21,7 +21,7 @@ import { RoleMetadataDao, RoleMetadataStorage, } from '../database/role-metadata'; -import { CSV_PERMISSION_POLICY_FILE_AUTHOR } from '../file-permissions/csv'; +import { CSV_PERMISSION_POLICY_FILE_AUTHOR } from '../file-permissions/csv-file-watcher'; import { policyToString } from '../helper'; import { BackstageRoleManager } from '../role-manager/role-manager'; import { EnforcerDelegate } from './enforcer-delegate'; diff --git a/plugins/rbac-backend/src/service/permission-policy.test.ts b/plugins/rbac-backend/src/service/permission-policy.test.ts index db054b1200..b5a4ec31e7 100644 --- a/plugins/rbac-backend/src/service/permission-policy.test.ts +++ b/plugins/rbac-backend/src/service/permission-policy.test.ts @@ -850,7 +850,6 @@ describe('RBACPermissionPolicy Tests', () => { config, enfDelegate, roleMetadataStorageTest, - policyMetadataStorageTest, ); catalogApi.getEntities.mockReturnValue({ items: [] }); @@ -1123,7 +1122,6 @@ describe('RBACPermissionPolicy Tests', () => { config, enfDelegate, roleMetadataStorageTest, - policyMetadataStorageTest, ); }); @@ -1214,18 +1212,6 @@ describe('Policy checks for resourced permissions defined by name', () => { 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; let policy: RBACPermissionPolicy; @@ -1237,7 +1223,6 @@ describe('Policy checks for resourced permissions defined by name', () => { config, enfDelegate, roleMetadataStorageTest, - policyMetadataStorageTest, ); }); @@ -1813,7 +1798,6 @@ describe('Policy checks for conditional policies', () => { conditionalStorage, enfDelegate, roleMetadataStorageMock, - policyMetadataStorageMock, knex, ); @@ -2170,7 +2154,6 @@ async function newPermissionPolicy( config: ConfigReader, enfDelegate: EnforcerDelegate, roleMock?: RoleMetadataStorage, - policyMock?: PolicyMetadataStorage, ): Promise { const logger = getVoidLogger(); return await RBACPermissionPolicy.build( @@ -2179,7 +2162,6 @@ async function newPermissionPolicy( conditionalStorage, enfDelegate, roleMock || roleMetadataStorageMock, - policyMock || policyMetadataStorageMock, knex, ); } diff --git a/plugins/rbac-backend/src/service/permission-policy.ts b/plugins/rbac-backend/src/service/permission-policy.ts index 449f267527..cbe6655794 100644 --- a/plugins/rbac-backend/src/service/permission-policy.ts +++ b/plugins/rbac-backend/src/service/permission-policy.ts @@ -23,16 +23,11 @@ import { } from '@janus-idp/backstage-plugin-rbac-common'; import { ConditionalStorage } from '../database/conditional-storage'; -import { PolicyMetadataStorage } from '../database/policy-metadata-storage'; import { RoleMetadataDao, RoleMetadataStorage, } from '../database/role-metadata'; -import { - addPermissionPoliciesFileData, - loadFilteredCSV, - removedOldPermissionPoliciesFileData, -} from '../file-permissions/csv'; +import { CSVFileWatcher } from '../file-permissions/csv-file-watcher'; import { metadataStringToPolicy, removeTheDifference } from '../helper'; import { EnforcerDelegate } from './enforcer-delegate'; import { validateEntityReference } from './policies-validation'; @@ -169,9 +164,6 @@ export class RBACPermissionPolicy implements PermissionPolicy { private readonly enforcer: EnforcerDelegate; private readonly logger: Logger; private readonly conditionStorage: ConditionalStorage; - private readonly policyMetadataStorage: PolicyMetadataStorage; - private readonly policiesFile?: string; - private readonly allowReload?: boolean; private readonly superUserList?: string[]; public static async build( @@ -180,7 +172,6 @@ export class RBACPermissionPolicy implements PermissionPolicy { conditionalStorage: ConditionalStorage, enforcerDelegate: EnforcerDelegate, roleMetadataStorage: RoleMetadataStorage, - policyMetaDataStorage: PolicyMetadataStorage, knex: Knex, ): Promise { const superUserList: string[] = []; @@ -196,9 +187,8 @@ export class RBACPermissionPolicy implements PermissionPolicy { 'permission.rbac.policies-csv-file', ); - const allowReload = configApi.getOptionalBoolean( - 'permission.rbac.policyFileReload', - ); + const allowReload = + configApi.getOptionalBoolean('permission.rbac.policyFileReload') || false; if (superUsers && superUsers.length > 0) { for (const user of superUsers) { @@ -224,24 +214,17 @@ export class RBACPermissionPolicy implements PermissionPolicy { ); } - if (policiesFile) { - await addPermissionPoliciesFileData( - policiesFile, - enforcerDelegate, - roleMetadataStorage, - logger, - ); - } else { - await removedOldPermissionPoliciesFileData(enforcerDelegate); - } + const csvFile = new CSVFileWatcher( + enforcerDelegate, + logger, + roleMetadataStorage, + ); + await csvFile.initialize(policiesFile, allowReload); return new RBACPermissionPolicy( enforcerDelegate, logger, conditionalStorage, - policyMetaDataStorage, - policiesFile, - allowReload, superUserList, ); } @@ -250,17 +233,11 @@ export class RBACPermissionPolicy implements PermissionPolicy { enforcer: EnforcerDelegate, logger: Logger, conditionStorage: ConditionalStorage, - policyMetadataStorage: PolicyMetadataStorage, - policiesFile?: string, - allowReload?: boolean, superUserList?: string[], ) { this.enforcer = enforcer; this.logger = logger; this.conditionStorage = conditionStorage; - this.policyMetadataStorage = policyMetadataStorage; - this.policiesFile = policiesFile; - this.allowReload = allowReload; this.superUserList = superUserList; } @@ -370,17 +347,6 @@ export class RBACPermissionPolicy implements PermissionPolicy { return true; } - const filter: string[] = [userIdentity, permission, action]; - if (this.policiesFile && this.allowReload) { - await loadFilteredCSV( - this.policiesFile, - this.enforcer, - filter, - this.logger, - this.policyMetadataStorage, - ); - } - return await this.enforcer.enforce(userIdentity, permission, action, roles); }; 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 90705e1e5c..04e4476a35 100644 --- a/plugins/rbac-backend/src/service/policies-rest-api.test.ts +++ b/plugins/rbac-backend/src/service/policies-rest-api.test.ts @@ -24,7 +24,6 @@ import { Source, } from '@janus-idp/backstage-plugin-rbac-common'; -import { PolicyMetadataStorage } from '../database/policy-metadata-storage'; import { RoleMetadataDao, RoleMetadataStorage, @@ -139,13 +138,6 @@ const roleMetadataStorageMock: RoleMetadataStorage = { removeRoleMetadata: jest.fn().mockImplementation(), }; -const policyMetadataStorageMock: PolicyMetadataStorage = { - findPolicyMetadataBySource: jest.fn().mockImplementation(), - findPolicyMetadata: jest.fn().mockImplementation(), - createPolicyMetadata: jest.fn().mockImplementation(), - removePolicyMetadata: jest.fn().mockImplementation(), -}; - const conditionalStorage = { filterConditions: jest.fn().mockImplementation(), createCondition: jest.fn().mockImplementation(), @@ -299,7 +291,6 @@ describe('REST policies api', () => { conditionalStorage, mockEnforcer as EnforcerDelegate, roleMetadataStorageMock, - policyMetadataStorageMock, knex, ), }; diff --git a/plugins/rbac-backend/src/service/policies-validation.ts b/plugins/rbac-backend/src/service/policies-validation.ts index 074707e96e..ba8e2536b8 100644 --- a/plugins/rbac-backend/src/service/policies-validation.ts +++ b/plugins/rbac-backend/src/service/policies-validation.ts @@ -111,7 +111,7 @@ export function validateEntityReference( return undefined; } -async function validateGroupingPolicy( +export async function validateGroupingPolicy( groupPolicy: string[], preDefinedPoliciesFile: string, roleMetadataStorage: RoleMetadataStorage, @@ -219,6 +219,26 @@ export const checkForDuplicatePolicies = async ( `Duplicate policy: ${policy} found in the file ${policyFile}`, ); } + + const flipPolicyEffect = [ + policy[0], + policy[1], + policy[2], + policy[3] === 'deny' ? 'allow' : 'deny', + ]; + + // Check if the same policy exists but with a different effect + const dupWithDifferentEffect = await fileEnf.getFilteredPolicy( + 0, + ...flipPolicyEffect, + ); + + if (dupWithDifferentEffect.length > 0) { + return new Error( + `Duplicate policy: ${policy[0]}, ${policy[1]}, ${policy[2]} with different effect found in the file ${policyFile}`, + ); + } + return undefined; }; diff --git a/plugins/rbac-backend/src/service/policy-builder.ts b/plugins/rbac-backend/src/service/policy-builder.ts index d43de012e6..a8f78a027b 100644 --- a/plugins/rbac-backend/src/service/policy-builder.ts +++ b/plugins/rbac-backend/src/service/policy-builder.ts @@ -104,7 +104,6 @@ export class PolicyBuilder { conditionStorage, enforcerDelegate, roleMetadataStorage, - policyMetadataStorage, knex, ), auth: auth, diff --git a/yarn.lock b/yarn.lock index 86846a609d..3d5b163743 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18478,6 +18478,11 @@ csv-parse@^5.3.5: resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.5.tgz#68a271a9092877b830541805e14c8a80e6a22517" integrity sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ== +csv-parse@^5.5.5: + version "5.5.5" + resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.5.tgz#68a271a9092877b830541805e14c8a80e6a22517" + integrity sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ== + ctrlc-windows@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ctrlc-windows/-/ctrlc-windows-2.1.0.tgz#f2096a96ac1d03181e0ec808c2c8a67fdc20b300"