UNPKG

@plumier/core

Version:

Delightful Node.js Rest Framework

480 lines (479 loc) • 20.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.throwAuthError = exports.executeAuthorizer = exports.createAuthContext = exports.WriteonlyAuthPolicy = exports.ReadonlyAuthPolicy = exports.AuthorizeWriteonly = exports.AuthorizeReadonly = exports.globalPolicies = exports.EntityAuthPolicy = exports.CustomAuthPolicy = exports.Authenticated = exports.Public = exports.PolicyAuthorizer = exports.entityPolicy = exports.authPolicy = exports.AuthenticatedAuthPolicy = exports.PublicAuthPolicy = exports.AuthorizerMiddleware = exports.getRouteAuthorizeDecorators = exports.checkAuthorize = void 0; const tslib_1 = require("tslib"); const reflect_1 = require("@plumier/reflect"); const debug_1 = tslib_1.__importDefault(require("debug")); const common_1 = require("./common"); const http_status_1 = require("./http-status"); const types_1 = require("./types"); // --------------------------------------------------------------------- // // ------------------------------- TYPES ------------------------------- // // --------------------------------------------------------------------- // const log = { debug: (0, debug_1.default)("@plumier/core:authorization"), }; /* ------------------------------------------------------------------------------- */ /* ------------------------------- HELPERS --------------------------------------- */ /* ------------------------------------------------------------------------------- */ function createDecoratorFilter(predicate) { return (x) => x.type === "plumier-meta:authorize" && predicate(x); } function getGlobalDecorators(globalDecorator) { const policies = typeof globalDecorator === "string" ? [globalDecorator] : globalDecorator; return [{ type: "plumier-meta:authorize", policies, tag: policies.join("|"), access: "route", evaluation: "Dynamic", location: "Method", appliedClass: Object }]; } function getRouteAuthorizeDecorators(info, globalDecorator) { // if action has decorators then return immediately to prioritize the action decorator const actionDecs = info.action.decorators.filter(createDecoratorFilter(x => x.access === "route")); if (actionDecs.length > 0) return actionDecs; // if controller has decorators then return immediately const controllerDecs = info.controller.decorators.filter(createDecoratorFilter(x => x.access === "route")); if (controllerDecs.length > 0) return controllerDecs; if (!globalDecorator) return []; return getGlobalDecorators(globalDecorator); } exports.getRouteAuthorizeDecorators = getRouteAuthorizeDecorators; function createAuthContext(ctx, access) { const { route, state } = ctx; return { user: state.user, route, ctx, access, policyIds: [], metadata: new types_1.MetadataImpl(ctx.parameters, ctx.route, {}), }; } exports.createAuthContext = createAuthContext; function throwAuthError(ctx, msg) { if (ctx.user) throw new types_1.HttpStatusError(http_status_1.HttpStatus.Forbidden, msg !== null && msg !== void 0 ? msg : "Forbidden"); else throw new types_1.HttpStatusError(http_status_1.HttpStatus.Unauthorized, msg !== null && msg !== void 0 ? msg : "Unauthorized"); } exports.throwAuthError = throwAuthError; function getErrorLocation(metadata) { const current = metadata.current; if (current.kind === "Class") return `class ${current.name}`; return `${current.kind.toLowerCase()} ${current.parent.name}.${current.name}`; } // --------------------------------------------------------------------- // // ------------------------ AUTHORIZATION POLICY ----------------------- // // --------------------------------------------------------------------- // const Public = "Public"; exports.Public = Public; const Authenticated = "Authenticated"; exports.Authenticated = Authenticated; const AuthorizeReadonly = "plumier::readonly"; exports.AuthorizeReadonly = AuthorizeReadonly; const AuthorizeWriteonly = "plumier::writeonly"; exports.AuthorizeWriteonly = AuthorizeWriteonly; class PolicyAuthorizer { constructor(policies, keys) { this.policies = policies; this.keys = keys; } async authorize(ctx) { var _a, _b; for (const Auth of this.policies.reverse()) { const authPolicy = new Auth(); for (const policy of this.keys) { if (authPolicy.equals(policy, ctx)) { const authorize = await authPolicy.authorize(ctx); log.debug("%s -> %s.%s by %s", authorize ? "AUTHORIZED" : "FORBIDDEN", (_b = (_a = ctx.metadata.current.parent) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : "", ctx.metadata.current.name, authPolicy.friendlyName()); if (authorize) return true; } } } return false; } } exports.PolicyAuthorizer = PolicyAuthorizer; class CustomAuthPolicy { constructor(name, authorizer) { this.name = name; this.authorizer = authorizer; } equals(id, ctx) { return id === this.name; } async authorize(ctx) { try { if (typeof this.authorizer === "function") return await this.authorizer(ctx); else return await this.authorizer.authorize(ctx); } catch (e) { const message = e instanceof Error ? e.stack : e; const location = getErrorLocation(ctx.metadata); throw new Error(`Error occur inside authorization policy ${this.name} on ${location} \n ${message}`); } } conflict(other) { return this.name === other.name; } friendlyName() { return `AuthPolicy { name: "${this.name}" }`; } } exports.CustomAuthPolicy = CustomAuthPolicy; class PublicAuthPolicy extends CustomAuthPolicy { constructor() { super(Public, {}); } async authorize(ctx) { return true; } } exports.PublicAuthPolicy = PublicAuthPolicy; class AuthenticatedAuthPolicy extends CustomAuthPolicy { constructor() { super(Authenticated, {}); } async authorize(ctx) { return !!ctx.user; } } exports.AuthenticatedAuthPolicy = AuthenticatedAuthPolicy; class ReadonlyAuthPolicy extends CustomAuthPolicy { constructor() { super(AuthorizeReadonly, {}); } async authorize(ctx) { return false; } } exports.ReadonlyAuthPolicy = ReadonlyAuthPolicy; class WriteonlyAuthPolicy extends CustomAuthPolicy { constructor() { super(AuthorizeWriteonly, {}); } async authorize(ctx) { return false; } } exports.WriteonlyAuthPolicy = WriteonlyAuthPolicy; class EntityAuthPolicy { constructor(name, entity, authorizer) { this.name = name; this.entity = entity; this.authorizer = authorizer; } getEntity(ctx) { if (ctx.access === "route" || ctx.access === "write") { // when the entity provider is Route // take the provided Entity from decorator // take the entity ID value from the Action Parameter const dec = ctx.metadata.action.decorators .find((x) => x.kind === "plumier-meta:entity-policy-provider"); if (!dec) { const meta = ctx.metadata; throw new Error(`Action ${meta.controller.name}.${meta.action.name} doesn't have Entity Policy Provider information`); } const id = ctx.metadata.actionParams.get(dec.idParam); return { entity: dec.entity, id }; } else { // when the entity provider is Read/Write/Filter // take the provided entity from the parent type from context // take the entity ID value using @primaryId() decorator const entity = ctx.metadata.current.parent; const prop = common_1.entityHelper.getIdProp(entity); if (!prop) throw new Error(`Entity ${entity.name} doesn't have primary ID information required for entity policy`); const id = ctx.parentValue[prop.name]; return { entity, id }; } } equals(id, ctx) { if (id === this.name) { const provider = this.getEntity(ctx); return this.entity === provider.entity; } return false; } async authorize(ctx) { const provider = this.getEntity(ctx); try { return await this.authorizer(ctx, provider.id); } catch (e) { const message = e instanceof Error ? e.stack : e; const location = getErrorLocation(ctx.metadata); throw new Error(`Error occur inside authorization policy ${this.name} for entity ${this.entity.name} on ${location} \n ${message}`); } } conflict(other) { if (other instanceof EntityAuthPolicy) return this.name === other.name && this.entity === other.entity; else return this.name === other.name; } friendlyName() { return `EntityPolicy { name: "${this.name}", entity: ${this.entity.name} }`; } } exports.EntityAuthPolicy = EntityAuthPolicy; class AuthPolicyBuilder { constructor(globalCache) { this.globalCache = globalCache; } /** * Define AuthPolicy class on the fly * @param id Id of the authorization policy that will be used in @authorize decorator * @param authorizer Authorization logic, a lambda function return true to authorize otherwise false */ define(id, authorizer) { class Policy extends CustomAuthPolicy { constructor() { super(id, authorizer); } } return Policy; } /** * Register authorization policy into authorization cache * @param id Id of the authorization policy that will be used in @authorize decorator * @param authorizer Authorization logic, a lambda function return true to authorize otherwise false */ register(id, authorizer) { const Policy = this.define(id, authorizer); this.globalCache.push(Policy); return this; } } class EntityPolicyBuilder { constructor(entity, globalCache) { this.entity = entity; this.globalCache = globalCache; } /** * Define AuthPolicy class on the fly * @param id Id of the authorization policy that will be used in @authorize decorator * @param authorizer Authorization logic, a lambda function return true to authorize otherwise false */ define(id, authorizer) { const entity = this.entity; class Policy extends EntityAuthPolicy { constructor() { super(id, entity, authorizer); } } return Policy; } /** * Register authorization policy into authorization cache * @param id Id of the authorization policy that will be used in @authorize decorator * @param authorizer Authorization logic, a lambda function return true to authorize otherwise false */ register(id, authorizer) { const Policy = this.define(id, authorizer); this.globalCache.push(Policy); return this; } } const globalPolicies = []; exports.globalPolicies = globalPolicies; function authPolicy() { return new AuthPolicyBuilder(globalPolicies); } exports.authPolicy = authPolicy; function entityPolicy(entity) { return new EntityPolicyBuilder(entity, globalPolicies); } exports.entityPolicy = entityPolicy; // --------------------------------------------------------------------- // // ---------------------- MAIN AUTHORIZER FUNCTION --------------------- // // --------------------------------------------------------------------- // function executeAuthorizer(decorator, info) { const policies = Array.isArray(decorator) ? decorator.map(x => x.policies).flatten() : decorator.policies; const instance = new PolicyAuthorizer(info.ctx.config.authPolicies, policies); return instance.authorize(info); } exports.executeAuthorizer = executeAuthorizer; // --------------------------------------------------------------------- // // ----------------- CONTROLLER OR ACTION AUTHORIZATION ---------------- // // --------------------------------------------------------------------- // function fixContext(decorator, info) { if (decorator.location === "Class") { info.metadata.current = info.ctx.route.controller; } if (decorator.location === "Method") { info.metadata.current = Object.assign(Object.assign({}, info.ctx.route.action), { parent: info.ctx.route.controller.type }); } return info; } async function checkUserAccessToRoute(decorators, info) { const conditions = await Promise.all(decorators.map(x => executeAuthorizer(x, fixContext(x, info)))); // if authorized once then pass if (conditions.some(x => x === true)) return; // if not then throw error accordingly throwAuthError(info); } function createContext(ctx, value, meta) { const info = Object.assign({}, ctx.info); const metadata = Object.assign({}, info.metadata); metadata.current = Object.assign(Object.assign({}, meta), { parent: ctx.parent }); info.value = value; info.parentValue = ctx.parentValue; info.metadata = metadata; return info; } async function checkParameter(meta, value, ctx) { if (value === undefined || value === null) return []; // skip check on GET method if (ctx.info.ctx.method === "GET") return []; const decorators = meta.decorators.filter(createDecoratorFilter(x => x.access === "write")); if (decorators.length > 0) { const info = createContext(ctx, value, meta); const allowed = await executeAuthorizer(decorators, info); if (!allowed) return [ctx.path.join(".")]; } // if the property is a relation property just skip checking, since we allow set relation using ID const isRelation = meta.decorators.some((x) => x.kind === "plumier-meta:relation"); if (isRelation) return []; // loop through property of type array if (Array.isArray(meta.type)) { const newMeta = Object.assign(Object.assign({}, meta), { type: meta.type[0] }); const result = []; for (let i = 0; i < value.length; i++) { const val = value[i]; result.push(...await checkParameter(newMeta, val, Object.assign(Object.assign({}, ctx), { path: ctx.path.concat(i.toString()) }))); } return result; } // loop through custom class properties if ((0, common_1.isCustomClass)(meta.type)) { const classMeta = (0, reflect_1.reflect)(meta.type); const values = classMeta.properties.map(x => value[x.name]); return checkParameters(classMeta.properties, values, Object.assign(Object.assign({}, ctx), { parent: meta.type, parentValue: value })); } // everything when fine then just return [] return []; } async function checkParameters(meta, value, ctx) { const result = []; for (let i = 0; i < meta.length; i++) { const prop = meta[i]; const issues = await checkParameter(prop, value[i], Object.assign(Object.assign({}, ctx), { path: ctx.path.concat(prop.name) })); result.push(...issues); } return result; } async function checkUserAccessToParameters(meta, values, info) { const unauthorizedPaths = await checkParameters(meta, values, { info, path: [], parent: info.ctx.route.controller.type }); if (unauthorizedPaths.length > 0) throwAuthError(info, `Unauthorized to populate parameter paths (${unauthorizedPaths.join(", ")})`); } async function createPropertyNode(prop, authPolicies) { const decorators = prop.decorators.filter(createDecoratorFilter(x => x.access === "read")); let policies = []; for (const dec of decorators) { policies.push(...dec.policies); } // if no authorize decorator then always allow to access const authorizer = policies.length === 0 ? true : new PolicyAuthorizer(authPolicies, policies); return { name: prop.name, authorizer }; } async function compileType(type, authPolicies, parentTypes) { if (Array.isArray(type)) return { kind: "Array", child: await compileType(type[0], authPolicies, parentTypes) }; if ((0, common_1.isCustomClass)(type)) { // CIRCULAR: just return basic node if circular dependency happened if (parentTypes.some(x => x === type)) return { kind: "Class", properties: [] }; const meta = (0, reflect_1.reflect)(type); const properties = []; for (const prop of meta.properties) { const meta = Object.assign(Object.assign({}, prop), { parent: type }); const propNode = await createPropertyNode(prop, authPolicies); properties.push(Object.assign(Object.assign({}, propNode), { meta, type: await compileType(prop.type, authPolicies, parentTypes.concat(type)) })); } return { kind: "Class", properties }; } return { kind: "Value" }; } async function getAuthorize(authorizers, ctx) { if (typeof authorizers === "boolean") return authorizers; return authorizers.authorize(ctx); } async function filterType(raw, node, ctx) { var _a; if (node.kind === "Array") { const result = []; if (!Array.isArray(raw)) throw new Error(`Action ${ctx.ctx.route.controller.name}.${ctx.ctx.route.action.name} expecting return value of type Array but got ${raw.constructor.name}`); for (const item of raw) { const val = await filterType(item, node.child, ctx); if (val !== undefined) result.push(val); } return result.length === 0 ? undefined : result; } else if (node.kind === "Class") { const result = {}; for (const prop of node.properties) { const value = raw[prop.name]; if (value === null || value === undefined) continue; const authorized = await getAuthorize(prop.authorizer, Object.assign(Object.assign({}, ctx), { value, parentValue: raw, metadata: Object.assign(Object.assign({}, ctx.metadata), { current: prop.meta }) })); if (authorized) { const candidate = await filterType(value, prop.type, ctx); const transform = (_a = ctx.ctx.config.responseTransformer) !== null && _a !== void 0 ? _a : ((a, b) => b); const val = transform(prop.meta, candidate); if (val !== undefined) result[prop.name] = val; } } return Object.keys(result).length === 0 && result.constructor === Object ? undefined : result; } else return raw; } async function responseAuthorize(raw, ctx) { var _a; const getType = (resp) => { return !!resp ? (reflect_1.reflection.isCallback(resp.type) ? resp.type({}) : resp.type) : undefined; }; const responseType = ctx.route.action.decorators.find((x) => x.kind === "plumier-meta:response-type"); const type = (_a = getType(responseType)) !== null && _a !== void 0 ? _a : ctx.route.action.returnType; if (type !== Promise && type && raw.status === 200 && raw.body) { const info = createAuthContext(ctx, "read"); const node = await compileType(type, ctx.config.authPolicies, []); raw.body = Array.isArray(raw.body) && raw.body.length === 0 ? [] : await filterType(raw.body, node, info); return raw; } else { return raw; } } // --------------------------------------------------------------------- // // ----------------------------- MIDDLEWARE ---------------------------- // // --------------------------------------------------------------------- // async function checkAuthorize(ctx) { if (ctx.config.enableAuthorization) { const { route, parameters, config } = ctx; const info = createAuthContext(ctx, "route"); const decorator = getRouteAuthorizeDecorators(route, config.globalAuthorizations); //check user access await checkUserAccessToRoute(decorator, info); //if ok check parameter access await checkUserAccessToParameters(route.action.parameters, parameters, Object.assign(Object.assign({}, info), { access: "write" })); } } exports.checkAuthorize = checkAuthorize; class AuthorizerMiddleware { constructor() { } async execute(invocation) { Object.assign(invocation.ctx, { user: invocation.ctx.state.user }); await checkAuthorize(invocation.ctx); const result = await invocation.proceed(); return responseAuthorize(result, invocation.ctx); } } exports.AuthorizerMiddleware = AuthorizerMiddleware;