@backstage-community/plugin-rbac-backend
Version:
493 lines (489 loc) • 18 kB
JavaScript
'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