UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

255 lines (235 loc) 7.65 kB
import flatten from "lodash/flatten"; import pickBy from "lodash/pickBy"; import { ID, type InsertFieldsRequired, type InsertInput, type Row, type Table, type UpdateInput, } from "../types"; import { EntNotInsertableError } from "./errors/EntNotInsertableError"; import { EntNotReadableError } from "./errors/EntNotReadableError"; import { EntNotUpdatableError } from "./errors/EntNotUpdatableError"; import { EntValidationError } from "./errors/EntValidationError"; import type { AbstractIs } from "./predicates/AbstractIs"; import type { AllowIf } from "./rules/AllowIf"; import type { DenyIf } from "./rules/DenyIf"; import { evaluate } from "./rules/evaluate"; import { Require } from "./rules/Require"; import type { Rule } from "./rules/Rule"; import { buildUpdateNewRow } from "./Triggers"; import type { VC } from "./VC"; export type LoadRule<TInput extends object> = AllowIf<TInput> | DenyIf<TInput>; /** * For safety, we enforce all Require rules to be in the end of the * insert/update/delete privacy list, and have at least one of them. In * TypeScript, it's not possible to create [...L[], R, ...R[]] type * (double-variadic) when both L[] and R[] are open-ended (i.e. tuples with * unknown length), so we have to brute-force. */ export type WriteRules<TInput extends object> = | [] | [Require<TInput>, ...Array<Require<TInput>>] | [LoadRule<TInput>, Require<TInput>, ...Array<Require<TInput>>] | [ LoadRule<TInput>, LoadRule<TInput>, Require<TInput>, ...Array<Require<TInput>>, ] | [ LoadRule<TInput>, LoadRule<TInput>, LoadRule<TInput>, Require<TInput>, ...Array<Require<TInput>>, ] | [ LoadRule<TInput>, LoadRule<TInput>, LoadRule<TInput>, LoadRule<TInput>, Require<TInput>, ...Array<Require<TInput>>, ]; export type ValidationRules<TTable extends Table> = { readonly tenantPrincipalField?: InsertFieldsRequired<TTable> & string; readonly inferPrincipal: (vc: VC, row: Row<TTable>) => Promise<VC>; readonly load: Validation<TTable>["load"]; readonly insert: Validation<TTable>["insert"]; readonly update?: Validation<TTable>["update"]; readonly delete?: Validation<TTable>["delete"]; readonly validate?: Array<AbstractIs<InsertInput<TTable>>>; }; export class Validation<TTable extends Table> { readonly tenantPrincipalField?: ValidationRules<TTable>["tenantPrincipalField"]; readonly inferPrincipal: ValidationRules<TTable>["inferPrincipal"]; readonly load: Array<LoadRule<Row<TTable>>>; readonly insert: WriteRules<InsertInput<TTable>>; readonly update: WriteRules<Row<TTable>>; readonly delete: WriteRules<Row<TTable>>; readonly validate: Array<Require<InsertInput<TTable>>>; constructor( private entName: string, rules: ValidationRules<TTable>, ) { this.tenantPrincipalField = rules.tenantPrincipalField; this.inferPrincipal = rules.inferPrincipal; this.load = rules.load; this.insert = rules.insert; this.update = rules.update || (this.insert as typeof this.update); this.delete = rules.delete || this.update; this.validate = (rules.validate || []).map((pred) => new Require(pred)); } async validateLoad(vc: VC, row: Row<TTable>): Promise<void> { await this.validatePrivacyImpl( "load", this.load, vc, row, "sequential", EntNotReadableError, ); } async validateInsert(vc: VC, input: InsertInput<TTable>): Promise<void> { await this.validateUserInputImpl(vc, input, input); await this.validatePrivacyImpl( "insert", this.insert, vc, input, "parallel", EntNotInsertableError, ); } async validateUpdate( vc: VC, old: Row<TTable>, input: UpdateInput<TTable>, privacyOnly = false, ): Promise<void> { // Simulate the update, as if it's applied to the ent. const newRow = buildUpdateNewRow(old, input); if (!privacyOnly) { await this.validateUserInputImpl( vc, newRow as InsertInput<TTable>, input, ); } await this.validatePrivacyImpl( "update", this.update, vc, newRow, "parallel", EntNotUpdatableError, ); } async validateDelete(vc: VC, row: Row<TTable>): Promise<void> { await this.validatePrivacyImpl( "delete", this.delete, vc, row, "parallel", EntNotUpdatableError, // same exception as for update ); } private async validatePrivacyImpl( op: string, rules: Array<Rule<object>>, vc: VC, row: object, fashion: "parallel" | "sequential", ExceptionClass: | typeof EntNotReadableError | typeof EntNotInsertableError | typeof EntNotUpdatableError, ): Promise<void> { this.validateTenantUserIDImpl(vc, row, ExceptionClass); const { allow, cause } = rules.length > 0 ? await evaluate(vc, row, rules, fashion) : { allow: false, cause: `No "${op}" rules defined` }; if (allow) { return; } throw new ExceptionClass( this.entName, vc.toString(), { [ID]: "?", ...row }, cause, ); } private validateTenantUserIDImpl( vc: VC, row: object, ExceptionClass: | typeof EntNotReadableError | typeof EntNotInsertableError | typeof EntNotUpdatableError, ): void { if (this.tenantPrincipalField === undefined) { return; } const rowTenantUserID = (row as Record<string, unknown>)[ this.tenantPrincipalField ]; if (rowTenantUserID === vc.principal) { return; } throw new ExceptionClass( this.entName, vc.toString(), { [ID]: "?", ...row }, `${this.tenantPrincipalField} is expected to be ` + JSON.stringify(vc.principal) + ", but got " + JSON.stringify(rowTenantUserID), ); } private async validateUserInputImpl( vc: VC, newRow: InsertInput<TTable>, input: object, ): Promise<void> { // Validation error details (like field name and message) are propagated // through results[].cause which is EntValidationError. const { allow, results } = await evaluate( vc, newRow, this.validate, "parallel", ); if (allow) { // Quick path (expected to fire most of the time). return; } // If some predicates failed, we ensure that they relate to the fields which // we actually touched. This makes sense for e.g. UPDATE: if we don't update // some field, it doesn't make sense to user-validate it. const touchedFields = Object.keys(pickBy(input, (v) => v !== undefined)); const errors = flatten( results .filter(({ decision }) => decision === "DENY") .map(({ cause, rule }) => { // It's safe to cast to AbstractIs, because it's how we build // this.validate array in our constructor. const { name, field, message } = rule.predicate as AbstractIs<object>; return cause instanceof EntValidationError ? cause.errors : cause === null ? // The Predicate just returned false. [{ field, message: message ?? `${name} returned false` }] : // Some other error; we must not expose error message details, // but can at least hint on the error class name. [{ field, message: cause.name }]; }), ).filter(({ field }) => field === null || touchedFields.includes(field)); if (errors.length > 0) { throw new EntValidationError(this.entName, errors); } } }