@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,83 @@
|
|
|
1
|
+
import type { AvAdapterModel } from "../adapters/adapter-model.js";
|
|
2
|
+
import type { AvContractCore } from "../contracts/contract.js";
|
|
3
|
+
import type { AvOperationIdentity } from "../operations/operation-identity.js";
|
|
4
|
+
import type { PlannedReadOperation, ReadProjectionPlan } from "../runtime/computed-read-runtime.js";
|
|
5
|
+
import type { AvOperationFailureResponse } from "../runtime/operation-response.js";
|
|
6
|
+
import { type PolicyCondition } from "./policy-condition.js";
|
|
7
|
+
import { type PolicySession } from "./policy-session.js";
|
|
8
|
+
/** The answer once the policy checked it: its rows, or the refusal. */
|
|
9
|
+
export type PolicySettlement = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly data: unknown;
|
|
12
|
+
} | {
|
|
13
|
+
readonly ok: false;
|
|
14
|
+
readonly response: AvOperationFailureResponse;
|
|
15
|
+
};
|
|
16
|
+
/** What one read runs once its policy is applied. */
|
|
17
|
+
export type PolicyRead = {
|
|
18
|
+
/** The policy refuses the read before it runs. */
|
|
19
|
+
readonly kind: "refused";
|
|
20
|
+
readonly response: AvOperationFailureResponse;
|
|
21
|
+
} | {
|
|
22
|
+
/** The read reaches no row: its answer, without the adapter. */
|
|
23
|
+
readonly kind: "empty";
|
|
24
|
+
readonly data: unknown;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "run";
|
|
27
|
+
/** The operation the adapter runs: the request's own, or a unique read's first-style form. */
|
|
28
|
+
readonly identity: AvOperationIdentity;
|
|
29
|
+
readonly arguments: unknown;
|
|
30
|
+
readonly plan: PlannedReadOperation;
|
|
31
|
+
/**
|
|
32
|
+
* What the returned rows are checked for after the adapter answers (an
|
|
33
|
+
* `exists` per level): the root level's conditions, each a row it must
|
|
34
|
+
* not meet, and whether a level below the root is checked.
|
|
35
|
+
*/
|
|
36
|
+
readonly checks: {
|
|
37
|
+
readonly root: readonly PolicyCondition[];
|
|
38
|
+
readonly nested: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** The returned rows checked, and every emptied relation filled. */
|
|
41
|
+
readonly settle: (data: unknown) => Promise<PolicySettlement>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* How a write's root rows are reached (`policy-write.runtime.ts`): the
|
|
45
|
+
* condition ANDed into its `where`, or `undefined` for a write whose `where`
|
|
46
|
+
* stays as sent (a create has none, an upsert's is checked before it runs);
|
|
47
|
+
* and whether each returned row carries its identifier, for the checks that
|
|
48
|
+
* follow the write.
|
|
49
|
+
*/
|
|
50
|
+
export type PolicyRootRows = {
|
|
51
|
+
readonly scope: PolicyCondition | undefined;
|
|
52
|
+
readonly identified: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The fields each returned root row carries besides the caller's projection (its
|
|
55
|
+
* identifier among them when `identified`): what the after check asks `can` about,
|
|
56
|
+
* read from the write's own answer rather than read again. Trimmed before the
|
|
57
|
+
* caller sees it.
|
|
58
|
+
*/
|
|
59
|
+
readonly carried?: readonly string[];
|
|
60
|
+
};
|
|
61
|
+
type Row = Readonly<Record<string, unknown>>;
|
|
62
|
+
/** The Resource's identifier as the adapter states it first (its primary key). */
|
|
63
|
+
export declare function identifierOf(model: AvAdapterModel, resource: string): readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* One read's policy (see the module comment): what the adapter runs, and the
|
|
66
|
+
* checks its answer goes through; or the empty answer when the read reaches no
|
|
67
|
+
* row. A write's answer is read the same way, its root reached as `rows`
|
|
68
|
+
* says (`policy-write.runtime.ts`); a read's root is reached by its read scope.
|
|
69
|
+
*/
|
|
70
|
+
export declare function enforceReadPolicy(session: PolicySession, core: AvContractCore, model: AvAdapterModel, request: AvOperationIdentity & {
|
|
71
|
+
readonly resource: string;
|
|
72
|
+
}, plan: PlannedReadOperation, adapterArguments: unknown, rows?: PolicyRootRows): Promise<PolicyRead>;
|
|
73
|
+
/**
|
|
74
|
+
* What the rows an answer returns are checked for after the adapter answers, by
|
|
75
|
+
* the projection level that returns them: each level's conditions, each a row
|
|
76
|
+
* it must not meet (a to-one level's rows outside their read scope, a level's
|
|
77
|
+
* rows outside the grant of a field it returns that is readable on some rows
|
|
78
|
+
* only). Empty when nothing is checked after the answer.
|
|
79
|
+
*/
|
|
80
|
+
export declare function returnedRowChecks(session: PolicySession, core: AvContractCore, plan: PlannedReadOperation): Promise<Map<ReadProjectionPlan, readonly PolicyCondition[]>>;
|
|
81
|
+
/** A condition naming exactly `rows` of `resource` by their identifier. */
|
|
82
|
+
export declare function identifiedRows(core: AvContractCore, identifier: readonly string[], resource: string, rows: readonly Row[]): PolicyCondition;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { lookupField, lookupResource } from "../contracts/contract-lookup.js";
|
|
2
|
+
import { policyRefusal } from "./policy-admission.js";
|
|
3
|
+
import { allOf, anyOf, everyOf, noneOf, someOf, } from "./policy-condition.js";
|
|
4
|
+
import { fieldAccess } from "./policy-session.js";
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function relationOf(core, resource, name) {
|
|
9
|
+
const field = lookupField(lookupResource(core, resource), name);
|
|
10
|
+
return field?.kind === "relation"
|
|
11
|
+
? field
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
export function identifierOf(model, resource) {
|
|
15
|
+
const resources = model.resources;
|
|
16
|
+
return ((Object.hasOwn(resources, resource)
|
|
17
|
+
? resources[resource]?.identifiers?.[0]
|
|
18
|
+
: undefined) ?? []);
|
|
19
|
+
}
|
|
20
|
+
function innerFilters(relation, value) {
|
|
21
|
+
return relation.cardinality === "many"
|
|
22
|
+
? [value.some, value.every, value.none]
|
|
23
|
+
: [value];
|
|
24
|
+
}
|
|
25
|
+
class ReadTargets {
|
|
26
|
+
core;
|
|
27
|
+
resources = new Set();
|
|
28
|
+
constructor(core) {
|
|
29
|
+
this.core = core;
|
|
30
|
+
}
|
|
31
|
+
where(resource, where) {
|
|
32
|
+
if (!isRecord(where)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const [key, value] of Object.entries(where)) {
|
|
36
|
+
if (key === "AND" || key === "OR" || key === "NOT") {
|
|
37
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
38
|
+
this.where(resource, entry);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const relation = relationOf(this.core, resource, key);
|
|
43
|
+
if (relation === undefined || !isRecord(value)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
this.resources.add(relation.target);
|
|
47
|
+
for (const filter of innerFilters(relation, value)) {
|
|
48
|
+
this.where(relation.target, filter);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
orderBy(resource, orderBy) {
|
|
53
|
+
for (const entry of Array.isArray(orderBy) ? orderBy : [orderBy]) {
|
|
54
|
+
if (!isRecord(entry)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
58
|
+
const relation = relationOf(this.core, resource, key);
|
|
59
|
+
if (relation !== undefined) {
|
|
60
|
+
this.resources.add(relation.target);
|
|
61
|
+
this.orderBy(relation.target, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
select(resource, select) {
|
|
67
|
+
if (!Array.isArray(select)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of select) {
|
|
71
|
+
if (!isRecord(entry)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const [name, value] of Object.entries(entry)) {
|
|
75
|
+
const relation = relationOf(this.core, resource, name);
|
|
76
|
+
if (relation === undefined) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
this.resources.add(relation.target);
|
|
80
|
+
if (isRecord(value)) {
|
|
81
|
+
this.where(relation.target, value.where);
|
|
82
|
+
this.orderBy(relation.target, value.orderBy);
|
|
83
|
+
this.select(relation.target, value.select);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
class ScopedWhere {
|
|
90
|
+
core;
|
|
91
|
+
scopes;
|
|
92
|
+
constructor(core, scopes) {
|
|
93
|
+
this.core = core;
|
|
94
|
+
this.scopes = scopes;
|
|
95
|
+
}
|
|
96
|
+
scope(resource) {
|
|
97
|
+
return this.scopes.get(resource) ?? true;
|
|
98
|
+
}
|
|
99
|
+
of(resource, where) {
|
|
100
|
+
if (!isRecord(where)) {
|
|
101
|
+
return where;
|
|
102
|
+
}
|
|
103
|
+
let changed = false;
|
|
104
|
+
const conditions = [];
|
|
105
|
+
const kept = {};
|
|
106
|
+
for (const [key, value] of Object.entries(where)) {
|
|
107
|
+
if ((key === "AND" || key === "OR") && Array.isArray(value)) {
|
|
108
|
+
const parts = value.map((entry) => this.of(resource, entry));
|
|
109
|
+
if (parts.every((part, index) => part === value[index])) {
|
|
110
|
+
kept[key] = value;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
changed = true;
|
|
114
|
+
conditions.push(key === "AND"
|
|
115
|
+
? everyOf(parts)
|
|
116
|
+
: someOf(parts));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (key === "NOT") {
|
|
120
|
+
const part = this.of(resource, value);
|
|
121
|
+
if (part === value) {
|
|
122
|
+
kept[key] = value;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
changed = true;
|
|
126
|
+
conditions.push(noneOf(part));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const relation = relationOf(this.core, resource, key);
|
|
130
|
+
const filter = relation === undefined || !isRecord(value)
|
|
131
|
+
? undefined
|
|
132
|
+
: this.relationFilter(key, relation, value);
|
|
133
|
+
if (filter === undefined) {
|
|
134
|
+
kept[key] = value;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
changed = true;
|
|
138
|
+
conditions.push(...filter);
|
|
139
|
+
}
|
|
140
|
+
if (!changed) {
|
|
141
|
+
return where;
|
|
142
|
+
}
|
|
143
|
+
return everyOf([
|
|
144
|
+
Object.keys(kept).length === 0 ? true : kept,
|
|
145
|
+
...conditions,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
relationFilter(key, relation, value) {
|
|
149
|
+
const scope = this.scope(relation.target);
|
|
150
|
+
if (relation.cardinality === "one") {
|
|
151
|
+
const inner = this.of(relation.target, value);
|
|
152
|
+
if (scope === true && inner === value) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const condition = allOf(inner, scope);
|
|
156
|
+
return [
|
|
157
|
+
typeof condition === "boolean"
|
|
158
|
+
? condition && { [key]: {} }
|
|
159
|
+
: { [key]: condition },
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
const inner = Object.keys(value).map((operator) => [operator, this.of(relation.target, value[operator])]);
|
|
163
|
+
if (scope === true &&
|
|
164
|
+
inner.every(([operator, condition]) => condition === value[operator])) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
return inner.map(([operator, raw]) => {
|
|
168
|
+
const condition = raw;
|
|
169
|
+
if (operator === "every") {
|
|
170
|
+
const every = anyOf(condition, noneOf(scope));
|
|
171
|
+
return typeof every === "boolean"
|
|
172
|
+
? every || { [key]: { none: {} } }
|
|
173
|
+
: { [key]: { every } };
|
|
174
|
+
}
|
|
175
|
+
const scoped = allOf(condition, scope);
|
|
176
|
+
if (typeof scoped !== "boolean") {
|
|
177
|
+
return { [key]: { [operator]: scoped } };
|
|
178
|
+
}
|
|
179
|
+
if (operator === "some") {
|
|
180
|
+
return scoped && { [key]: { some: {} } };
|
|
181
|
+
}
|
|
182
|
+
return !scoped || { [key]: { none: {} } };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function emptyRelationValue(select) {
|
|
187
|
+
const entries = Array.isArray(select) ? select : [];
|
|
188
|
+
if (!entries.includes("$count")) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return entries.some((entry) => entry !== "$count")
|
|
192
|
+
? { data: [], count: 0 }
|
|
193
|
+
: { count: 0 };
|
|
194
|
+
}
|
|
195
|
+
function withIdentifier(select, identifier) {
|
|
196
|
+
const missing = identifier.filter((member) => !select.includes(member));
|
|
197
|
+
return missing.length === 0 ? select : [...select, ...missing];
|
|
198
|
+
}
|
|
199
|
+
export async function enforceReadPolicy(session, core, model, request, plan, adapterArguments, rows) {
|
|
200
|
+
const args = isRecord(adapterArguments) ? adapterArguments : {};
|
|
201
|
+
const root = request.resource;
|
|
202
|
+
const targets = new ReadTargets(core);
|
|
203
|
+
targets.resources.add(root);
|
|
204
|
+
targets.where(root, args.where);
|
|
205
|
+
targets.orderBy(root, args.orderBy);
|
|
206
|
+
targets.select(root, args.select);
|
|
207
|
+
const scopes = new Map(await Promise.all([...targets.resources].map(async (resource) => [resource, await session.scope(resource, "read")])));
|
|
208
|
+
const scoped = new ScopedWhere(core, scopes);
|
|
209
|
+
let sortRefusal = unscopedSort(core, scoped, root, args.orderBy);
|
|
210
|
+
const checks = await returnedRowChecks(session, core, plan);
|
|
211
|
+
let identity = request;
|
|
212
|
+
let rootWhere = args.where;
|
|
213
|
+
const scope = rows === undefined ? scoped.scope(root) : rows.scope;
|
|
214
|
+
if (scope === undefined) {
|
|
215
|
+
}
|
|
216
|
+
else if (request.variant === "unique") {
|
|
217
|
+
if (scope !== true) {
|
|
218
|
+
const within = allOf(args.where, scope);
|
|
219
|
+
if (within === false) {
|
|
220
|
+
return { kind: "empty", data: null };
|
|
221
|
+
}
|
|
222
|
+
const first = firstVariantOf(core, root, request.family);
|
|
223
|
+
if (rows !== undefined &&
|
|
224
|
+
(request.family === "update" || request.family === "delete")) {
|
|
225
|
+
rootWhere = { ...args.where, AND: [scope] };
|
|
226
|
+
}
|
|
227
|
+
else if (first !== undefined) {
|
|
228
|
+
identity = first;
|
|
229
|
+
rootWhere = within;
|
|
230
|
+
}
|
|
231
|
+
else if (!(await session.exists(root, within))) {
|
|
232
|
+
return { kind: "empty", data: null };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const where = everyOf([
|
|
238
|
+
args.where === undefined
|
|
239
|
+
? true
|
|
240
|
+
: scoped.of(root, args.where),
|
|
241
|
+
scope,
|
|
242
|
+
]);
|
|
243
|
+
if (where === false) {
|
|
244
|
+
return {
|
|
245
|
+
kind: "empty",
|
|
246
|
+
data: request.variant === "many"
|
|
247
|
+
? []
|
|
248
|
+
: request.variant === "count"
|
|
249
|
+
? 0
|
|
250
|
+
: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
rootWhere = where === true ? undefined : where;
|
|
254
|
+
}
|
|
255
|
+
const rewriteLevel = (resource, select, level) => {
|
|
256
|
+
const entries = [];
|
|
257
|
+
const emptied = [];
|
|
258
|
+
const nested = new Map();
|
|
259
|
+
for (const entry of Array.isArray(select) ? select : []) {
|
|
260
|
+
const [name] = isRecord(entry) ? Object.keys(entry) : [];
|
|
261
|
+
const relation = name === undefined ? undefined : relationOf(core, resource, name);
|
|
262
|
+
if (relation === undefined || name === undefined) {
|
|
263
|
+
entries.push(entry);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const value = isRecord(entry[name])
|
|
267
|
+
? entry[name]
|
|
268
|
+
: {};
|
|
269
|
+
const nestedLevel = level?.relations.find((candidate) => candidate.field === name)?.projection;
|
|
270
|
+
let rewritten = { ...value };
|
|
271
|
+
sortRefusal ??= unscopedSort(core, scoped, relation.target, value.orderBy);
|
|
272
|
+
if (relation.cardinality === "many") {
|
|
273
|
+
const where = everyOf([
|
|
274
|
+
value.where === undefined
|
|
275
|
+
? true
|
|
276
|
+
: scoped.of(relation.target, value.where),
|
|
277
|
+
scoped.scope(relation.target),
|
|
278
|
+
]);
|
|
279
|
+
if (where === false) {
|
|
280
|
+
emptied.push({
|
|
281
|
+
field: name,
|
|
282
|
+
value: emptyRelationValue(value.select),
|
|
283
|
+
});
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (where === true) {
|
|
287
|
+
delete rewritten.where;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
rewritten.where = where;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const inner = rewriteLevel(relation.target, value.select, nestedLevel);
|
|
294
|
+
nested.set(name, inner);
|
|
295
|
+
rewritten = { ...rewritten, select: inner.select };
|
|
296
|
+
entries.push({ [name]: rewritten });
|
|
297
|
+
}
|
|
298
|
+
const checked = level !== undefined &&
|
|
299
|
+
(checks.has(level) ||
|
|
300
|
+
(level === plan.projection && rows?.identified === true));
|
|
301
|
+
const carried = level !== undefined && level === plan.projection
|
|
302
|
+
? (rows?.carried ?? [])
|
|
303
|
+
: [];
|
|
304
|
+
return {
|
|
305
|
+
select: withIdentifier(checked
|
|
306
|
+
? withIdentifier(entries, identifierOf(model, resource))
|
|
307
|
+
: entries, carried),
|
|
308
|
+
emptied,
|
|
309
|
+
nested,
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
const rewritten = rewriteLevel(root, args.select, plan.projection);
|
|
313
|
+
if (sortRefusal !== undefined) {
|
|
314
|
+
return { kind: "refused", response: policyRefusal(sortRefusal) };
|
|
315
|
+
}
|
|
316
|
+
const runArguments = { ...args };
|
|
317
|
+
if (rootWhere === undefined) {
|
|
318
|
+
delete runArguments.where;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
runArguments.where = rootWhere;
|
|
322
|
+
}
|
|
323
|
+
if (Array.isArray(args.select)) {
|
|
324
|
+
runArguments.select = rewritten.select;
|
|
325
|
+
}
|
|
326
|
+
const changesShape = checks.size > 0 || hasEmptied(rewritten);
|
|
327
|
+
return {
|
|
328
|
+
kind: "run",
|
|
329
|
+
identity,
|
|
330
|
+
arguments: runArguments,
|
|
331
|
+
plan: changesShape ||
|
|
332
|
+
rows?.identified === true ||
|
|
333
|
+
(rows?.carried?.length ?? 0) > 0
|
|
334
|
+
? { ...plan, materialize: true }
|
|
335
|
+
: plan,
|
|
336
|
+
checks: {
|
|
337
|
+
root: plan.projection === undefined
|
|
338
|
+
? []
|
|
339
|
+
: (checks.get(plan.projection) ?? []),
|
|
340
|
+
nested: [...checks.keys()].some((level) => level !== plan.projection),
|
|
341
|
+
},
|
|
342
|
+
settle: async (data) => (await rowsReadable(session, core, model, plan.projection, checks, data))
|
|
343
|
+
? { ok: true, data: filledEmptied(rewritten, data) }
|
|
344
|
+
: {
|
|
345
|
+
ok: false,
|
|
346
|
+
response: policyRefusal("The policy does not allow reading every row or field this request returns."),
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
export async function returnedRowChecks(session, core, plan) {
|
|
351
|
+
const checks = new Map();
|
|
352
|
+
const collect = async (level, reachedOne) => {
|
|
353
|
+
if (level === undefined) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const fields = await session.fields(level.resource, "read");
|
|
357
|
+
const conditions = [];
|
|
358
|
+
if (reachedOne) {
|
|
359
|
+
const scope = await session.scope(level.resource, "read");
|
|
360
|
+
if (scope !== true) {
|
|
361
|
+
conditions.push(noneOf(scope));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const field of level.requested) {
|
|
365
|
+
const access = fieldAccess(core, level.resource, fields, field);
|
|
366
|
+
if (access !== "always" && access !== "never") {
|
|
367
|
+
conditions.push(noneOf(access));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (conditions.length > 0) {
|
|
371
|
+
checks.set(level, conditions);
|
|
372
|
+
}
|
|
373
|
+
for (const relation of level.relations) {
|
|
374
|
+
await collect(relation.projection, relation.cardinality === "one");
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
await collect(plan.projection, false);
|
|
378
|
+
return checks;
|
|
379
|
+
}
|
|
380
|
+
function firstVariantOf(core, resource, family) {
|
|
381
|
+
const operations = lookupResource(core, resource)?.operations;
|
|
382
|
+
return family !== "create" &&
|
|
383
|
+
family !== "upsert" &&
|
|
384
|
+
operations?.[family]?.first !== undefined
|
|
385
|
+
? { family, variant: "first" }
|
|
386
|
+
: undefined;
|
|
387
|
+
}
|
|
388
|
+
function hasEmptied(level) {
|
|
389
|
+
return (level.emptied.length > 0 ||
|
|
390
|
+
[...level.nested.values()].some((nested) => hasEmptied(nested)));
|
|
391
|
+
}
|
|
392
|
+
function unscopedSort(core, scoped, resource, orderBy) {
|
|
393
|
+
for (const entry of Array.isArray(orderBy) ? orderBy : [orderBy]) {
|
|
394
|
+
if (!isRecord(entry)) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
for (const key of Object.keys(entry)) {
|
|
398
|
+
const relation = relationOf(core, resource, key);
|
|
399
|
+
if (relation !== undefined && scoped.scope(relation.target) !== true) {
|
|
400
|
+
return `The policy does not allow sorting by relation "${key}" of Resource "${resource}": it reaches rows of "${relation.target}" this request may not read.`;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
function rowsByLevel(level, rows, into) {
|
|
407
|
+
if (level === undefined) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const own = into.get(level) ?? [];
|
|
411
|
+
for (const row of rows) {
|
|
412
|
+
if (isRecord(row)) {
|
|
413
|
+
own.push(row);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
into.set(level, own);
|
|
417
|
+
for (const relation of level.relations) {
|
|
418
|
+
const nested = [];
|
|
419
|
+
for (const row of own) {
|
|
420
|
+
const value = row[relation.field];
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
nested.push(...value);
|
|
423
|
+
}
|
|
424
|
+
else if (isRecord(value) && Array.isArray(value.data)) {
|
|
425
|
+
nested.push(...value.data);
|
|
426
|
+
}
|
|
427
|
+
else if (relation.cardinality === "one" && isRecord(value)) {
|
|
428
|
+
nested.push(value);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
rowsByLevel(relation.projection, nested, into);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
export function identifiedRows(core, identifier, resource, rows) {
|
|
435
|
+
const [single] = identifier;
|
|
436
|
+
if (identifier.length === 1 && single !== undefined) {
|
|
437
|
+
const values = [...new Set(rows.map((row) => row[single]))];
|
|
438
|
+
const field = lookupField(lookupResource(core, resource), single);
|
|
439
|
+
if (field?.kind === "scalar" && field.capabilities.filter?.includes("in")) {
|
|
440
|
+
return { [single]: { in: values } };
|
|
441
|
+
}
|
|
442
|
+
return someOf(values.map((value) => ({ [single]: value })));
|
|
443
|
+
}
|
|
444
|
+
return someOf(rows.map((row) => Object.fromEntries(identifier.map((member) => [member, row[member]]))));
|
|
445
|
+
}
|
|
446
|
+
async function rowsReadable(session, core, model, projection, checks, data) {
|
|
447
|
+
if (checks.size === 0 || projection === undefined) {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
const levels = new Map();
|
|
451
|
+
rowsByLevel(projection, Array.isArray(data) ? data : [data], levels);
|
|
452
|
+
const questions = [...checks].map(async ([level, misses]) => {
|
|
453
|
+
const rows = levels.get(level) ?? [];
|
|
454
|
+
if (rows.length === 0) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
const miss = someOf(misses);
|
|
458
|
+
if (miss === false) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
if (miss === true) {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
const identifier = identifierOf(model, level.resource);
|
|
465
|
+
const reach = allOf(identifiedRows(core, identifier, level.resource, rows), miss);
|
|
466
|
+
return (reach !== false &&
|
|
467
|
+
session.exists(level.resource, reach === true ? {} : reach));
|
|
468
|
+
});
|
|
469
|
+
const answers = await Promise.all(questions);
|
|
470
|
+
return !answers.some((answer) => answer === true);
|
|
471
|
+
}
|
|
472
|
+
function filledEmptied(scopedLevel, data) {
|
|
473
|
+
if (!hasEmptied(scopedLevel)) {
|
|
474
|
+
return data;
|
|
475
|
+
}
|
|
476
|
+
if (Array.isArray(data)) {
|
|
477
|
+
return data.map((row) => filledEmptied(scopedLevel, row));
|
|
478
|
+
}
|
|
479
|
+
if (!isRecord(data)) {
|
|
480
|
+
return data;
|
|
481
|
+
}
|
|
482
|
+
const row = { ...data };
|
|
483
|
+
for (const emptied of scopedLevel.emptied) {
|
|
484
|
+
row[emptied.field] = structuredClone(emptied.value);
|
|
485
|
+
}
|
|
486
|
+
for (const [field, nested] of scopedLevel.nested) {
|
|
487
|
+
const value = row[field];
|
|
488
|
+
row[field] =
|
|
489
|
+
isRecord(value) && Array.isArray(value.data)
|
|
490
|
+
? { ...value, data: filledEmptied(nested, value.data) }
|
|
491
|
+
: filledEmptied(nested, value);
|
|
492
|
+
}
|
|
493
|
+
return row;
|
|
494
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AvAdapterOperationExecutor } from "../adapters/adapter-operation-executor.interface.js";
|
|
2
|
+
import type { ResolvedPolicyConfig } from "../config/config.resolver.js";
|
|
3
|
+
import type { AvRequestOrigin } from "../config/config-layer.js";
|
|
4
|
+
import type { AvContract, AvContractCore } from "../contracts/contract.js";
|
|
5
|
+
import type { AvOperationIdentity } from "../operations/operation-identity.js";
|
|
6
|
+
import type { DiagnosticsReporter } from "../runtime/diagnostics-reporter.js";
|
|
7
|
+
import { type CallerArgumentsView } from "../runtime/error-boundary.js";
|
|
8
|
+
import type { AvOperationFailureResponse } from "../runtime/operation-response.js";
|
|
9
|
+
import type { AvState } from "../runtime/request-state.js";
|
|
10
|
+
import { type PolicySession } from "./policy-session.js";
|
|
11
|
+
/** The principal an application call runs as (`{ as }`). */
|
|
12
|
+
export type PolicyCaller = {
|
|
13
|
+
readonly principal: unknown;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The principal a call's options name (`{ as }`), or `undefined` when they name
|
|
17
|
+
* none. `null` is a principal: the anonymous one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function callerOf(options: unknown): PolicyCaller | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The policy session of one request, or `undefined` when the policy does not
|
|
22
|
+
* check it. Deriving the principal is server code: a throw is the caller's
|
|
23
|
+
* to answer as the server's defect.
|
|
24
|
+
*/
|
|
25
|
+
export declare function openRequestPolicy(policy: ResolvedPolicyConfig | undefined, core: AvContractCore, request: {
|
|
26
|
+
readonly origin: AvRequestOrigin;
|
|
27
|
+
readonly state: AvState;
|
|
28
|
+
}, caller: PolicyCaller | undefined, executor: AvAdapterOperationExecutor): PolicySession | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Policy admission inside its own boundary, as the Framework Validation Layer's
|
|
31
|
+
* is: a refusal, a defect of the engine or its answers (`A3000`), or an adapter
|
|
32
|
+
* failure of an `exists` question (`A3001`), answered without any developer
|
|
33
|
+
* filter; `undefined` when the request goes on to its guards.
|
|
34
|
+
*/
|
|
35
|
+
export declare function admitRequestPolicy(session: PolicySession, core: AvContractCore, active: AvContract, request: AvOperationIdentity & {
|
|
36
|
+
readonly resource: string;
|
|
37
|
+
}, args: unknown, report: DiagnosticsReporter, view: CallerArgumentsView): Promise<AvOperationFailureResponse | undefined>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AdapterBoundary, sanitizeOperationFailure, } from "../runtime/error-boundary.js";
|
|
2
|
+
import { admitByPolicy, admitWriteByPolicy } from "./policy-admission.js";
|
|
3
|
+
import { openPolicySession } from "./policy-session.js";
|
|
4
|
+
export function callerOf(options) {
|
|
5
|
+
if (typeof options !== "object" || options === null) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
let principal;
|
|
9
|
+
try {
|
|
10
|
+
principal = options.as;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return principal === undefined ? undefined : { principal };
|
|
16
|
+
}
|
|
17
|
+
export function openRequestPolicy(policy, core, request, caller, executor) {
|
|
18
|
+
if (policy === undefined) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
let principal;
|
|
22
|
+
if (caller !== undefined) {
|
|
23
|
+
principal = caller.principal;
|
|
24
|
+
}
|
|
25
|
+
else if (request.origin === "application") {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
principal = policy.principal({
|
|
30
|
+
$state: request.state,
|
|
31
|
+
$origin: request.origin,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return openPolicySession(policy, core, principal, request.origin, {
|
|
35
|
+
executor,
|
|
36
|
+
boundary: new AdapterBoundary(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function admitRequestPolicy(session, core, active, request, args, report, view) {
|
|
40
|
+
try {
|
|
41
|
+
const refused = await admitByPolicy(session, core, active, request, args);
|
|
42
|
+
if (refused !== undefined) {
|
|
43
|
+
return refused;
|
|
44
|
+
}
|
|
45
|
+
return request.family === "find"
|
|
46
|
+
? undefined
|
|
47
|
+
: await admitWriteByPolicy(session, core, request, args);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return sanitizeOperationFailure(error, session.execution.boundary.originOf(error), report, view);
|
|
51
|
+
}
|
|
52
|
+
}
|