UNPKG

casbin-mongoose-adapter

Version:
663 lines (593 loc) 22.3 kB
// Copyright 2019 The elastic.io team (http://elastic.io). All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { BatchAdapter, FilteredAdapter, Helper, logPrint, Model, UpdatableAdapter } from 'casbin'; import { ClientSession, Connection, ConnectOptions, createConnection, FilterQuery, Model as MongooseModel } from 'mongoose'; import { AdapterError, InvalidAdapterTypeError } from './errors'; import { collectionName, IModel, modelName, schema } from './model'; export interface MongooseAdapterOptions { filtered?: boolean; synced?: boolean; autoAbort?: boolean; autoCommit?: boolean; timestamps?: boolean; } export interface policyLine { ptype?: string; v0?: string; v1?: string; v2?: string; v3?: string; v4?: string; v5?: string; } export interface sessionOption { session?: ClientSession; } /** * Implements a policy adapter for casbin with MongoDB support. * * @class */ export class MongooseAdapter implements BatchAdapter, FilteredAdapter, UpdatableAdapter { public connection?: Connection; private filtered: boolean; private isSynced: boolean; private uri: string; private options?: ConnectOptions; private autoAbort: boolean; private autoCommit: boolean; private session: ClientSession; private casbinRule: MongooseModel<IModel>; /** * Creates a new instance of mongoose adapter for casbin. * It does not wait for successfull connection to MongoDB. * So, if you want to have a possibility to wait until connection successful, use newAdapter instead. * * @constructor * @param {String} uri Mongo URI where casbin rules must be persisted * @param {Object} [options={}] Additional options to pass on to mongoose client * @param {Object} [adapterOptions={}] adapterOptions additional adapter options * @example * const adapter = new MongooseAdapter('MONGO_URI'); * const adapter = new MongooseAdapter('MONGO_URI', { mongoose_options: 'here' }) */ constructor(uri: string, options?: ConnectOptions, adapterOptions?: MongooseAdapterOptions) { if (!uri) { throw new AdapterError('You must provide Mongo URI to connect to!'); } // by default, adapter is not filtered this.filtered = false; this.isSynced = false; this.autoAbort = false; this.uri = uri; this.options = options; this.connection = createConnection(this.uri, this.options); this.casbinRule = this.connection.model<IModel>( modelName, schema(adapterOptions?.timestamps), collectionName ); } /** * Creates a new instance of mongoose adapter for casbin. * Instead of constructor, it does wait for successfull connection to MongoDB. * Preferable way to construct an adapter instance, is to use this static method. * * @static * @param {String} uri Mongo URI where casbin rules must be persisted * @param {Object} [options={}] Additional options to pass on to mongoose client * @param {Object} [adapterOptions={}] Additional options to pass on to adapter * @example * const adapter = await MongooseAdapter.newAdapter('MONGO_URI'); * const adapter = await MongooseAdapter.newAdapter('MONGO_URI', { mongoose_options: 'here' }); */ static async newAdapter( uri: string, options?: ConnectOptions, adapterOptions: MongooseAdapterOptions = {} ) { const adapter = new MongooseAdapter(uri, options, adapterOptions); const { filtered = false, synced = false, autoAbort = false, autoCommit = false } = adapterOptions; adapter.setFiltered(filtered); adapter.setSynced(synced); adapter.setAutoAbort(autoAbort); adapter.setAutoCommit(autoCommit); return adapter; } /** * Creates a new instance of mongoose adapter for casbin. * It does the same as newAdapter, but it also sets a flag that this adapter is in filtered state. * That way, casbin will not call loadPolicy() automatically. * * @static * @param {String} uri Mongo URI where casbin rules must be persisted * @param {Object} [options] Additional options to pass on to mongoose client * @example * const adapter = await MongooseAdapter.newFilteredAdapter('MONGO_URI'); * const adapter = await MongooseAdapter.newFilteredAdapter('MONGO_URI', { mongoose_options: 'here' }); */ static async newFilteredAdapter(uri: string, options?: ConnectOptions) { const adapter = await MongooseAdapter.newAdapter(uri, options, { filtered: true }); return adapter; } /** * Creates a new instance of mongoose adapter for casbin. * It does the same as newAdapter, but it checks wether database is a replica set. If it is, it enables * transactions for the adapter. * Transactions are never commited automatically. You have to use commitTransaction to add pending changes. * * @static * @param {String} uri Mongo URI where casbin rules must be persisted * @param {Object} [options={}] Additional options to pass on to mongoose client * @param {Boolean} autoAbort Whether to abort transactions on Error automatically * @param autoCommit * @example * const adapter = await MongooseAdapter.newFilteredAdapter('MONGO_URI'); * const adapter = await MongooseAdapter.newFilteredAdapter('MONGO_URI', { mongoose_options: 'here' }); */ static async newSyncedAdapter( uri: string, options?: ConnectOptions, autoAbort: boolean = true, autoCommit = true ) { return await MongooseAdapter.newAdapter(uri, options, { synced: true, autoAbort, autoCommit }); } /** * Switch adapter to (non)filtered state. * Casbin uses this flag to determine if it should load the whole policy from DB or not. * * @param {Boolean} [enable=true] Flag that represents the current state of adapter (filtered or not) */ setFiltered(enable: boolean = true) { this.filtered = enable; } /** * isFiltered determines whether the filtered model is enabled for the adapter. * @returns {boolean} */ isFiltered(): boolean { return this.filtered; } /** * SyncedAdapter: Switch adapter to (non)synced state. * This enables mongoDB transactions when loading and saving policies to DB. * * @param {Boolean} [synced=true] Flag that represents the current state of adapter (filtered or not) */ setSynced(synced: boolean = true) { this.isSynced = synced; } /** * SyncedAdapter: Automatically abort on Error. * When enabled, functions will automatically abort on error * * @param {Boolean} [abort=true] Flag that represents if automatic abort should be enabled or not */ setAutoAbort(abort: boolean = true) { if (this.isSynced) this.autoAbort = abort; } /** * SyncedAdapter: Automatically commit after each addition. * When enabled, functions will automatically commit after function has finished * * @param {Boolean} [commit=true] Flag that represents if automatic commit should be enabled or not */ setAutoCommit(commit: boolean = true) { if (this.isSynced) this.autoCommit = commit; } /** * SyncedAdapter: Gets active session or starts a new one. Sessions are used to handle transactions. */ async getSession(): Promise<ClientSession> { if (this.isSynced) { return this.session && this.session.inTransaction() ? this.session : this.connection!.startSession(); } else throw new InvalidAdapterTypeError( 'Transactions are only supported by SyncedAdapter. See newSyncedAdapter' ); } /** * SyncedAdapter: Sets current session to specific one. Do not use this unless you know what you are doing. */ async setSession(session: ClientSession) { if (this.isSynced) { this.session = session; } else { throw new InvalidAdapterTypeError( 'Sessions are only supported by SyncedAdapter. See newSyncedAdapter' ); } } /** * SyncedAdapter: Gets active transaction or starts a new one. Transaction must be closed before changes are done * to the database. See: commitTransaction, abortTransaction * @returns {Promise<ClientSession>} Returns a session with active transaction */ async getTransaction(): Promise<ClientSession> { if (this.isSynced) { const session = await this.getSession(); if (!session.inTransaction()) { await this.casbinRule.createCollection(); await session.startTransaction(); logPrint( 'Transaction started. To commit changes use adapter.commitTransaction() or to abort use adapter.abortTransaction()' ); } return session; } else throw new InvalidAdapterTypeError( 'Transactions are only supported by SyncedAdapter. See newSyncedAdapter' ); } /** * SyncedAdapter: Commits active transaction. Documents are not saved before this function is used. * Transaction closes after the use of this function. * @returns {Promise<void>} */ async commitTransaction(): Promise<void> { if (this.isSynced) { const session = await this.getSession(); await session.commitTransaction(); } else throw new InvalidAdapterTypeError( 'Transactions are only supported by SyncedAdapter. See newSyncedAdapter' ); } /** * SyncedAdapter: Aborts active transaction. All Document changes within this transaction are reverted. * Transaction closes after the use of this function. * @returns {Promise<void>} */ async abortTransaction(): Promise<void> { if (this.isSynced) { const session = await this.getSession(); await session.abortTransaction(); logPrint('Transaction aborted'); } else throw new InvalidAdapterTypeError( 'Transactions are only supported by SyncedAdapter. See newSyncedAdapter' ); } /** * Loads one policy rule into casbin model. * This method is used by casbin and should not be called by user. * * @param {Object} line Record with one policy rule from MongoDB * @param {Object} model Casbin model to which policy rule must be loaded */ loadPolicyLine(line: policyLine, model: Model) { let lineText = `${line.ptype!}`; for (const word of [line.v0, line.v1, line.v2, line.v3, line.v4, line.v5]) { if (word !== undefined) { let wrappedWord = /^".*"$/.test(word) ? word : `"${word}"`; lineText = `${lineText},${wrappedWord}`; } else { break; } } if (lineText) { Helper.loadPolicyLine(lineText, model); } } /** * Implements the process of loading policy from database into enforcer. * This method is used by casbin and should not be called by user. * * @param {Model} model Model instance from enforcer * @returns {Promise<void>} */ async loadPolicy(model: Model): Promise<void> { return this.loadFilteredPolicy(model, null); } /** * Loads partial policy based on filter criteria. * This method is used by casbin and should not be called by user. * * @param {Model} model Enforcer model * @param {Object} [filter] MongoDB filter to query */ async loadFilteredPolicy(model: Model, filter: FilterQuery<IModel> | null) { if (filter) { this.setFiltered(true); } else { this.setFiltered(false); } const options: sessionOption = {}; if (this.isSynced) options.session = await this.getTransaction(); const lines = await this.casbinRule.find(filter || {}, null, options).lean(); this.autoCommit && options.session && (await options.session.commitTransaction()); for (const line of lines) { this.loadPolicyLine(line, model); } } /** * Generates one policy rule ready to be saved into MongoDB. * This method is used by casbin to generate Mongoose Model Object for single policy * and should not be called by user. * * @param {String} pType Policy type to save into MongoDB * @param {Array<String>} rule An array which consists of policy rule elements to store * @returns {IModel} Returns a created CasbinRule record for MongoDB */ savePolicyLine(pType: string, rule: string[]): IModel { const model = new this.casbinRule({ ptype: pType }); if (rule.length > 0) { model.v0 = rule[0]; } if (rule.length > 1) { model.v1 = rule[1]; } if (rule.length > 2) { model.v2 = rule[2]; } if (rule.length > 3) { model.v3 = rule[3]; } if (rule.length > 4) { model.v4 = rule[4]; } if (rule.length > 5) { model.v5 = rule[5]; } return model; } /** * Implements the process of saving policy from enforcer into database. * If you are using replica sets with mongo, this function will use mongo * transaction, so every line in the policy needs tosucceed for this to * take effect. * This method is used by casbin and should not be called by user. * * @param {Model} model Model instance from enforcer * @returns {Promise<Boolean>} */ async savePolicy(model: Model) { const options: sessionOption = {}; if (this.isSynced) options.session = await this.getTransaction(); try { const lines: IModel[] = []; const policyRuleAST = model.model.get('p') instanceof Map ? model.model.get('p')! : new Map(); const groupingPolicyAST = model.model.get('g') instanceof Map ? model.model.get('g')! : new Map(); for (const [ptype, ast] of policyRuleAST) { for (const rule of ast.policy) { lines.push(this.savePolicyLine(ptype, rule)); } } for (const [ptype, ast] of groupingPolicyAST) { for (const rule of ast.policy) { lines.push(this.savePolicyLine(ptype, rule)); } } await this.casbinRule.collection.insertMany(lines, options); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); console.error(err); return false; } return true; } /** * Implements the process of adding policy rule. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Array<String>} rule Policy rule to add into enforcer * @returns {Promise<void>} */ async addPolicy(sec: string, pType: string, rule: string[]): Promise<void> { const options: sessionOption = {}; try { if (this.isSynced) options.session = await this.getTransaction(); const line = this.savePolicyLine(pType, rule); await line.save(options); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } /** * Implements the process of adding a list of policy rules. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Array<String>} rules Policy rule to add into enforcer * @returns {Promise<void>} */ async addPolicies(sec: string, pType: string, rules: Array<string[]>): Promise<void> { const options: sessionOption = {}; if (this.isSynced) options.session = await this.getTransaction(); else throw new InvalidAdapterTypeError( 'addPolicies is only supported by SyncedAdapter. See newSyncedAdapter' ); try { const promises = rules.map(async (rule) => this.addPolicy(sec, pType, rule)); await Promise.all(promises); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } /** * Implements the process of updating policy rule. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Array<String>} oldRule Policy rule to remove from enforcer * @param {Array<String>} newRule Policy rule to add into enforcer * @returns {Promise<void>} */ async updatePolicy( sec: string, pType: string, oldRule: string[], newRule: string[] ): Promise<void> { const options: sessionOption = {}; try { if (this.isSynced) options.session = await this.getTransaction(); const { ptype, v0, v1, v2, v3, v4, v5 } = this.savePolicyLine(pType, oldRule); const newRuleLine = this.savePolicyLine(pType, newRule); const newModel = { ptype: newRuleLine.ptype, v0: newRuleLine.v0, v1: newRuleLine.v1, v2: newRuleLine.v2, v3: newRuleLine.v3, v4: newRuleLine.v4, v5: newRuleLine.v5 }; await this.casbinRule.updateOne({ ptype, v0, v1, v2, v3, v4, v5 }, newModel, options); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } /** * Implements the process of removing a list of policy rules. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Array<String>} rule Policy rule to remove from enforcer * @returns {Promise<void>} */ async removePolicy(sec: string, pType: string, rule: string[]): Promise<void> { const options: sessionOption = {}; try { if (this.isSynced) options.session = await this.getTransaction(); const { ptype, v0, v1, v2, v3, v4, v5 } = this.savePolicyLine(pType, rule); await this.casbinRule.deleteMany({ ptype, v0, v1, v2, v3, v4, v5 }, options); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } /** * Implements the process of removing a policyList rules. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Array<String>} rules Policy rule to remove from enforcer * @returns {Promise<void>} */ async removePolicies(sec: string, pType: string, rules: Array<string[]>): Promise<void> { const options: sessionOption = {}; try { if (this.isSynced) options.session = await this.getTransaction(); else throw new InvalidAdapterTypeError( 'removePolicies is only supported by SyncedAdapter. See newSyncedAdapter' ); const promises = rules.map(async (rule) => this.removePolicy(sec, pType, rule)); await Promise.all(promises); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } /** * Implements the process of removing policy rules. * This method is used by casbin and should not be called by user. * * @param {String} sec Section of the policy * @param {String} pType Type of the policy (e.g. "p" or "g") * @param {Number} fieldIndex Index of the field to start filtering from * @param {...String} fieldValues Policy rule to match when removing (starting from fieldIndex) * @returns {Promise<void>} */ async removeFilteredPolicy( sec: string, pType: string, fieldIndex: number, ...fieldValues: string[] ): Promise<void> { const options: sessionOption = {}; try { if (this.isSynced) options.session = await this.getTransaction(); const where: any = pType ? { ptype: pType } : {}; if (fieldIndex <= 0 && fieldIndex + fieldValues.length > 0 && fieldValues[0 - fieldIndex]) { if (pType === 'g') { where.$or = [{ v0: fieldValues[0 - fieldIndex] }, { v1: fieldValues[0 - fieldIndex] }]; } else { where.v0 = fieldValues[0 - fieldIndex]; } } if (fieldIndex <= 1 && fieldIndex + fieldValues.length > 1 && fieldValues[1 - fieldIndex]) { where.v1 = fieldValues[1 - fieldIndex]; } if (fieldIndex <= 2 && fieldIndex + fieldValues.length > 2 && fieldValues[2 - fieldIndex]) { where.v2 = fieldValues[2 - fieldIndex]; } if (fieldIndex <= 3 && fieldIndex + fieldValues.length > 3 && fieldValues[3 - fieldIndex]) { where.v3 = fieldValues[3 - fieldIndex]; } if (fieldIndex <= 4 && fieldIndex + fieldValues.length > 4 && fieldValues[4 - fieldIndex]) { where.v4 = fieldValues[4 - fieldIndex]; } if (fieldIndex <= 5 && fieldIndex + fieldValues.length > 5 && fieldValues[5 - fieldIndex]) { where.v5 = fieldValues[5 - fieldIndex]; } await this.casbinRule.deleteMany(where, options); this.autoCommit && options.session && (await options.session.commitTransaction()); } catch (err) { this.autoAbort && options.session && (await options.session.abortTransaction()); throw err; } } async close() { if (this.connection) { if (this.session) await this.session.endSession(); await this.connection.close(); } } /** * Just for testing. */ getCasbinRule() { return this.casbinRule; } }