UNPKG

moleculer-iam

Version:

Centralized IAM module for moleculer. Including a certified OIDC provider and an Identity provider for user profile, credentials, and custom claims management. Custom claims could be defined/updated by declarative schema which contains claims validation a

359 lines 16.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.IdentityClaimsManager = void 0; const tslib_1 = require("tslib"); const _ = tslib_1.__importStar(require("lodash")); const vm = tslib_1.__importStar(require("vm")); const terser_1 = tslib_1.__importDefault(require("terser")); const object_hash_1 = tslib_1.__importDefault(require("object-hash")); const validator_1 = require("../../lib/validator"); const error_1 = require("../error"); const types_1 = require("./types"); const options_1 = require("./options"); class IdentityClaimsManager { constructor(props, opts) { this.props = props; this.mandatoryScopes = []; this._supportedScopes = {}; this.logger = props.logger || console; // compile payload validation functions this.validatePayload = validator_1.validator.compile(types_1.IdentityClaimsSchemaPayloadValidationSchema); // prepare base claims this.options = _.defaultsDeep(opts || {}, options_1.defaultIdentityClaimsManagerOptions); this.mandatoryScopes = [...new Set(this.options.mandatoryScopes.concat(["openid"]))]; } get adapter() { return this.props.adapter; } /* lifecycle */ async start() { // define mandatory claims and base claims const payloads = [ { scope: "openid", key: "sub", description: "account id", validation: "string", immutable: true, unique: true, }, ...this.options.baseClaims, ]; for (const payload of payloads) { await this.defineClaimsSchema(payload); } await this.syncSupportedScopes(); this.logger.info("identity claims manager has been started"); } async stop() { this.logger.info("identity claims manager has been stopped"); } /* to update claims schema */ hashClaimsSchemaPayload(payload) { return object_hash_1.default(payload, { algorithm: "md5", unorderedArrays: true, unorderedObjects: true, unorderedSets: true, }); } createClaimsSchema(payload) { const result = this.validatePayload(payload); if (result !== true) { throw new error_1.IAMErrors.ValidationFailed(result, { payload, }); } // normalize migration codes const { code, error } = terser_1.default.minify(`(${payload.migration})(oldClaim, claims);`, { ecma: 6, compress: false, mangle: false, output: { beautify: true, indent_level: 2 } }); if (error) { throw error; } payload.migration = code; const schema = { ...payload, version: this.hashClaimsSchemaPayload(payload), active: true, }; return schema; } compileClaimsValidator(schema) { const validate = validator_1.validator.compile({ [schema.key]: schema.validation, $$strict: true, }); return (claims) => { const result = validate(claims); if (result !== true) { throw new error_1.IAMErrors.ValidationFailed(result, claims); } }; } compileClaimsMigrationStrategy(schema) { // compile function try { const script = new vm.Script(schema.migration, { displayErrors: true, timeout: 100, }); // uncomment to read function codes on jest cov_ errors // console.log(`(${schema.migration!})(oldClaim, claims)`); return (oldClaim, claims) => { return script.runInNewContext({ oldClaim, claims }); }; } catch (error) { throw new error_1.IAMErrors.ValidationFailed([], { migration: schema.migration, error }); } } get supportedScopes() { return this._supportedScopes; } async syncSupportedScopes() { // update supported scope information this._supportedScopes = await this.getActiveClaimsSchemata() .then(schemata => schemata.reduce((scopes, schema) => { scopes[schema.scope] = (scopes[schema.scope] || []).concat(schema.key); return scopes; }, {})); } async onClaimsSchemaUpdated() { await this.adapter.onClaimsSchemaUpdated(); await this.syncSupportedScopes(); return; } async getActiveClaimsSchemata() { return this.adapter.getClaimsSchemata({ scope: [], active: true }); } async getClaimsSchemata(args) { if (typeof args.scope === "string") { args = { ...args, scope: args.scope.split(" ").filter(s => !!s) }; } else if (typeof args.scope === "undefined") { args = { ...args, scope: [] }; } return this.adapter.getClaimsSchemata(args); } async getClaimsSchema(args) { return this.adapter.getClaimsSchema(args); } async forceReloadClaims(args) { this.logger.info(`force reload identity claims: onClaimsUpdated()`, args); let transaction; try { transaction = await this.adapter.transaction(); // search and reload if (args.where) { // migrate in batches const limit = 100; let offset = 0; while (true) { const ids = await this.adapter.get({ where: args.where, offset, limit }); if (ids.length === 0) { break; } await Promise.all(ids.map(async (id) => { try { await this.adapter.onClaimsUpdated(id, {}, transaction); } catch (error) { this.logger.error("failed to reload user claims", id, error); throw error; } })); offset += limit; } } // reload directly if (args.ids && args.ids.length > 0) { await Promise.all(args.ids.map(async (id) => { try { await this.adapter.onClaimsUpdated(id, {}, transaction); } catch (error) { this.logger.error("failed to reload user claims", id, error); throw error; } })); } await transaction.commit(); } catch (error) { this.logger.error(`force reload identity claims failed`, error); if (transaction) { await transaction.rollback(); } throw error; } } async forceDeleteClaimsSchemata(...keys) { for (const key of keys) { await this.adapter.acquireMigrationLock(key); const transaction = await this.adapter.transaction(); try { this.logger.info("force delete claims schema:", key); await this.adapter.forceDeleteClaimsSchema(key, transaction); await this.adapter.onClaimsSchemaUpdated(); await transaction.commit(); } catch (error) { this.logger.error("failed to force delete claims schema:", key); await transaction.rollback(); throw error; } finally { await this.adapter.releaseMigrationLock(key); } } } async defineClaimsSchema(payload) { await this.adapter.acquireMigrationLock(payload.key); try { // validate payload and create schema const schema = this.createClaimsSchema(payload); const scopeFilter = { metadata: { scope: { [schema.scope]: true } } }; // compile claims schema and validate it with default value const validateClaims = this.compileClaimsValidator(schema); // compile migration function const migrateClaims = this.compileClaimsMigrationStrategy(schema); // restore inactive schema version if does const inactiveSchema = await this.adapter.getClaimsSchema({ key: schema.key, version: schema.version, active: false }); if (inactiveSchema) { this.logger.info(`activate identity claims schema for ${schema.key}:${schema.version.substr(0, 8)}`); // tslint:disable-next-line:no-shadowed-variable const transaction = await this.adapter.transaction(); try { // activate await this.adapter.setActiveClaimsSchema({ key: schema.key, version: schema.version }, transaction); // migrate in batches const limit = 100; let offset = 0; while (true) { const identities = await this.adapter.get({ offset, limit, where: scopeFilter }); if (identities.length === 0) { break; } await Promise.all(identities.map(async (identity) => { try { await this.adapter.onClaimsUpdated(identity, {}, transaction); } catch (error) { this.logger.error("failed to update user claims", error); throw error; } })); // notice current migration is alive if (this.adapter.touchMigrationLock) { await this.adapter.touchMigrationLock(schema.key, offset + identities.length); } offset += limit; } await this.adapter.onClaimsSchemaUpdated(); await transaction.commit(); return schema; } catch (error) { this.logger.error(`identity claims migration failed`, error); await transaction.rollback(); throw error; } } // get current active schema const activeSchema = await this.adapter.getClaimsSchema({ key: schema.key, active: true }); // if has exactly same schema if (activeSchema && activeSchema.version === schema.version) { this.logger.info(`skip identity claims schema migration for ${activeSchema.key}:${activeSchema.version.substr(0, 8)}`); await this.adapter.onClaimsSchemaUpdated(); // for the case of distributed system return activeSchema; } // get target schema let parentSchema; if (schema.parentVersion) { // from specific version parentSchema = await this.adapter.getClaimsSchema({ key: schema.key, version: schema.parentVersion }); if (!parentSchema) { throw new error_1.IAMErrors.ValidationFailed([], { parentVersion: schema.parentVersion }); } } else { parentSchema = activeSchema; schema.parentVersion = parentSchema ? parentSchema.version : undefined; } // update user client claims this.logger.info(`start identity claims migration: ${schema.key}:${schema.parentVersion ? schema.parentVersion.substr(0, 8) + " -> " : ""}${schema.version.substr(0, 8)}`); // begin transaction const transaction = await this.adapter.transaction(); try { // create new claims schema await this.adapter.createClaimsSchema(schema, transaction); await this.adapter.setActiveClaimsSchema({ key: schema.key, version: schema.version }, transaction); // migrate in batches const limit = 100; let offset = 0; while (true) { const ids = await this.adapter.get({ offset, limit, where: scopeFilter }); if (ids.length === 0) { break; } await Promise.all(ids.map(async (id, index) => { // validate new claims and save let oldClaim; let newClaim; let claims; try { // create new value claims = await this.adapter.getClaims(id, []); oldClaim = parentSchema ? await this.adapter.getVersionedClaims(id, [{ key: schema.key, schemaVersion: schema.parentVersion, }]) .then(result => result[schema.key]) : undefined; oldClaim = typeof oldClaim === "undefined" ? null : oldClaim; newClaim = migrateClaims(oldClaim, claims); newClaim = typeof newClaim === "undefined" ? null : newClaim; // validate and re-assign (may) sanitized value const newClaims = { [schema.key]: newClaim }; validateClaims(newClaims); // in migration, schema.unique property is ignored newClaim = newClaims[schema.key]; this.logger.info(`migrate user claims ${id}:${schema.key}:${schema.version.substr(0, 8)}`, oldClaim, "->", newClaim); await this.adapter.createOrUpdateVersionedClaims(id, [{ key: schema.key, value: newClaim, schemaVersion: schema.version, }], transaction); if (JSON.stringify(oldClaim) !== JSON.stringify(newClaim)) { await this.adapter.onClaimsUpdated(id, { [schema.key]: newClaim }, transaction); } } catch (error) { const detail = { id, oldClaim, newClaim, error, index: index + offset }; this.logger.error("failed to update user claims", detail); throw new error_1.IAMErrors.ValidationFailed([], detail); } })); // notice current migration is alive if (this.adapter.touchMigrationLock) { await this.adapter.touchMigrationLock(schema.key, offset + ids.length); } offset += limit; } // commit transaction await this.adapter.onClaimsSchemaUpdated(); await transaction.commit(); this.logger.info(`identity claims migration finished: ${schema.key}:${schema.parentVersion ? schema.parentVersion.substr(0, 8) + " -> " : ""}${schema.version.substr(0, 8)}`); return schema; } catch (error) { // failed to migrate, revoke migration this.logger.error(`identity claims migration failed:`, error); await transaction.rollback(); throw error; } } finally { await this.adapter.releaseMigrationLock(payload.key); } } } exports.IdentityClaimsManager = IdentityClaimsManager; //# sourceMappingURL=claims.js.map