@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
255 lines (235 loc) • 7.65 kB
text/typescript
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);
}
}
}