@aventara/core 1.0.0-rc.3 → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -1
- package/ERRORS.md +6 -6
- package/README.md +72 -12
- package/dist/adapters/adapter-operation.d.ts +7 -0
- package/dist/compiler/contract.compiler.d.ts +6 -0
- package/dist/compiler/contract.compiler.js +73 -12
- package/dist/config/computed-fields.js +2 -9
- package/dist/config/config-diagnostic.guidance.js +3 -3
- package/dist/config/config.resolver.d.ts +14 -0
- package/dist/config/config.resolver.js +31 -0
- package/dist/config/config.validator.js +82 -0
- package/dist/config/exact-framework-config.d.ts +5 -0
- package/dist/config/framework-config.d.ts +26 -1
- package/dist/contracts/client-contract-structure.validator.d.ts +8 -0
- package/dist/contracts/client-contract-structure.validator.js +6 -1
- package/dist/contracts/field.d.ts +11 -0
- package/dist/framework/contract-view.d.ts +41 -10
- package/dist/framework/framework.factory.js +39 -11
- package/dist/framework/operation-facade.d.ts +24 -5
- package/dist/framework/operation-facade.js +2 -1
- package/dist/index.d.ts +4 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/operations/operation-arguments.d.ts +1 -1
- package/dist/operations/operation-call.d.ts +9 -5
- package/dist/operations/operation-result.d.ts +5 -3
- package/dist/operations/operation-validator-core.js +6 -0
- package/dist/policy/policy-admission.d.ts +22 -0
- package/dist/policy/policy-admission.js +400 -0
- package/dist/policy/policy-after-update.runtime.d.ts +29 -0
- package/dist/policy/policy-after-update.runtime.js +131 -0
- package/dist/policy/policy-condition.d.ts +15 -0
- package/dist/policy/policy-condition.js +36 -0
- package/dist/policy/policy-engine.d.ts +111 -0
- package/dist/policy/policy-engine.js +1 -0
- package/dist/policy/policy-nested-write.runtime.d.ts +38 -0
- package/dist/policy/policy-nested-write.runtime.js +449 -0
- package/dist/policy/policy-read.runtime.d.ts +83 -0
- package/dist/policy/policy-read.runtime.js +494 -0
- package/dist/policy/policy-request.d.ts +37 -0
- package/dist/policy/policy-request.js +52 -0
- package/dist/policy/policy-session.d.ts +83 -0
- package/dist/policy/policy-session.js +234 -0
- package/dist/policy/policy-startup.validator.d.ts +8 -0
- package/dist/policy/policy-startup.validator.js +106 -0
- package/dist/policy/policy-write.runtime.d.ts +30 -0
- package/dist/policy/policy-write.runtime.js +394 -0
- package/dist/prebuilt/core-version.generated.d.ts +2 -0
- package/dist/prebuilt/core-version.generated.js +1 -0
- package/dist/prebuilt/prebuilt-contracts.d.ts +40 -0
- package/dist/prebuilt/prebuilt-contracts.emitter.d.ts +16 -0
- package/dist/prebuilt/prebuilt-contracts.emitter.js +50 -0
- package/dist/prebuilt/prebuilt-contracts.js +1 -0
- package/dist/prebuilt/prebuilt-contracts.loader.d.ts +21 -0
- package/dist/prebuilt/prebuilt-contracts.loader.js +122 -0
- package/dist/prebuilt/prebuilt-inputs.fingerprint.d.ts +16 -0
- package/dist/prebuilt/prebuilt-inputs.fingerprint.js +192 -0
- package/dist/runtime/computed-read-runtime.d.ts +6 -1
- package/dist/runtime/computed-read-runtime.js +224 -45
- package/dist/runtime/context-executor.runtime.js +4 -2
- package/dist/runtime/execution-options.d.ts +8 -0
- package/dist/runtime/framework-warning.emitter.d.ts +6 -0
- package/dist/runtime/framework-warning.emitter.js +8 -0
- package/dist/runtime/level-lifecycle.runtime.js +3 -2
- package/dist/runtime/operation-code.guidance.js +3 -3
- package/dist/runtime/operation-request.d.ts +7 -0
- package/dist/runtime/operation-runtime.d.ts +9 -1
- package/dist/runtime/operation-runtime.js +114 -10
- package/dist/runtime/readonly-value.d.ts +5 -1
- package/dist/runtime/readonly-value.js +67 -28
- package/dist/transactions/transaction-runtime.d.ts +4 -1
- package/dist/transactions/transaction-runtime.js +27 -5
- package/package.json +2 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function allOf(left, right) {
|
|
2
|
+
if (left === false || right === false) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
if (left === true) {
|
|
6
|
+
return right;
|
|
7
|
+
}
|
|
8
|
+
if (right === true) {
|
|
9
|
+
return left;
|
|
10
|
+
}
|
|
11
|
+
return { AND: [left, right] };
|
|
12
|
+
}
|
|
13
|
+
export function anyOf(left, right) {
|
|
14
|
+
if (left === true || right === true) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (left === false) {
|
|
18
|
+
return right;
|
|
19
|
+
}
|
|
20
|
+
if (right === false) {
|
|
21
|
+
return left;
|
|
22
|
+
}
|
|
23
|
+
return { OR: [left, right] };
|
|
24
|
+
}
|
|
25
|
+
export function everyOf(conditions) {
|
|
26
|
+
return conditions.reduce(allOf, true);
|
|
27
|
+
}
|
|
28
|
+
export function someOf(conditions) {
|
|
29
|
+
return conditions.reduce(anyOf, false);
|
|
30
|
+
}
|
|
31
|
+
export function noneOf(condition) {
|
|
32
|
+
return typeof condition === "boolean" ? !condition : { NOT: condition };
|
|
33
|
+
}
|
|
34
|
+
export function whereOf(condition) {
|
|
35
|
+
return condition === true ? {} : condition;
|
|
36
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { AvRequestOrigin } from "../config/config-layer.js";
|
|
2
|
+
/** An answer now or later: every question may be answered asynchronously. */
|
|
3
|
+
type PolicyAnswer<T> = T | Promise<T>;
|
|
4
|
+
/**
|
|
5
|
+
* A fragment of Aventara's own `where` grammar, as a policy states a row
|
|
6
|
+
* condition: a scope, a field's per-row grant, or the question an engine's rule
|
|
7
|
+
* asks through `exists`. It is validated against the Core Contract when it is
|
|
8
|
+
* used, never the caller's, so it may name a field the caller cannot see
|
|
9
|
+
* (`ownerId`). One that does not validate is the server's defect: the request
|
|
10
|
+
* answers `A3000`.
|
|
11
|
+
*/
|
|
12
|
+
export type AvPolicyWhere = {
|
|
13
|
+
readonly [key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
/** What every question to the engine states. */
|
|
16
|
+
type PolicyQuestion = {
|
|
17
|
+
/** What the configuration's `policy.principal` derived for the request, or the `as` of an application call. */
|
|
18
|
+
readonly principal: unknown;
|
|
19
|
+
/** Which layer the request runs under. */
|
|
20
|
+
readonly origin: AvRequestOrigin;
|
|
21
|
+
/** The Resource the question is about. */
|
|
22
|
+
readonly resource: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The policy engine a Framework consults (root configuration `policy.engine`).
|
|
26
|
+
* Core asks it three questions per request and Resource, and enforces the
|
|
27
|
+
* answers: the operation (`can`), the rows (`scopeOf`) and the fields
|
|
28
|
+
* (`fieldsOf`). Every method may answer with a promise.
|
|
29
|
+
*
|
|
30
|
+
* `Governed` is the engine's `governed` set, stated by its type so the
|
|
31
|
+
* application's types leave those fields out of every default projection, as
|
|
32
|
+
* the Contracts do.
|
|
33
|
+
*/
|
|
34
|
+
export interface AvPolicyEngine<Governed extends {
|
|
35
|
+
readonly [resource: string]: readonly string[];
|
|
36
|
+
} = {
|
|
37
|
+
readonly [resource: string]: readonly string[];
|
|
38
|
+
}> {
|
|
39
|
+
/**
|
|
40
|
+
* The fields and relations the policy governs, by Resource. Each is left out
|
|
41
|
+
* of every default projection, for every caller, and is the only trace the
|
|
42
|
+
* policy leaves in a Contract (a `governed: true` mark). A caller that wants
|
|
43
|
+
* one names it, and is refused (`A4002`) unless it may read it on every row
|
|
44
|
+
* it uses it on. Checked against the Core Contract at startup.
|
|
45
|
+
*/
|
|
46
|
+
readonly governed?: Governed;
|
|
47
|
+
/**
|
|
48
|
+
* The relation paths and filter operators the engine's rules filter on, by
|
|
49
|
+
* the rule's name, each path a list of segments:
|
|
50
|
+
* `["Note", "campaign", "memberships", "some", "userId", "equals"]`, a
|
|
51
|
+
* Resource, then its fields and relations (a to-many relation followed by
|
|
52
|
+
* `some`, `every` or `none`), then the scalar field, then optionally the
|
|
53
|
+
* filter operator. Checked against the Core Contract at startup: a path it
|
|
54
|
+
* cannot filter fails construction, naming the rule.
|
|
55
|
+
*/
|
|
56
|
+
readonly paths?: {
|
|
57
|
+
readonly [rule: string]: readonly (readonly string[])[];
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The writes whose grants carry an `after` condition, by Resource: a row
|
|
61
|
+
* they change must still meet it once changed (`{ Note: ["update"] }`).
|
|
62
|
+
* Inside a transaction core asks `can` about each row every checked update
|
|
63
|
+
* changed, listed or not, and rolls the write back (`A4002`) when one fails.
|
|
64
|
+
* This list decides where the operation has no interactive transaction:
|
|
65
|
+
* these updates are refused there (`A4002`), and every other one runs
|
|
66
|
+
* without the after check. Checked against the Core Contract at startup.
|
|
67
|
+
*/
|
|
68
|
+
readonly afterChecks?: {
|
|
69
|
+
readonly [resource: string]: readonly "update"[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether the principal may run `action` on `resource`. Asked once per
|
|
73
|
+
* request before any developer stage, without a row: `false` refuses the
|
|
74
|
+
* request (`A4002`). Asked again with the row a write leaves: each row a
|
|
75
|
+
* create inserts (before the insert), and each row an update changed (after
|
|
76
|
+
* the write, inside its transaction; see `afterChecks`); `false` refuses the
|
|
77
|
+
* write. Rules that need a fact the row does not carry ask it
|
|
78
|
+
* through `exists`, which core runs through the adapter, unscoped, inside
|
|
79
|
+
* the write's transaction when it has one, and answers with a boolean only.
|
|
80
|
+
*/
|
|
81
|
+
can(question: PolicyQuestion & {
|
|
82
|
+
readonly action: "read" | "create" | "update" | "delete";
|
|
83
|
+
readonly row?: Readonly<Record<string, unknown>>;
|
|
84
|
+
readonly exists: (resource: string, where: AvPolicyWhere) => Promise<boolean>;
|
|
85
|
+
}): PolicyAnswer<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* The rows the principal may `action`: `true` every row, `false` none, or a
|
|
88
|
+
* condition core ANDs into the request at every level that reads the
|
|
89
|
+
* Resource (its `where`, an included relation's, a relation filter, a
|
|
90
|
+
* relation count).
|
|
91
|
+
*/
|
|
92
|
+
scopeOf(question: PolicyQuestion & {
|
|
93
|
+
readonly action: "read" | "update" | "delete";
|
|
94
|
+
}): PolicyAnswer<boolean | AvPolicyWhere>;
|
|
95
|
+
/**
|
|
96
|
+
* The fields the principal may read or write. `always` lists the fields it
|
|
97
|
+
* may use on every row it can reach (`"all"`: every field that is not
|
|
98
|
+
* governed); `perRow` maps a field it may use only on some rows to the
|
|
99
|
+
* condition those rows meet. A field in neither is refused (`A4002`)
|
|
100
|
+
* wherever the request names it.
|
|
101
|
+
*/
|
|
102
|
+
fieldsOf(question: PolicyQuestion & {
|
|
103
|
+
readonly access: "read" | "write";
|
|
104
|
+
}): PolicyAnswer<{
|
|
105
|
+
readonly always: "all" | readonly string[];
|
|
106
|
+
readonly perRow: {
|
|
107
|
+
readonly [field: string]: AvPolicyWhere | undefined;
|
|
108
|
+
};
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AvAdapterModel } from "../adapters/adapter-model.js";
|
|
2
|
+
import type { AvContractCore } from "../contracts/contract.js";
|
|
3
|
+
import type { AvOperationFailureResponse } from "../runtime/operation-response.js";
|
|
4
|
+
import { type PolicyCondition } from "./policy-condition.js";
|
|
5
|
+
import { type PolicySession } from "./policy-session.js";
|
|
6
|
+
type Row = Readonly<Record<string, unknown>>;
|
|
7
|
+
/** The rows a level's records write: new rows, or rows a condition reaches before the write. */
|
|
8
|
+
export type NestedParent = {
|
|
9
|
+
readonly kind: "new";
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: "existing";
|
|
12
|
+
readonly reach: PolicyCondition;
|
|
13
|
+
};
|
|
14
|
+
/** The checks after the write: told the root rows it wrote, the refusal or `undefined`. */
|
|
15
|
+
export type NestedWriteCheck = (rows: readonly Row[]) => Promise<AvOperationFailureResponse | undefined>;
|
|
16
|
+
/**
|
|
17
|
+
* What a nested write needs: a refusal before it runs, or the root record the
|
|
18
|
+
* adapter runs (its to-many `$update` and `$delete` narrowed to the rows within
|
|
19
|
+
* their scopes) and the check after it.
|
|
20
|
+
*/
|
|
21
|
+
export type NestedWritePlan = {
|
|
22
|
+
readonly kind: "refused";
|
|
23
|
+
readonly response: AvOperationFailureResponse;
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: "planned";
|
|
26
|
+
readonly record: Row;
|
|
27
|
+
readonly after: NestedWriteCheck;
|
|
28
|
+
};
|
|
29
|
+
/** Whether `record` of `resource` writes a relation through a directive. */
|
|
30
|
+
export declare function writesNested(core: AvContractCore, resource: string, record: unknown): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The policy of the nested writes in one root record (see the module comment):
|
|
33
|
+
* the refusal before the call, or the checks after it. `created` says the root
|
|
34
|
+
* rows are created by this write, so they too are read as stored and asked
|
|
35
|
+
* `can({ action: "create", row })` after the call.
|
|
36
|
+
*/
|
|
37
|
+
export declare function planNestedWrites(session: PolicySession, core: AvContractCore, model: AvAdapterModel, resource: string, record: Row, parent: NestedParent): Promise<NestedWritePlan>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { lookupField, lookupResource } from "../contracts/contract-lookup.js";
|
|
2
|
+
import { isRelationMutationDirective } from "../runtime/mutation-planner.js";
|
|
3
|
+
import { frozenCopy } from "../runtime/readonly-value.js";
|
|
4
|
+
import { policyRefusal } from "./policy-admission.js";
|
|
5
|
+
import { afterUpdate, checkChanged, rowFieldsOf, } from "./policy-after-update.runtime.js";
|
|
6
|
+
import { allOf, noneOf, someOf, whereOf, } from "./policy-condition.js";
|
|
7
|
+
import { identifiedRows, identifierOf } from "./policy-read.runtime.js";
|
|
8
|
+
import { fieldAccess } from "./policy-session.js";
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
if (!isRecord(value)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const prototype = Object.getPrototypeOf(value);
|
|
17
|
+
return prototype === Object.prototype || prototype === null;
|
|
18
|
+
}
|
|
19
|
+
const DROPPED = Symbol("dropped");
|
|
20
|
+
function rewritten(value, replaced) {
|
|
21
|
+
if (value !== null && typeof value === "object" && replaced.has(value)) {
|
|
22
|
+
return replaced.get(value);
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
const entries = value.map((entry) => rewritten(entry, replaced));
|
|
26
|
+
return entries.every((entry, index) => entry === value[index])
|
|
27
|
+
? value
|
|
28
|
+
: entries.filter((entry) => entry !== DROPPED);
|
|
29
|
+
}
|
|
30
|
+
if (!isPlainObject(value)) {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
let changed = false;
|
|
34
|
+
const copy = {};
|
|
35
|
+
for (const [key, member] of Object.entries(value)) {
|
|
36
|
+
const next = rewritten(member, replaced);
|
|
37
|
+
changed ||= next !== member;
|
|
38
|
+
copy[key] = next === DROPPED ? [] : next;
|
|
39
|
+
}
|
|
40
|
+
return changed ? copy : value;
|
|
41
|
+
}
|
|
42
|
+
function conditionOf(where) {
|
|
43
|
+
return isRecord(where) && Object.keys(where).length > 0 ? where : true;
|
|
44
|
+
}
|
|
45
|
+
function relationOf(core, resource, name) {
|
|
46
|
+
const field = lookupField(lookupResource(core, resource), name);
|
|
47
|
+
return field?.kind === "relation"
|
|
48
|
+
? field
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
export function writesNested(core, resource, record) {
|
|
52
|
+
return (isRecord(record) &&
|
|
53
|
+
Object.entries(record).some(([key, value]) => isRecord(value) &&
|
|
54
|
+
relationOf(core, resource, key) !== undefined &&
|
|
55
|
+
Object.keys(value).some(isRelationMutationDirective)));
|
|
56
|
+
}
|
|
57
|
+
class NestedRefusal extends Error {
|
|
58
|
+
name = "NestedRefusal";
|
|
59
|
+
}
|
|
60
|
+
const QUESTION_MESSAGES = {
|
|
61
|
+
link: (resource) => `The policy does not allow this nested write to link or unlink a row of Resource "${resource}" outside what the caller may change or read.`,
|
|
62
|
+
update: (resource) => `The policy does not allow this nested write to change a row of Resource "${resource}" outside what the caller may change.`,
|
|
63
|
+
delete: (resource) => `The policy does not allow this nested write to delete a row of Resource "${resource}" outside what the caller may delete.`,
|
|
64
|
+
fields: (resource) => `The policy does not allow this nested write to write a field of Resource "${resource}" on every row it reaches.`,
|
|
65
|
+
};
|
|
66
|
+
function linkedTo(inverse, parents) {
|
|
67
|
+
if (parents === false) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const reach = whereOf(parents);
|
|
71
|
+
return inverse.cardinality === "one"
|
|
72
|
+
? { [inverse.name]: reach }
|
|
73
|
+
: { [inverse.name]: { some: reach } };
|
|
74
|
+
}
|
|
75
|
+
class NestedWalk {
|
|
76
|
+
session;
|
|
77
|
+
core;
|
|
78
|
+
model;
|
|
79
|
+
questions = new Map();
|
|
80
|
+
replaced = new Map();
|
|
81
|
+
constructor(session, core, model) {
|
|
82
|
+
this.session = session;
|
|
83
|
+
this.core = core;
|
|
84
|
+
this.model = model;
|
|
85
|
+
}
|
|
86
|
+
ask(resource, kind, condition) {
|
|
87
|
+
if (condition === false) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const key = `${resource}\u0000${kind}`;
|
|
91
|
+
let question = this.questions.get(key);
|
|
92
|
+
if (question === undefined) {
|
|
93
|
+
question = { resource, kind, conditions: [] };
|
|
94
|
+
this.questions.set(key, question);
|
|
95
|
+
}
|
|
96
|
+
question.conditions.push(condition);
|
|
97
|
+
}
|
|
98
|
+
async answer() {
|
|
99
|
+
for (const { resource, kind, conditions } of this.questions.values()) {
|
|
100
|
+
const reach = someOf(conditions);
|
|
101
|
+
if (reach !== false &&
|
|
102
|
+
(await this.session.exists(resource, whereOf(reach)))) {
|
|
103
|
+
return policyRefusal(QUESTION_MESSAGES[kind](resource));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
identifier(resource) {
|
|
109
|
+
const identifier = identifierOf(this.model, resource);
|
|
110
|
+
if (identifier.length === 0) {
|
|
111
|
+
throw new NestedRefusal(`The policy checks the rows a nested write reaches in Resource "${resource}" by identifier, and the Resource has none.`);
|
|
112
|
+
}
|
|
113
|
+
return identifier;
|
|
114
|
+
}
|
|
115
|
+
async grantsOf(resource, records) {
|
|
116
|
+
const fields = await this.session.fields(resource, "write");
|
|
117
|
+
const grants = new Map();
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
for (const [name, value] of Object.entries(record)) {
|
|
120
|
+
if (value === undefined) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const relation = relationOf(this.core, resource, name);
|
|
124
|
+
for (const member of [name, ...(relation?.referenceFields ?? [])]) {
|
|
125
|
+
const access = fieldAccess(this.core, resource, fields, member);
|
|
126
|
+
if (access === "never") {
|
|
127
|
+
throw new NestedRefusal(`The policy does not allow writing field "${member}" of Resource "${resource}".`);
|
|
128
|
+
}
|
|
129
|
+
if (access !== "always") {
|
|
130
|
+
grants.set(member, access);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...grants.values()];
|
|
136
|
+
}
|
|
137
|
+
async level(resource, parent, records) {
|
|
138
|
+
const relations = new Map();
|
|
139
|
+
for (const record of records) {
|
|
140
|
+
for (const [key, value] of Object.entries(record)) {
|
|
141
|
+
if (!isRecord(value) ||
|
|
142
|
+
relationOf(this.core, resource, key) === undefined) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (const [directive, operand] of Object.entries(value)) {
|
|
146
|
+
if (isRelationMutationDirective(directive) && operand !== undefined) {
|
|
147
|
+
let operands = relations.get(key);
|
|
148
|
+
if (operands === undefined) {
|
|
149
|
+
operands = [];
|
|
150
|
+
relations.set(key, operands);
|
|
151
|
+
}
|
|
152
|
+
operands.push([directive, operand]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const checks = [];
|
|
158
|
+
for (const [name, operands] of relations) {
|
|
159
|
+
checks.push(...(await this.relation(resource, parent, name, operands)));
|
|
160
|
+
}
|
|
161
|
+
return checks;
|
|
162
|
+
}
|
|
163
|
+
async relation(resource, parent, name, operands) {
|
|
164
|
+
const core = this.core;
|
|
165
|
+
const relation = relationOf(core, resource, name);
|
|
166
|
+
const target = relation.target;
|
|
167
|
+
const back = relation.inverse === undefined
|
|
168
|
+
? undefined
|
|
169
|
+
: relationOf(core, target, relation.inverse);
|
|
170
|
+
const inverse = back === undefined || relation.inverse === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: { name: relation.inverse, cardinality: back.cardinality };
|
|
173
|
+
const throughInverse = () => {
|
|
174
|
+
if (inverse === undefined) {
|
|
175
|
+
throw new NestedRefusal(`The policy checks the rows relation "${name}" of Resource "${resource}" reaches through its other end, and the relation states none.`);
|
|
176
|
+
}
|
|
177
|
+
return inverse;
|
|
178
|
+
};
|
|
179
|
+
let reached;
|
|
180
|
+
const linked = () => {
|
|
181
|
+
reached ??=
|
|
182
|
+
parent.kind === "new"
|
|
183
|
+
? false
|
|
184
|
+
: linkedTo(throughInverse(), parent.reach);
|
|
185
|
+
return reached;
|
|
186
|
+
};
|
|
187
|
+
const read = await this.session.scope(target, "read");
|
|
188
|
+
const change = allOf(await this.session.scope(target, "update"), read);
|
|
189
|
+
const remove = allOf(await this.session.scope(target, "delete"), read);
|
|
190
|
+
const targetHolds = relation.referenceFields === undefined;
|
|
191
|
+
const linkScope = targetHolds ? change : read;
|
|
192
|
+
const many = relation.cardinality === "many";
|
|
193
|
+
const selectors = [];
|
|
194
|
+
const created = [];
|
|
195
|
+
const checks = [];
|
|
196
|
+
const changing = [];
|
|
197
|
+
const link = (selector) => {
|
|
198
|
+
if (isRecord(selector)) {
|
|
199
|
+
selectors.push(selector);
|
|
200
|
+
this.ask(target, "link", allOf(selector, noneOf(linkScope)));
|
|
201
|
+
if (targetHolds) {
|
|
202
|
+
changing.push(selector);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const unlinkLinked = (except) => {
|
|
207
|
+
const unlinked = allOf(linked(), noneOf(except));
|
|
208
|
+
this.ask(target, "link", allOf(unlinked, noneOf(linkScope)));
|
|
209
|
+
if (targetHolds) {
|
|
210
|
+
changing.push(unlinked);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const entriesOf = (operand) => many && Array.isArray(operand) ? operand : [operand];
|
|
214
|
+
for (const [directive, operand] of operands) {
|
|
215
|
+
if (!many &&
|
|
216
|
+
targetHolds &&
|
|
217
|
+
parent.kind === "existing" &&
|
|
218
|
+
(directive === "$create" ||
|
|
219
|
+
directive === "$connect" ||
|
|
220
|
+
directive === "$connectOrCreate" ||
|
|
221
|
+
directive === "$set")) {
|
|
222
|
+
unlinkLinked(false);
|
|
223
|
+
}
|
|
224
|
+
switch (directive) {
|
|
225
|
+
case "$create":
|
|
226
|
+
created.push(...entriesOf(operand).filter(isRecord));
|
|
227
|
+
break;
|
|
228
|
+
case "$connect":
|
|
229
|
+
entriesOf(operand).forEach(link);
|
|
230
|
+
break;
|
|
231
|
+
case "$connectOrCreate":
|
|
232
|
+
for (const entry of entriesOf(operand)) {
|
|
233
|
+
if (isRecord(entry)) {
|
|
234
|
+
link(entry.where);
|
|
235
|
+
if (isRecord(entry.create)) {
|
|
236
|
+
created.push(entry.create);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case "$disconnect":
|
|
242
|
+
if (many) {
|
|
243
|
+
entriesOf(operand).forEach(link);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
unlinkLinked(false);
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
case "$set":
|
|
250
|
+
if (!many) {
|
|
251
|
+
if (operand === null) {
|
|
252
|
+
unlinkLinked(false);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
link(operand);
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
{
|
|
260
|
+
const kept = (Array.isArray(operand) ? operand : []).filter(isRecord);
|
|
261
|
+
kept.forEach(link);
|
|
262
|
+
unlinkLinked(someOf(kept));
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
case "$update":
|
|
266
|
+
for (const entry of entriesOf(operand)) {
|
|
267
|
+
if (isRecord(entry) && isRecord(entry.data)) {
|
|
268
|
+
checks.push(await this.changed(target, allOf(linked(), conditionOf(entry.where)), change, entry.data, many ? entry : undefined));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
case "$upsert":
|
|
273
|
+
for (const entry of entriesOf(operand)) {
|
|
274
|
+
if (!isRecord(entry)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const reach = allOf(linked(), conditionOf(entry.where));
|
|
278
|
+
if (reach !== false &&
|
|
279
|
+
isRecord(entry.update) &&
|
|
280
|
+
(await this.session.exists(target, whereOf(reach)))) {
|
|
281
|
+
checks.push(await this.changed(target, reach, change, entry.update));
|
|
282
|
+
}
|
|
283
|
+
else if (isRecord(entry.create)) {
|
|
284
|
+
created.push(entry.create);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
case "$delete":
|
|
289
|
+
for (const entry of entriesOf(operand)) {
|
|
290
|
+
const reach = allOf(linked(), conditionOf(isRecord(entry) ? entry.where : undefined));
|
|
291
|
+
if (many && isRecord(entry)) {
|
|
292
|
+
await this.narrow(entry, target, reach, remove);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
this.ask(target, "delete", allOf(reach, noneOf(remove)));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (changing.length > 0) {
|
|
302
|
+
checks.push(await this.relinked(target, inverse, read, changing));
|
|
303
|
+
}
|
|
304
|
+
if (created.length > 0) {
|
|
305
|
+
checks.push(await this.creates(resource, target, throughInverse(), linked(), selectors, created));
|
|
306
|
+
}
|
|
307
|
+
return checks;
|
|
308
|
+
}
|
|
309
|
+
async narrow(entry, resource, reach, scope) {
|
|
310
|
+
const identifier = this.identifier(resource);
|
|
311
|
+
const within = allOf(reach, scope);
|
|
312
|
+
const rows = within === false
|
|
313
|
+
? []
|
|
314
|
+
: await this.session.rows(resource, whereOf(within), identifier);
|
|
315
|
+
const where = identifiedRows(this.core, identifier, resource, rows);
|
|
316
|
+
this.replaced.set(entry, where === false ? DROPPED : { ...entry, where: whereOf(where) });
|
|
317
|
+
return rows;
|
|
318
|
+
}
|
|
319
|
+
async changed(resource, reach, scope, data, entry) {
|
|
320
|
+
const identifier = this.identifier(resource);
|
|
321
|
+
let rows;
|
|
322
|
+
if (entry === undefined) {
|
|
323
|
+
this.ask(resource, "update", allOf(reach, noneOf(scope)));
|
|
324
|
+
rows =
|
|
325
|
+
reach === false
|
|
326
|
+
? []
|
|
327
|
+
: await this.session.rows(resource, whereOf(reach), identifier);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
rows = await this.narrow(entry, resource, reach, scope);
|
|
331
|
+
reach =
|
|
332
|
+
rows.length === 0
|
|
333
|
+
? false
|
|
334
|
+
: identifiedRows(this.core, identifier, resource, rows);
|
|
335
|
+
}
|
|
336
|
+
const grants = await this.grantsOf(resource, [data]);
|
|
337
|
+
if (grants.length > 0) {
|
|
338
|
+
this.ask(resource, "fields", allOf(reach, someOf(grants.map(noneOf))));
|
|
339
|
+
}
|
|
340
|
+
const after = await afterUpdate(this.session, this.core, this.model, resource, data, true);
|
|
341
|
+
const inner = await this.level(resource, { kind: "existing", reach }, [
|
|
342
|
+
data,
|
|
343
|
+
]);
|
|
344
|
+
return async () => {
|
|
345
|
+
if (after !== undefined) {
|
|
346
|
+
const refused = await checkChanged(this.session, this.core, this.model, resource, after, rows);
|
|
347
|
+
if (refused !== undefined) {
|
|
348
|
+
return refused;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return runChecks(inner, rows);
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
async relinked(resource, inverse, read, changing) {
|
|
355
|
+
const identifier = this.identifier(resource);
|
|
356
|
+
const reach = someOf(changing);
|
|
357
|
+
const rows = reach === false
|
|
358
|
+
? []
|
|
359
|
+
: await this.session.rows(resource, whereOf(reach), identifier);
|
|
360
|
+
const after = (inverse === undefined
|
|
361
|
+
? undefined
|
|
362
|
+
: await afterUpdate(this.session, this.core, this.model, resource, { [inverse.name]: {} }, true)) ?? { moveOut: read === true ? undefined : read, after: true };
|
|
363
|
+
return () => checkChanged(this.session, this.core, this.model, resource, after, rows);
|
|
364
|
+
}
|
|
365
|
+
async creates(parentResource, resource, inverse, linked, selectors, records) {
|
|
366
|
+
const identifier = this.identifier(resource);
|
|
367
|
+
const parentIdentifier = this.identifier(parentResource);
|
|
368
|
+
const there = someOf([linked, ...selectors]);
|
|
369
|
+
const before = there === false
|
|
370
|
+
? []
|
|
371
|
+
: await this.session.rows(resource, whereOf(there), identifier);
|
|
372
|
+
const grants = await this.grantsOf(resource, records);
|
|
373
|
+
const inner = await this.level(resource, { kind: "new" }, records);
|
|
374
|
+
return async (parents) => {
|
|
375
|
+
if (parents.length === 0) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const fresh = this.session.renewed();
|
|
379
|
+
const reach = allOf(linkedTo(inverse, identifiedRows(this.core, parentIdentifier, parentResource, parents)), before.length === 0
|
|
380
|
+
? true
|
|
381
|
+
: noneOf(identifiedRows(this.core, identifier, resource, before)));
|
|
382
|
+
const rows = reach === false
|
|
383
|
+
? []
|
|
384
|
+
: await fresh.rows(resource, whereOf(reach), [
|
|
385
|
+
...new Set([...identifier, ...rowFieldsOf(this.core, resource)]),
|
|
386
|
+
]);
|
|
387
|
+
const refused = await checkCreatedRows(fresh, this.core, identifier, resource, grants, rows);
|
|
388
|
+
return refused ?? runChecks(inner, rows);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function runChecks(checks, rows) {
|
|
393
|
+
for (const check of checks) {
|
|
394
|
+
const refused = await check(rows);
|
|
395
|
+
if (refused !== undefined) {
|
|
396
|
+
return refused;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
async function checkCreatedRows(session, core, identifier, resource, grants, rows) {
|
|
402
|
+
if (rows.length === 0) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
const answers = await Promise.all(rows.map((row) => session.can(resource, "create", frozenCopy(row))));
|
|
406
|
+
if (answers.includes(false)) {
|
|
407
|
+
return policyRefusal(`The policy does not allow creating this row of Resource "${resource}".`);
|
|
408
|
+
}
|
|
409
|
+
const missed = allOf(identifiedRows(core, identifier, resource, rows), someOf(grants.map(noneOf)));
|
|
410
|
+
if (missed !== false && (await session.exists(resource, whereOf(missed)))) {
|
|
411
|
+
return policyRefusal(`The policy does not allow writing a field of Resource "${resource}" on the row this request creates.`);
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
export async function planNestedWrites(session, core, model, resource, record, parent) {
|
|
416
|
+
const walk = new NestedWalk(session, core, model);
|
|
417
|
+
try {
|
|
418
|
+
const identifier = identifierOf(model, resource);
|
|
419
|
+
if (identifier.length === 0) {
|
|
420
|
+
throw new NestedRefusal(`The policy checks the rows a nested write reaches by identifier, and Resource "${resource}" has none.`);
|
|
421
|
+
}
|
|
422
|
+
const checks = await walk.level(resource, parent, [record]);
|
|
423
|
+
const refused = await walk.answer();
|
|
424
|
+
if (refused !== undefined) {
|
|
425
|
+
return { kind: "refused", response: refused };
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
kind: "planned",
|
|
429
|
+
record: rewritten(record, walk.replaced),
|
|
430
|
+
after: async (rows) => {
|
|
431
|
+
if (parent.kind === "new" && rows.length > 0) {
|
|
432
|
+
const fresh = session.renewed();
|
|
433
|
+
const stored = await fresh.rows(resource, whereOf(identifiedRows(core, identifier, resource, rows)), [...new Set([...identifier, ...rowFieldsOf(core, resource)])]);
|
|
434
|
+
const refusal = await checkCreatedRows(fresh, core, identifier, resource, [], stored);
|
|
435
|
+
if (refusal !== undefined) {
|
|
436
|
+
return refusal;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return runChecks(checks, rows);
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (error instanceof NestedRefusal) {
|
|
445
|
+
return { kind: "refused", response: policyRefusal(error.message) };
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|