UNPKG

@backstage-community/plugin-rbac-backend

Version:
493 lines (489 loc) 18 kB
'use strict'; var casbin = require('casbin'); var sync = require('csv-parse/sync'); var lodash = require('lodash'); var auditor = require('../auditor/auditor.cjs.js'); var helper = require('../helper.cjs.js'); var permissionModel = require('../service/permission-model.cjs.js'); var policiesValidation = require('../validation/policies-validation.cjs.js'); var fileWatcher = require('./file-watcher.cjs.js'); var lowercaseFileAdapter = require('./lowercase-file-adapter.cjs.js'); const CSV_PERMISSION_POLICY_FILE_AUTHOR = "csv permission policy file"; class CSVFileWatcher extends fileWatcher.AbstractFileWatcher { constructor(filePath, allowReload, logger, enforcer, roleMetadataStorage, auditor) { super(filePath, allowReload, logger); this.enforcer = enforcer; this.roleMetadataStorage = roleMetadataStorage; this.auditor = auditor; this.currentContent = []; this.csvFilePolicies = { addedPolicies: [], removedPolicies: [], addedGroupPolicies: /* @__PURE__ */ new Map(), removedGroupPolicies: /* @__PURE__ */ new Map() }; } currentContent; csvFilePolicies; /** * parse is used to parse the current contents of the CSV file. * @returns The CSV file parsed into a string[][]. */ parse() { const content = this.getCurrentContents(); const lines = content.split(/\r?\n/); const data = []; const parseOptions = { delimiter: ",", skip_empty_lines: true, relax_column_count: true, trim: true }; for (let i = 0; i < lines.length; i += 1) { const rawLine = lines[i]; const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } const lineNumber = i + 1; try { const rows = sync.parse(line, parseOptions); const row = rows[0]; if (row && row.length > 0) { helper.transformPolicyGroupToLowercase(row); data.push(row); } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger.warn( `Skipping invalid CSV policy line ${lineNumber} in ${this.filePath}: ${message}; line=${line.slice( 0, 120 )}` ); } } return data; } /** * 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() { if (!this.filePath) { return; } let content = []; content = this.parse(); const tempEnforcer = await casbin.newEnforcer( casbin.newModelFromString(permissionModel.MODEL), new lowercaseFileAdapter.LowercaseFileAdapter(this.filePath, this.logger) ); await this.filterPoliciesAndRoles( this.enforcer, tempEnforcer, this.csvFilePolicies.removedPolicies, this.csvFilePolicies.removedGroupPolicies, true ); await this.filterPoliciesAndRoles( tempEnforcer, this.enforcer, this.csvFilePolicies.addedPolicies, this.csvFilePolicies.addedGroupPolicies ); await this.migrateLegacyMetadata(tempEnforcer); await this.updatePolicies(content); if (this.allowReload) { this.watchFile(); } } // Check for policies that might need to be updated // This will involve update "legacy" source in the role metadata if it exist in both the // temp enforcer (csv file) and a role metadata storage. // We will update role metadata with the new source "csv-file" async migrateLegacyMetadata(tempEnforcer) { let legacyRolesMetadata = await this.roleMetadataStorage.filterRoleMetadata("legacy"); const legacyRoles = legacyRolesMetadata.map((meta) => meta.roleEntityRef); if (legacyRoles.length > 0) { const legacyGroupPolicies = await tempEnforcer.getFilteredGroupingPolicy( 1, ...legacyRoles ); const legacyPolicies = await tempEnforcer.getFilteredPolicy( 0, ...legacyRoles ); const legacyRolesFromFile = /* @__PURE__ */ new Set([ ...legacyGroupPolicies.map((gp) => gp[1]), ...legacyPolicies.map((p) => p[0]) ]); legacyRolesMetadata = legacyRolesMetadata.filter( (meta) => legacyRolesFromFile.has(meta.roleEntityRef) ); for (const legacyRoleMeta of legacyRolesMetadata) { const nonLegacyRole = helper.mergeRoleMetadata(legacyRoleMeta, { modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, source: "csv-file", roleEntityRef: legacyRoleMeta.roleEntityRef }); await this.roleMetadataStorage.updateRoleMetadata( nonLegacyRole, legacyRoleMeta.roleEntityRef ); } } } /** * 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() { const newContent = this.parse(); const tempEnforcer = await casbin.newEnforcer( casbin.newModelFromString(permissionModel.MODEL), new lowercaseFileAdapter.LowercaseFileAdapter(this.filePath, this.logger) ); const currentFlatContent = this.currentContent.flatMap((data) => { return helper.policyToString(data); }); const newFlatContent = newContent.flatMap((data) => { return helper.policyToString(data); }); await this.findFileContentDiff( currentFlatContent, newFlatContent, tempEnforcer ); await this.updatePolicies(newContent); } /** * 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 */ async updatePolicies(newContent) { this.currentContent = newContent; if (this.csvFilePolicies.addedPolicies.length > 0) await this.addPermissionPolicies(); if (this.csvFilePolicies.removedPolicies.length > 0) await this.removePermissionPolicies(); if (this.csvFilePolicies.addedGroupPolicies.size > 0) await this.addRoles(); if (this.csvFilePolicies.removedGroupPolicies.size > 0) await this.removeRoles(); } /** * addPermissionPolicies will add the new permission policies that are present in the CSV file. */ async addPermissionPolicies() { const auditorEvent = await this.auditor.createEvent({ eventId: auditor.PermissionEvents.POLICY_WRITE, severityLevel: "medium", meta: { actionType: auditor.ActionType.CREATE, source: "csv-file" } }); try { await this.enforcer.addPolicies(this.csvFilePolicies.addedPolicies); await auditorEvent.success({ meta: { policies: this.csvFilePolicies.addedPolicies } }); } catch (e) { await auditorEvent.fail({ meta: { policies: this.csvFilePolicies.addedPolicies }, error: e }); } this.csvFilePolicies.addedPolicies = []; } /** * removePermissionPolicies will remove the permission policies that are no longer present in the CSV file. */ async removePermissionPolicies() { const auditorEvent = await this.auditor.createEvent({ eventId: auditor.PermissionEvents.POLICY_WRITE, severityLevel: "medium", meta: { actionType: auditor.ActionType.DELETE, source: "csv-file" } }); try { await this.enforcer.removePolicies(this.csvFilePolicies.removedPolicies); await auditorEvent.success({ meta: { policies: this.csvFilePolicies.removedPolicies } }); } catch (e) { await auditorEvent.fail({ meta: { policies: this.csvFilePolicies.removedPolicies }, error: e }); } this.csvFilePolicies.removedPolicies = []; } /** * addRoles will add the new roles that are present in the CSV file. */ async addRoles() { const changedPolicies = { addedPolicies: [], updatedPolicies: [], failedPolicies: [] }; const auditorEvent = await this.auditor.createEvent({ eventId: auditor.RoleEvents.ROLE_WRITE, severityLevel: "medium", meta: { actionType: auditor.ActionType.CREATE_OR_UPDATE, source: "csv-file" } }); for (const [key, value] of this.csvFilePolicies.addedGroupPolicies) { const groupPolicies = value.map((member) => { return [member, key]; }); const roleMetadata = { source: "csv-file", roleEntityRef: key, author: CSV_PERMISSION_POLICY_FILE_AUTHOR, modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR }; try { const currentMetadata = await this.roleMetadataStorage.findRoleMetadata( roleMetadata.roleEntityRef ); await this.enforcer.addGroupingPolicies(groupPolicies, roleMetadata); if (currentMetadata) { changedPolicies.updatedPolicies.push(...groupPolicies); } else { changedPolicies.addedPolicies.push(...groupPolicies); } } catch (e) { changedPolicies.failedPolicies.push({ error: e, policies: groupPolicies }); } } if (changedPolicies.failedPolicies.length > 0) { await auditorEvent.fail({ error: new Error( `Failed to add or update group policies after modification ${this.filePath}.` ), meta: { ...changedPolicies } }); } else { await auditorEvent.success({ meta: { addedPolicies: changedPolicies.addedPolicies, updatedPolicies: changedPolicies.updatedPolicies } }); } this.csvFilePolicies.addedGroupPolicies = /* @__PURE__ */ new Map(); } /** * 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() { for (const [key, value] of this.csvFilePolicies.removedGroupPolicies) { const oldGroupingPolicies = await this.enforcer.getFilteredGroupingPolicy( 1, key ); const groupPolicies = value.map((member) => { return [member, key]; }); const roleMetadata = { source: "csv-file", roleEntityRef: key, author: CSV_PERMISSION_POLICY_FILE_AUTHOR, modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR }; const isUpdate = oldGroupingPolicies.length > 1 && oldGroupingPolicies.length !== groupPolicies.length; const actionType = isUpdate ? auditor.ActionType.UPDATE : auditor.ActionType.DELETE; const meta = { ...roleMetadata, members: value }; const auditorEvent = await this.auditor.createEvent({ eventId: auditor.RoleEvents.ROLE_WRITE, severityLevel: "medium", meta: { actionType, source: meta.source } }); try { await this.enforcer.removeGroupingPolicies( groupPolicies, roleMetadata, isUpdate ); await auditorEvent.success({ meta }); } catch (e) { await auditorEvent.fail({ meta, error: e }); } } this.csvFilePolicies.removedGroupPolicies = /* @__PURE__ */ new Map(); } async cleanUpRolesAndPolicies() { const roleMetadatas = await this.roleMetadataStorage.filterRoleMetadata("csv-file"); const fileRoles = roleMetadatas.map((meta) => meta.roleEntityRef); if (fileRoles.length > 0) { for (const fileRole of fileRoles) { const filteredPolicies = await this.enforcer.getFilteredGroupingPolicy( 1, fileRole ); for (const groupPolicy of filteredPolicies) { this.addGroupPolicyToMap( this.csvFilePolicies.removedGroupPolicies, groupPolicy[1], groupPolicy[0] ); } this.csvFilePolicies.removedPolicies.push( ...await this.enforcer.getFilteredPolicy(0, fileRole) ); } } await this.removePermissionPolicies(); await this.removeRoles(); } async filterPoliciesAndRoles(enforcerOne, enforcerTwo, policies, groupPolicies, remove) { const policiesToEdit = await enforcerOne.getPolicy(); const groupPoliciesToEdit = await enforcerOne.getGroupingPolicy(); for (const policy of policiesToEdit) { if (!await enforcerTwo.hasPolicy(...policy) && await this.validateAddedPolicy( policy, enforcerOne, remove )) { policies.push(policy); } if (policy[1] === "policy-entity" && policy[2] === "create" && !remove) { this.logger.warn( `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${policy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file` ); } } for (const groupPolicy of groupPoliciesToEdit) { if (!await enforcerTwo.hasGroupingPolicy(...groupPolicy) && await this.validateAddedGroupPolicy( groupPolicy, enforcerOne, remove )) { this.addGroupPolicyToMap(groupPolicies, groupPolicy[1], groupPolicy[0]); } } } async validateAddedPolicy(policy, tempEnforcer, remove) { const transformedPolicy = helper.transformArrayToPolicy(policy); const metadata = await this.roleMetadataStorage.findRoleMetadata(policy[0]); if (remove) { return metadata?.source === "csv-file"; } let err = policiesValidation.validatePolicy(transformedPolicy); if (err) { this.logger.warn( `Failed to validate policy from file ${this.filePath}. Cause: ${err.message}` ); return false; } err = await policiesValidation.validateSource("csv-file", metadata); if (err) { this.logger.warn( `Unable to add policy ${policy} from file ${this.filePath}. Cause: ${err.message}` ); return false; } err = await policiesValidation.checkForDuplicatePolicies(tempEnforcer, policy, this.filePath); if (err) { this.logger.warn(err.message); return false; } return true; } async validateAddedGroupPolicy(groupPolicy, tempEnforcer, remove) { const metadata = await this.roleMetadataStorage.findRoleMetadata( groupPolicy[1] ); if (remove) { return metadata?.source === "csv-file"; } let err = await policiesValidation.validateGroupingPolicy(groupPolicy, metadata, "csv-file"); if (err) { this.logger.warn( `${err.message}, error originates from file ${this.filePath}` ); return false; } err = await policiesValidation.checkForDuplicateGroupPolicies( tempEnforcer, groupPolicy, this.filePath ); if (err) { this.logger.warn(err.message); return false; } return true; } async findFileContentDiff(currentFlatContent, newFlatContent, tempEnforcer) { const diffRemoved = lodash.difference(currentFlatContent, newFlatContent); const diffAdded = lodash.difference(newFlatContent, currentFlatContent); await this.migrateLegacyMetadata(tempEnforcer); if (diffRemoved.length === 0 && diffAdded.length === 0) { return; } diffRemoved.forEach((policy) => { const convertedPolicy = helper.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.addGroupPolicyToMap( this.csvFilePolicies.removedGroupPolicies, convertedPolicy[1], convertedPolicy[0] ); } }); for (const policy of diffAdded) { const convertedPolicy = helper.metadataStringToPolicy(policy); if (convertedPolicy[0] === "p") { convertedPolicy.splice(0, 1); if (await this.validateAddedPolicy(convertedPolicy, tempEnforcer)) this.csvFilePolicies.addedPolicies.push(convertedPolicy); if (convertedPolicy[1] === "policy-entity" && convertedPolicy[2] === "create") { this.logger.warn( `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${convertedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file` ); } } else if (convertedPolicy[0] === "g") { convertedPolicy.splice(0, 1); if (await this.validateAddedGroupPolicy(convertedPolicy, tempEnforcer)) this.addGroupPolicyToMap( this.csvFilePolicies.addedGroupPolicies, convertedPolicy[1], convertedPolicy[0] ); } } } addGroupPolicyToMap(groupPolicyMap, key, value) { if (!groupPolicyMap.has(key)) { groupPolicyMap.set(key, []); } groupPolicyMap.get(key)?.push(value); } } exports.CSVFileWatcher = CSVFileWatcher; exports.CSV_PERMISSION_POLICY_FILE_AUTHOR = CSV_PERMISSION_POLICY_FILE_AUTHOR; //# sourceMappingURL=csv-file-watcher.cjs.js.map