@plumier/core
Version:
Delightful Node.js Rest Framework
480 lines (479 loc) • 20.8 kB
JavaScript
;
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;