@aventara/access 0.0.0-stage → 1.0.0-rc.1
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 +877 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2536 -2
- package/dist/access/access-options.d.ts +74 -0
- package/dist/access/access-options.js +5 -0
- package/dist/access/access.error.d.ts +35 -0
- package/dist/access/access.error.js +11 -0
- package/dist/access/access.factory.d.ts +275 -0
- package/dist/access/access.factory.js +440 -0
- package/dist/access/better-auth.hardening.d.ts +120 -0
- package/dist/access/better-auth.hardening.js +420 -0
- package/dist/access/better-auth.loader.d.ts +67 -0
- package/dist/access/better-auth.loader.js +65 -0
- package/dist/access/better-auth.range.d.ts +9 -0
- package/dist/access/better-auth.range.js +28 -0
- package/dist/access/better-auth.require.d.ts +90 -0
- package/dist/access/better-auth.require.js +6 -0
- package/dist/access/better-auth.require.node.d.ts +7 -0
- package/dist/access/better-auth.require.node.js +52 -0
- package/dist/access/claims.mapper.d.ts +79 -0
- package/dist/access/claims.mapper.js +152 -0
- package/dist/access/key-set.cache.d.ts +10 -0
- package/dist/access/key-set.cache.js +63 -0
- package/dist/access/policies-block.checker.d.ts +64 -0
- package/dist/access/policies-block.checker.js +138 -0
- package/dist/access/principal.subject.d.ts +22 -0
- package/dist/access/principal.subject.js +89 -0
- package/dist/access/profile.policy.d.ts +74 -0
- package/dist/access/profile.policy.js +110 -0
- package/dist/access/request.state.d.ts +89 -0
- package/dist/access/request.state.js +266 -0
- package/dist/access/session.hooks.d.ts +114 -0
- package/dist/access/session.hooks.js +258 -0
- package/dist/access/token.verifier.d.ts +35 -0
- package/dist/access/token.verifier.js +69 -0
- package/dist/access-schema.d.ts +48 -0
- package/dist/access-schema.js +5 -0
- package/dist/access-warning.emitter.d.ts +4 -0
- package/dist/access-warning.emitter.js +24 -0
- package/dist/access.builder.d.ts +340 -0
- package/dist/access.builder.js +75 -0
- package/dist/access.engine.d.ts +155 -0
- package/dist/access.engine.js +477 -0
- package/dist/audit/audit-event.d.ts +135 -0
- package/dist/audit/audit-event.js +8 -0
- package/dist/audit/audit.log.d.ts +122 -0
- package/dist/audit/audit.log.js +345 -0
- package/dist/audit/audit.schema.d.ts +143 -0
- package/dist/audit/audit.schema.js +50 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +2 -0
- package/dist/aventara-where.translator.d.ts +19 -0
- package/dist/aventara-where.translator.js +87 -0
- package/dist/beacon/beacon.check.d.ts +59 -0
- package/dist/beacon/beacon.check.js +128 -0
- package/dist/beacon/beacon.schema.d.ts +41 -0
- package/dist/beacon/beacon.schema.js +48 -0
- package/dist/beacon/beacon.store.d.ts +29 -0
- package/dist/beacon/beacon.store.js +78 -0
- package/dist/better-auth/access.client-plugin.d.ts +75 -0
- package/dist/better-auth/access.client-plugin.js +133 -0
- package/dist/better-auth/access.plugin.d.ts +164 -0
- package/dist/better-auth/access.plugin.js +348 -0
- package/dist/better-auth/access.provider.d.ts +80 -0
- package/dist/better-auth/access.provider.js +100 -0
- package/dist/better-auth/index.d.ts +7 -0
- package/dist/better-auth/index.js +4 -0
- package/dist/better-auth/organization.bridge.d.ts +66 -0
- package/dist/better-auth/organization.bridge.js +144 -0
- package/dist/canonical-json.writer.d.ts +12 -0
- package/dist/canonical-json.writer.js +30 -0
- package/dist/cli/avaccess.bin.d.ts +2 -0
- package/dist/cli/avaccess.bin.js +26 -0
- package/dist/cli/avaccess.program.d.ts +15 -0
- package/dist/cli/avaccess.program.js +166 -0
- package/dist/cli/beacon-pins.file.d.ts +14 -0
- package/dist/cli/beacon-pins.file.js +62 -0
- package/dist/cli/beacon.loader.d.ts +20 -0
- package/dist/cli/beacon.loader.js +71 -0
- package/dist/cli/cli.context.d.ts +49 -0
- package/dist/cli/cli.context.js +66 -0
- package/dist/cli/diff.command.d.ts +2 -0
- package/dist/cli/diff.command.js +66 -0
- package/dist/cli/generate.command.d.ts +2 -0
- package/dist/cli/generate.command.js +82 -0
- package/dist/cli/keys.command.d.ts +2 -0
- package/dist/cli/keys.command.js +96 -0
- package/dist/cli/lock.file.d.ts +31 -0
- package/dist/cli/lock.file.js +181 -0
- package/dist/cli/module.loader.d.ts +13 -0
- package/dist/cli/module.loader.js +63 -0
- package/dist/cli/policies.diff.d.ts +16 -0
- package/dist/cli/policies.diff.js +101 -0
- package/dist/cli/pull.command.d.ts +2 -0
- package/dist/cli/pull.command.js +234 -0
- package/dist/cli/push.command.d.ts +2 -0
- package/dist/cli/push.command.js +59 -0
- package/dist/cli/registry.client.d.ts +63 -0
- package/dist/cli/registry.client.js +287 -0
- package/dist/cli/schema.command.d.ts +2 -0
- package/dist/cli/schema.command.js +60 -0
- package/dist/cli/serve.command.d.ts +6 -0
- package/dist/cli/serve.command.js +200 -0
- package/dist/cli/status.command.d.ts +2 -0
- package/dist/cli/status.command.js +31 -0
- package/dist/cli/translator.flag.d.ts +6 -0
- package/dist/cli/translator.flag.js +24 -0
- package/dist/cli/verify.command.d.ts +2 -0
- package/dist/cli/verify.command.js +40 -0
- package/dist/client/access-client.factory.d.ts +92 -0
- package/dist/client/access-client.factory.js +269 -0
- package/dist/client/access-verbs.schema.d.ts +66 -0
- package/dist/client/access-verbs.schema.js +159 -0
- package/dist/client/verb-rate.limiter.d.ts +116 -0
- package/dist/client/verb-rate.limiter.js +358 -0
- package/dist/foreign/foreign-facts.d.ts +30 -0
- package/dist/foreign/foreign-facts.js +75 -0
- package/dist/foreign/foreign.merger.d.ts +10 -0
- package/dist/foreign/foreign.merger.js +52 -0
- package/dist/foreign-references.check.d.ts +6 -0
- package/dist/foreign-references.check.js +131 -0
- package/dist/generator/client.emitter.d.ts +28 -0
- package/dist/generator/client.emitter.js +411 -0
- package/dist/generator/copied-modules.reader.d.ts +14 -0
- package/dist/generator/copied-modules.reader.js +80 -0
- package/dist/generator/namespace.facts.d.ts +28 -0
- package/dist/generator/namespace.facts.js +32 -0
- package/dist/generator/orm.translator.d.ts +48 -0
- package/dist/generator/orm.translator.js +29 -0
- package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
- package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
- package/dist/generator/prisma7/prisma-where.translator.d.ts +73 -0
- package/dist/generator/prisma7/prisma-where.translator.js +138 -0
- package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
- package/dist/generator/prisma7/prisma7.translator.js +87 -0
- package/dist/generator/schema.emitter.d.ts +14 -0
- package/dist/generator/schema.emitter.js +23 -0
- package/dist/generator/tsconfig.reader.d.ts +44 -0
- package/dist/generator/tsconfig.reader.js +110 -0
- package/dist/generator/typescript-literal.writer.d.ts +5 -0
- package/dist/generator/typescript-literal.writer.js +42 -0
- package/dist/guardrails/guardrails-contract.d.ts +45 -0
- package/dist/guardrails/guardrails-contract.js +12 -0
- package/dist/guardrails/guardrails.builder.d.ts +48 -0
- package/dist/guardrails/guardrails.builder.js +32 -0
- package/dist/guardrails/guardrails.compiler.d.ts +10 -0
- package/dist/guardrails/guardrails.compiler.js +254 -0
- package/dist/guardrails/guardrails.composer.d.ts +7 -0
- package/dist/guardrails/guardrails.composer.js +95 -0
- package/dist/guardrails/guardrails.hash.d.ts +10 -0
- package/dist/guardrails/guardrails.hash.js +38 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
- package/dist/kiosk/kiosk-verbs.schema.js +61 -0
- package/dist/kiosk/kiosk.plugin.d.ts +217 -0
- package/dist/kiosk/kiosk.plugin.js +877 -0
- package/dist/kiosk/kiosk.schema.d.ts +176 -0
- package/dist/kiosk/kiosk.schema.js +101 -0
- package/dist/kiosk/kiosk.store.d.ts +171 -0
- package/dist/kiosk/kiosk.store.js +478 -0
- package/dist/kiosk/lan-range.policy.d.ts +31 -0
- package/dist/kiosk/lan-range.policy.js +113 -0
- package/dist/kiosk/pin.policy.d.ts +9 -0
- package/dist/kiosk/pin.policy.js +143 -0
- package/dist/kiosk/session-use.marker.d.ts +4 -0
- package/dist/kiosk/session-use.marker.js +16 -0
- package/dist/lock/lock-contract.d.ts +38 -0
- package/dist/lock/lock-contract.js +67 -0
- package/dist/lock/lock.verifier.d.ts +27 -0
- package/dist/lock/lock.verifier.js +62 -0
- package/dist/mcp/agent-scope.gate.d.ts +13 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +40 -0
- package/dist/mcp/agent.grant.js +92 -0
- package/dist/mcp/mcp-options.d.ts +60 -0
- package/dist/mcp/mcp-options.js +72 -0
- package/dist/oauth/access-token.verifier.d.ts +66 -0
- package/dist/oauth/access-token.verifier.js +201 -0
- package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
- package/dist/oauth/oauth-provider.hardening.js +261 -0
- package/dist/passwords/argon2id.hasher.d.ts +97 -0
- package/dist/passwords/argon2id.hasher.js +256 -0
- package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
- package/dist/passwords/node-rs-argon2.adapter.js +42 -0
- package/dist/passwords/passwords.options.d.ts +79 -0
- package/dist/passwords/passwords.options.js +212 -0
- package/dist/policies-contract.d.ts +219 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +38 -0
- package/dist/policies.compiler.js +1433 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +390 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +54 -0
- package/dist/predicate.evaluator.d.ts +62 -0
- package/dist/predicate.evaluator.js +378 -0
- package/dist/random-secret.generator.d.ts +2 -0
- package/dist/random-secret.generator.js +15 -0
- package/dist/read-governed-unique.check.d.ts +3 -0
- package/dist/read-governed-unique.check.js +21 -0
- package/dist/reference-reads.check.d.ts +16 -0
- package/dist/reference-reads.check.js +158 -0
- package/dist/registry/access.registry.d.ts +173 -0
- package/dist/registry/access.registry.js +865 -0
- package/dist/registry/beacon.ledger.d.ts +30 -0
- package/dist/registry/beacon.ledger.js +93 -0
- package/dist/registry/ownership.check.d.ts +54 -0
- package/dist/registry/ownership.check.js +221 -0
- package/dist/registry/registry.endpoints.d.ts +50 -0
- package/dist/registry/registry.endpoints.js +193 -0
- package/dist/registry/registry.key.d.ts +7 -0
- package/dist/registry/registry.key.js +13 -0
- package/dist/registry/registry.options.d.ts +13 -0
- package/dist/registry/registry.options.js +28 -0
- package/dist/registry/registry.refusal.d.ts +6 -0
- package/dist/registry/registry.refusal.js +11 -0
- package/dist/registry/registry.schema.d.ts +238 -0
- package/dist/registry/registry.schema.js +127 -0
- package/dist/resources/auth-tables.catalog.d.ts +96 -0
- package/dist/resources/auth-tables.catalog.js +110 -0
- package/dist/resources/auth-tables.guard.d.ts +106 -0
- package/dist/resources/auth-tables.guard.js +462 -0
- package/dist/scope.resolver.d.ts +144 -0
- package/dist/scope.resolver.js +529 -0
- package/dist/sha256.digest.d.ts +2 -0
- package/dist/sha256.digest.js +81 -0
- package/dist/standalone/access.server.d.ts +41 -0
- package/dist/standalone/access.server.js +301 -0
- package/dist/standalone/index.d.ts +1 -0
- package/dist/standalone/index.js +1 -0
- package/package.json +102 -3
- package/skill/SKILL.md +170 -0
- package/skill/reference/callers.md +99 -0
- package/skill/reference/errors.md +89 -0
- package/skill/reference/recipes.md +281 -0
- package/skill/reference/rules.md +153 -0
- package/skill/reference/setup.md +334 -0
- package/src/aventara-where.translator.ts +116 -0
- package/src/beacon/beacon.check.ts +204 -0
- package/src/canonical-json.writer.ts +51 -0
- package/src/generator/prisma7/prisma-where.translator.ts +243 -0
- package/src/policies-contract.ts +215 -0
- package/src/policies.decider.ts +662 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +819 -0
- package/src/sha256.digest.ts +85 -0
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
// Builder → `@1` policies file, and the checks every policies file must pass before an
|
|
2
|
+
// engine runs from it: the format and the file's shape, unknown Resources, fields,
|
|
3
|
+
// relations and roles, role cycles, misplaced `in` / `after` / `fields` /
|
|
4
|
+
// `write.except`, grants, denies and governed fields on a foreign Resource, and
|
|
5
|
+
// create rules through a relation whose references discovery did not confirm.
|
|
6
|
+
// Errors name the rule.
|
|
7
|
+
// Nothing here runs per request.
|
|
8
|
+
import { POLICIES_FORMAT, RETIRED_POLICIES_FORMAT, } from "./policies-contract.js";
|
|
9
|
+
import { decimalParts, instantOf } from "./predicate.evaluator.js";
|
|
10
|
+
import { warnReadGovernedUnique } from "./read-governed-unique.check.js";
|
|
11
|
+
import { governedFor, isPlainObject, ownOf } from "./scope.resolver.js";
|
|
12
|
+
/** The marker a `me` value carries; the compiler writes it as `{ principal: [...] }`. */
|
|
13
|
+
export const PRINCIPAL_REF = Symbol.for("aventara.access.principal-ref");
|
|
14
|
+
export const ACTIONS = [
|
|
15
|
+
"read",
|
|
16
|
+
"create",
|
|
17
|
+
"update",
|
|
18
|
+
"delete",
|
|
19
|
+
];
|
|
20
|
+
export const ORIGINS = [
|
|
21
|
+
"application",
|
|
22
|
+
"client",
|
|
23
|
+
"agent",
|
|
24
|
+
];
|
|
25
|
+
const BUILT_IN_SUBJECTS = new Set(["signedIn", "anyone"]);
|
|
26
|
+
export const CLAIM_SCALARS = ["string", "number", "boolean"];
|
|
27
|
+
/** A namespace name: a lowercase letter, then lowercase letters, digits, `_` or `-`; at most 63 characters. */
|
|
28
|
+
const NAMESPACE = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
29
|
+
/** The members of a `@1` file, and of one of its schema entries. Anything else is refused. */
|
|
30
|
+
const FILE_KEYS = new Set([
|
|
31
|
+
"format",
|
|
32
|
+
"namespace",
|
|
33
|
+
"claims",
|
|
34
|
+
"schema",
|
|
35
|
+
"roles",
|
|
36
|
+
"govern",
|
|
37
|
+
"governModes",
|
|
38
|
+
"grants",
|
|
39
|
+
"denies",
|
|
40
|
+
]);
|
|
41
|
+
const SCHEMA_ENTRY_KEYS = new Set([
|
|
42
|
+
"identifiers",
|
|
43
|
+
"fields",
|
|
44
|
+
"nullable",
|
|
45
|
+
"types",
|
|
46
|
+
"relations",
|
|
47
|
+
"foreign",
|
|
48
|
+
]);
|
|
49
|
+
/** The scalar types `can` compares by more than their JavaScript value: a schema entry's `types`. */
|
|
50
|
+
const COMPARED_TYPES = new Set(["bigint", "datetime", "decimal"]);
|
|
51
|
+
export const fail = (where, message) => {
|
|
52
|
+
throw new Error(`Aventara Access: ${where}: ${message}`);
|
|
53
|
+
};
|
|
54
|
+
export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
export const asList = (value) => Array.isArray(value) ? value : [value];
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Schema facts
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/** The type `can` compares a scalar field by, when it is not its JavaScript value's. */
|
|
60
|
+
const comparedType = (field) => field.kind === "scalar" &&
|
|
61
|
+
!field.list &&
|
|
62
|
+
"scalar" in field.type &&
|
|
63
|
+
COMPARED_TYPES.has(field.type.scalar)
|
|
64
|
+
? field.type.scalar
|
|
65
|
+
: undefined;
|
|
66
|
+
const schemaFacts = (schema) => {
|
|
67
|
+
const facts = {};
|
|
68
|
+
for (const [resource, definition] of Object.entries(schema.resources)) {
|
|
69
|
+
const relations = {};
|
|
70
|
+
const nullable = [];
|
|
71
|
+
const types = {};
|
|
72
|
+
for (const [name, field] of Object.entries(definition.fields)) {
|
|
73
|
+
if (field.kind !== "relation") {
|
|
74
|
+
if (field.nullable) {
|
|
75
|
+
nullable.push(name);
|
|
76
|
+
}
|
|
77
|
+
const type = comparedType(field);
|
|
78
|
+
if (type !== undefined) {
|
|
79
|
+
types[name] = type;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const paired = field.referenceFields !== undefined &&
|
|
84
|
+
field.references !== undefined &&
|
|
85
|
+
field.referenceFields.length === field.references.length;
|
|
86
|
+
relations[name] = {
|
|
87
|
+
target: field.target,
|
|
88
|
+
cardinality: field.cardinality,
|
|
89
|
+
...(paired && field.referenceFields && field.references
|
|
90
|
+
? {
|
|
91
|
+
referenceFields: [...field.referenceFields],
|
|
92
|
+
references: [...field.references],
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
facts[resource] = {
|
|
98
|
+
identifiers: definition.identifiers.map((identifier) => [...identifier]),
|
|
99
|
+
fields: Object.keys(definition.fields),
|
|
100
|
+
...(nullable.length > 0 ? { nullable } : {}),
|
|
101
|
+
...(Object.keys(types).length > 0 ? { types } : {}),
|
|
102
|
+
relations,
|
|
103
|
+
...(definition.foreign !== undefined
|
|
104
|
+
? { foreign: definition.foreign }
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return facts;
|
|
109
|
+
};
|
|
110
|
+
const fieldOf = (context, resource, name) => {
|
|
111
|
+
const owner = context.schema.resources;
|
|
112
|
+
const fields = Object.hasOwn(owner, resource)
|
|
113
|
+
? owner[resource]?.fields
|
|
114
|
+
: undefined;
|
|
115
|
+
const field = fields !== undefined && Object.hasOwn(fields, name)
|
|
116
|
+
? fields[name]
|
|
117
|
+
: undefined;
|
|
118
|
+
return (field ?? fail(context.rule, `Resource "${resource}" has no field "${name}"`));
|
|
119
|
+
};
|
|
120
|
+
/** A decimal's canonical text: no exponent, no leading or trailing zero, `-` only when not zero. */
|
|
121
|
+
const decimalText = (parts) => {
|
|
122
|
+
const { sign, digits, point } = parts;
|
|
123
|
+
if (digits === "") {
|
|
124
|
+
return "0";
|
|
125
|
+
}
|
|
126
|
+
const text = point <= 0
|
|
127
|
+
? `0.${"0".repeat(-point)}${digits}`
|
|
128
|
+
: point >= digits.length
|
|
129
|
+
? `${digits}${"0".repeat(point - digits.length)}`
|
|
130
|
+
: `${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
131
|
+
return `${sign}${text}`;
|
|
132
|
+
};
|
|
133
|
+
/** The longest decimal text a rule may compare with: far past any database's precision. */
|
|
134
|
+
const DECIMAL_TEXT_MAX = 1000;
|
|
135
|
+
/**
|
|
136
|
+
* A literal as the file writes it for a field of the given type: a `datetime` as its
|
|
137
|
+
* canonical ISO instant (`toISOString()`), a `decimal` as its canonical text, a
|
|
138
|
+
* `bigint` as a number when it is a safe integer and as its digits otherwise.
|
|
139
|
+
*/
|
|
140
|
+
const literalFor = (context, at, value, field) => {
|
|
141
|
+
const refuse = (what) => fail(context.rule, `${at}: ${what} (got ${value === undefined ? "undefined" : value instanceof Date ? "an invalid Date" : typeof value === "object" && value !== null ? "an object" : typeof value === "string" ? JSON.stringify(value) : typeof value})`);
|
|
142
|
+
if (value === null) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
146
|
+
return refuse("a rule compares with a finite number");
|
|
147
|
+
}
|
|
148
|
+
const type = field === undefined ? undefined : comparedType(field);
|
|
149
|
+
const scalar = field !== undefined && "scalar" in field.type
|
|
150
|
+
? field.type.scalar
|
|
151
|
+
: undefined;
|
|
152
|
+
switch (type) {
|
|
153
|
+
case "datetime": {
|
|
154
|
+
const instant = value instanceof Date || typeof value === "string"
|
|
155
|
+
? instantOf(value)
|
|
156
|
+
: undefined;
|
|
157
|
+
return instant === undefined
|
|
158
|
+
? refuse("a datetime field compares with a valid Date or an ISO-8601 instant (a date, or a date and time with `Z` or an offset)")
|
|
159
|
+
: new Date(instant).toISOString();
|
|
160
|
+
}
|
|
161
|
+
case "decimal": {
|
|
162
|
+
const parts = typeof value === "number" || typeof value === "string"
|
|
163
|
+
? decimalParts(String(value))
|
|
164
|
+
: undefined;
|
|
165
|
+
const text = parts === undefined ? undefined : decimalText(parts);
|
|
166
|
+
return text === undefined || text.length > DECIMAL_TEXT_MAX
|
|
167
|
+
? refuse("a decimal field compares with a number or a decimal string")
|
|
168
|
+
: text;
|
|
169
|
+
}
|
|
170
|
+
case "bigint": {
|
|
171
|
+
if (typeof value === "bigint") {
|
|
172
|
+
return value >= BigInt(Number.MIN_SAFE_INTEGER) &&
|
|
173
|
+
value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
174
|
+
? Number(value)
|
|
175
|
+
: value.toString();
|
|
176
|
+
}
|
|
177
|
+
return typeof value === "number" && Number.isSafeInteger(value)
|
|
178
|
+
? value
|
|
179
|
+
: refuse("a bigint field compares with a bigint, or a number that is a safe integer");
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (typeof value !== "string" &&
|
|
185
|
+
typeof value !== "number" &&
|
|
186
|
+
typeof value !== "boolean") {
|
|
187
|
+
return refuse("a rule compares with a string, number, boolean, null or a `me` value");
|
|
188
|
+
}
|
|
189
|
+
const expected = field !== undefined && "enum" in field.type
|
|
190
|
+
? "string"
|
|
191
|
+
: scalar === "string" || scalar === "boolean"
|
|
192
|
+
? scalar
|
|
193
|
+
: scalar === "int" || scalar === "float"
|
|
194
|
+
? "number"
|
|
195
|
+
: undefined;
|
|
196
|
+
if (expected !== undefined && typeof value !== expected) {
|
|
197
|
+
return refuse(`a ${scalar ?? "enum"} field compares with a ${expected}`);
|
|
198
|
+
}
|
|
199
|
+
if (scalar === "int" && !Number.isInteger(value)) {
|
|
200
|
+
return refuse("an int field compares with an integer");
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
};
|
|
204
|
+
const operandOf = (context, at, value, field) => {
|
|
205
|
+
if (isRecord(value) && PRINCIPAL_REF in value) {
|
|
206
|
+
const path = value.path;
|
|
207
|
+
return { principal: [...path] };
|
|
208
|
+
}
|
|
209
|
+
return literalFor(context, at, value, field);
|
|
210
|
+
};
|
|
211
|
+
const andOf = (args) => args.length === 0
|
|
212
|
+
? { op: "true" }
|
|
213
|
+
: args.length === 1
|
|
214
|
+
? args[0]
|
|
215
|
+
: { op: "and", args };
|
|
216
|
+
const compileScalar = (context, resource, name, field, value) => {
|
|
217
|
+
const at = `${resource}.${name}`;
|
|
218
|
+
if (field.list) {
|
|
219
|
+
return fail(context.rule, `${at} is a list; a rule cannot compare it`);
|
|
220
|
+
}
|
|
221
|
+
// Only a plain object is an operator object: a `Date` is a literal, and any other
|
|
222
|
+
// object (a class instance, a `Map`) is refused, never read as "no operator".
|
|
223
|
+
const operators = isPlainObject(value) && !(PRINCIPAL_REF in value) ? value : undefined;
|
|
224
|
+
if (operators === undefined) {
|
|
225
|
+
return {
|
|
226
|
+
op: "cmp",
|
|
227
|
+
field: name,
|
|
228
|
+
cmp: "eq",
|
|
229
|
+
value: operandOf(context, at, value, field),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (Object.keys(operators).length === 0) {
|
|
233
|
+
fail(context.rule, `${at}: an empty operator object compares nothing; name one of equals, not, in, notIn`);
|
|
234
|
+
}
|
|
235
|
+
const parts = [];
|
|
236
|
+
for (const [operator, operand] of Object.entries(operators)) {
|
|
237
|
+
switch (operator) {
|
|
238
|
+
case "equals":
|
|
239
|
+
parts.push({
|
|
240
|
+
op: "cmp",
|
|
241
|
+
field: name,
|
|
242
|
+
cmp: "eq",
|
|
243
|
+
value: operandOf(context, at, operand, field),
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
case "not":
|
|
247
|
+
parts.push({
|
|
248
|
+
op: "cmp",
|
|
249
|
+
field: name,
|
|
250
|
+
cmp: "ne",
|
|
251
|
+
value: operandOf(context, at, operand, field),
|
|
252
|
+
});
|
|
253
|
+
break;
|
|
254
|
+
case "in":
|
|
255
|
+
case "notIn": {
|
|
256
|
+
if (!Array.isArray(operand)) {
|
|
257
|
+
fail(context.rule, `${at}.${operator} takes a list`);
|
|
258
|
+
}
|
|
259
|
+
parts.push({
|
|
260
|
+
op: "cmp",
|
|
261
|
+
field: name,
|
|
262
|
+
cmp: operator,
|
|
263
|
+
value: operand.map((item) => operandOf(context, at, item, field)),
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
fail(context.rule, `${at}: unknown operator "${operator}" (equals, not, in, notIn)`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return andOf(parts);
|
|
272
|
+
};
|
|
273
|
+
const compileWhere = (context, resource, where) => {
|
|
274
|
+
if (!isPlainObject(where)) {
|
|
275
|
+
return fail(context.rule, `a condition on ${resource} must be an object`);
|
|
276
|
+
}
|
|
277
|
+
const parts = [];
|
|
278
|
+
for (const [key, value] of Object.entries(where)) {
|
|
279
|
+
if (value === undefined) {
|
|
280
|
+
fail(context.rule, `${resource}.${key} is undefined`);
|
|
281
|
+
}
|
|
282
|
+
if (key === "AND" || key === "OR") {
|
|
283
|
+
if (!Array.isArray(value)) {
|
|
284
|
+
fail(context.rule, `${resource}.${key} takes a list`);
|
|
285
|
+
}
|
|
286
|
+
const args = value.map((item) => compileWhere(context, resource, item));
|
|
287
|
+
parts.push(key === "AND" ? { op: "and", args } : { op: "or", args });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (key === "NOT") {
|
|
291
|
+
parts.push({ op: "not", arg: compileWhere(context, resource, value) });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (key === "$role") {
|
|
295
|
+
if (typeof value !== "string") {
|
|
296
|
+
fail(context.rule, `${resource}.$role takes a role name`);
|
|
297
|
+
}
|
|
298
|
+
parts.push({ op: "role", role: value });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const field = fieldOf(context, resource, key);
|
|
302
|
+
if (field.kind === "scalar") {
|
|
303
|
+
parts.push(compileScalar(context, resource, key, field, value));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (field.cardinality === "one") {
|
|
307
|
+
if (value === null) {
|
|
308
|
+
fail(context.rule, `${resource}.${key}: comparing a relation with null is not supported in v0`);
|
|
309
|
+
}
|
|
310
|
+
parts.push({
|
|
311
|
+
op: "rel",
|
|
312
|
+
relation: key,
|
|
313
|
+
target: field.target,
|
|
314
|
+
where: compileWhere(context, field.target, value),
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!isRecord(value)) {
|
|
319
|
+
fail(context.rule, `${resource}.${key} is a to-many relation: use some, every or none`);
|
|
320
|
+
}
|
|
321
|
+
for (const [quantifier, sub] of Object.entries(value)) {
|
|
322
|
+
if (quantifier !== "some" &&
|
|
323
|
+
quantifier !== "every" &&
|
|
324
|
+
quantifier !== "none") {
|
|
325
|
+
fail(context.rule, `${resource}.${key}: unknown filter "${quantifier}" (some, every, none)`);
|
|
326
|
+
}
|
|
327
|
+
parts.push({
|
|
328
|
+
op: quantifier,
|
|
329
|
+
relation: key,
|
|
330
|
+
target: field.target,
|
|
331
|
+
where: compileWhere(context, field.target, sub),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return andOf(parts);
|
|
336
|
+
};
|
|
337
|
+
const claimTypeOf = (value) => typeof value === "string"
|
|
338
|
+
? "string"
|
|
339
|
+
: typeof value === "number"
|
|
340
|
+
? "number"
|
|
341
|
+
: typeof value === "boolean"
|
|
342
|
+
? "boolean"
|
|
343
|
+
: undefined;
|
|
344
|
+
const compileClaims = (rule, claims, types) => {
|
|
345
|
+
if (!isPlainObject(claims)) {
|
|
346
|
+
return fail(rule, "`claims` must be an object");
|
|
347
|
+
}
|
|
348
|
+
return Object.entries(claims).map(([claim, value]) => {
|
|
349
|
+
const scalar = claimTypeOf(value);
|
|
350
|
+
if (scalar === undefined ||
|
|
351
|
+
(typeof value === "number" && !Number.isFinite(value))) {
|
|
352
|
+
return fail(rule, `claim "${claim}" compares with a string, number or boolean`);
|
|
353
|
+
}
|
|
354
|
+
const known = types[claim];
|
|
355
|
+
if (known !== undefined && known.scalar !== scalar) {
|
|
356
|
+
fail(rule, `claim "${claim}" is compared as ${known.scalar} elsewhere and as ${scalar} here`);
|
|
357
|
+
}
|
|
358
|
+
types[claim] = { scalar };
|
|
359
|
+
return {
|
|
360
|
+
op: "cmp",
|
|
361
|
+
claim: [claim],
|
|
362
|
+
cmp: "eq",
|
|
363
|
+
value: value,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
const principalAnd = (args) => args.length === 1
|
|
368
|
+
? args[0]
|
|
369
|
+
: { op: "and", args };
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Rule ids
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
export const idAllocator = () => {
|
|
374
|
+
const seen = new Map();
|
|
375
|
+
const named = new Set();
|
|
376
|
+
return (prefix, name) => {
|
|
377
|
+
if (name !== undefined) {
|
|
378
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
379
|
+
return fail(prefix, "`name` must be a non-empty string");
|
|
380
|
+
}
|
|
381
|
+
if (named.has(name)) {
|
|
382
|
+
return fail(name, "two rules have this name");
|
|
383
|
+
}
|
|
384
|
+
named.add(name);
|
|
385
|
+
return name;
|
|
386
|
+
}
|
|
387
|
+
const count = (seen.get(prefix) ?? 0) + 1;
|
|
388
|
+
seen.set(prefix, count);
|
|
389
|
+
return `${prefix}#${count}`;
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Compile
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
const GRANT_OPTIONS = new Set([
|
|
396
|
+
"name",
|
|
397
|
+
"where",
|
|
398
|
+
"in",
|
|
399
|
+
"fields",
|
|
400
|
+
"write",
|
|
401
|
+
"after",
|
|
402
|
+
]);
|
|
403
|
+
const DENY_OPTIONS = new Set(["name", "where", "if"]);
|
|
404
|
+
const compileRoles = (definition, claimTypes) => {
|
|
405
|
+
const roles = {};
|
|
406
|
+
for (const [name, role] of Object.entries(definition.roles)) {
|
|
407
|
+
const context = { schema: definition.schema, rule: `role "${name}"` };
|
|
408
|
+
if (BUILT_IN_SUBJECTS.has(name)) {
|
|
409
|
+
fail(context.rule, "is a built-in subject");
|
|
410
|
+
}
|
|
411
|
+
if (isRecord(role) && "global" in role) {
|
|
412
|
+
const global = role.global;
|
|
413
|
+
if (!isRecord(global) ||
|
|
414
|
+
Object.keys(global).some((key) => key !== "claims")) {
|
|
415
|
+
fail(context.rule, "a global role is `{ global: { claims } }`");
|
|
416
|
+
}
|
|
417
|
+
const claims = compileClaims(context.rule, global.claims, claimTypes);
|
|
418
|
+
if (claims.length === 0) {
|
|
419
|
+
fail(context.rule, "a global role compares at least one claim; `{ claims: {} }` would hold for everyone, anonymous included");
|
|
420
|
+
}
|
|
421
|
+
roles[name] = { global: principalAnd(claims) };
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (isRecord(role) && typeof role.on === "string") {
|
|
425
|
+
if (!Object.hasOwn(definition.schema.resources, role.on)) {
|
|
426
|
+
fail(context.rule, `unknown Resource "${role.on}"`);
|
|
427
|
+
}
|
|
428
|
+
roles[name] = {
|
|
429
|
+
on: role.on,
|
|
430
|
+
where: compileWhere(context, role.on, role.where),
|
|
431
|
+
};
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
fail(context.rule, "a role is `{ global: { claims } }` or `{ on, where }`");
|
|
435
|
+
}
|
|
436
|
+
return roles;
|
|
437
|
+
};
|
|
438
|
+
const compileGrant = (definition, rule, allocate) => {
|
|
439
|
+
const subjects = [...asList(rule.subjects)];
|
|
440
|
+
const actions = [...asList(rule.actions)];
|
|
441
|
+
const prefix = `${rule.resource}/${actions.join(",")}/${subjects.join(",")}`;
|
|
442
|
+
for (const key of Object.keys(rule.options)) {
|
|
443
|
+
if (!GRANT_OPTIONS.has(key)) {
|
|
444
|
+
fail(prefix, `unknown grant option "${key}"`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const { name, where, in: inRelation, fields, write, after } = rule.options;
|
|
448
|
+
const id = allocate(prefix, name);
|
|
449
|
+
const context = { schema: definition.schema, rule: id };
|
|
450
|
+
const onResource = rule.resource === "*" ? undefined : rule.resource;
|
|
451
|
+
if (onResource === undefined &&
|
|
452
|
+
(where !== undefined ||
|
|
453
|
+
inRelation !== undefined ||
|
|
454
|
+
after !== undefined ||
|
|
455
|
+
write !== undefined)) {
|
|
456
|
+
fail(id, 'a grant on "*" takes no where, in, write or after');
|
|
457
|
+
}
|
|
458
|
+
let except;
|
|
459
|
+
if (write !== undefined) {
|
|
460
|
+
if (!isRecord(write) ||
|
|
461
|
+
!Array.isArray(write.except) ||
|
|
462
|
+
Object.keys(write).some((key) => key !== "except")) {
|
|
463
|
+
fail(id, "`write` is `{ except: [fields] }`");
|
|
464
|
+
}
|
|
465
|
+
except = [...write.except];
|
|
466
|
+
}
|
|
467
|
+
if (fields !== undefined && !Array.isArray(fields)) {
|
|
468
|
+
fail(id, "`fields` is a list of `Resource.field`");
|
|
469
|
+
}
|
|
470
|
+
if (inRelation !== undefined && typeof inRelation !== "string") {
|
|
471
|
+
fail(id, "`in` names a to-one relation");
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
id,
|
|
475
|
+
subjects,
|
|
476
|
+
actions,
|
|
477
|
+
resource: rule.resource,
|
|
478
|
+
...(inRelation !== undefined ? { in: inRelation } : {}),
|
|
479
|
+
...(where !== undefined && onResource !== undefined
|
|
480
|
+
? { where: compileWhere(context, onResource, where) }
|
|
481
|
+
: {}),
|
|
482
|
+
...(fields !== undefined
|
|
483
|
+
? { fields: [...fields] }
|
|
484
|
+
: {}),
|
|
485
|
+
...(except !== undefined ? { write: { except } } : {}),
|
|
486
|
+
...(after !== undefined && onResource !== undefined
|
|
487
|
+
? { after: compileWhere(context, onResource, after) }
|
|
488
|
+
: {}),
|
|
489
|
+
};
|
|
490
|
+
};
|
|
491
|
+
const compileDeny = (definition, rule, allocate, claimTypes) => {
|
|
492
|
+
const actions = rule.actions === "*" ? "*" : [...asList(rule.actions)];
|
|
493
|
+
const prefix = `${rule.resource}/${actions === "*" ? "*" : actions.join(",")}/deny`;
|
|
494
|
+
for (const key of Object.keys(rule.options)) {
|
|
495
|
+
if (!DENY_OPTIONS.has(key)) {
|
|
496
|
+
fail(prefix, `unknown deny option "${key}"`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const { name, where, if: condition } = rule.options;
|
|
500
|
+
const id = allocate(prefix, name);
|
|
501
|
+
const context = { schema: definition.schema, rule: id };
|
|
502
|
+
if (rule.resource === "*" && where !== undefined) {
|
|
503
|
+
fail(id, 'a deny on "*" takes no where');
|
|
504
|
+
}
|
|
505
|
+
const principal = condition === undefined
|
|
506
|
+
? undefined
|
|
507
|
+
: compilePrincipalIf(id, condition, claimTypes);
|
|
508
|
+
return {
|
|
509
|
+
id,
|
|
510
|
+
actions,
|
|
511
|
+
resource: rule.resource,
|
|
512
|
+
...(where !== undefined
|
|
513
|
+
? { where: compileWhere(context, rule.resource, where) }
|
|
514
|
+
: {}),
|
|
515
|
+
...(principal !== undefined ? { if: principal } : {}),
|
|
516
|
+
};
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* A rule's `if` (`{ claims?, origin? }`), compiled to a condition on the principal
|
|
520
|
+
* alone. Each claim's type is recorded in `claimTypes`, and a claim compared as two
|
|
521
|
+
* types is refused.
|
|
522
|
+
*/
|
|
523
|
+
export const compilePrincipalIf = (rule, condition, claimTypes) => {
|
|
524
|
+
if (!isRecord(condition) ||
|
|
525
|
+
Object.keys(condition).some((key) => key !== "claims" && key !== "origin")) {
|
|
526
|
+
fail(rule, "`if` is `{ claims?, origin? }`");
|
|
527
|
+
}
|
|
528
|
+
const { claims, origin } = condition;
|
|
529
|
+
const parts = [];
|
|
530
|
+
if (claims !== undefined) {
|
|
531
|
+
parts.push(...compileClaims(rule, claims, claimTypes));
|
|
532
|
+
}
|
|
533
|
+
if (origin !== undefined) {
|
|
534
|
+
if (!ORIGINS.includes(origin)) {
|
|
535
|
+
fail(rule, `unknown origin "${String(origin)}" (application, client, agent)`);
|
|
536
|
+
}
|
|
537
|
+
parts.push({ op: "origin", value: origin });
|
|
538
|
+
}
|
|
539
|
+
if (parts.length === 0) {
|
|
540
|
+
fail(rule, "`if` names no condition");
|
|
541
|
+
}
|
|
542
|
+
return principalAnd(parts);
|
|
543
|
+
};
|
|
544
|
+
/** Compiles the builder's rules to the `@1` policies file and checks it. */
|
|
545
|
+
export const compilePolicies = (definition) => {
|
|
546
|
+
const claimTypes = {};
|
|
547
|
+
const roles = compileRoles(definition, claimTypes);
|
|
548
|
+
const allocate = idAllocator();
|
|
549
|
+
const grants = definition.grants.map((rule) => compileGrant(definition, rule, allocate));
|
|
550
|
+
const denies = definition.denies.map((rule) => compileDeny(definition, rule, allocate, claimTypes));
|
|
551
|
+
// Each governed field once, with one mode: "both" in `govern` alone, "read" or
|
|
552
|
+
// "write" in `governModes` too.
|
|
553
|
+
const govern = {};
|
|
554
|
+
const governModes = {};
|
|
555
|
+
for (const [resource, entries] of Object.entries(definition.govern)) {
|
|
556
|
+
const at = `govern("${resource}")`;
|
|
557
|
+
const modes = new Map();
|
|
558
|
+
for (const { field, mode } of entries) {
|
|
559
|
+
fieldOf({ schema: definition.schema, rule: at }, resource, field);
|
|
560
|
+
if (mode !== "read" && mode !== "write" && mode !== "both") {
|
|
561
|
+
fail(at, `"${field}" is ${JSON.stringify(mode)}; a mode is "read", "write" or "both"`);
|
|
562
|
+
}
|
|
563
|
+
const prior = modes.get(field);
|
|
564
|
+
if (prior !== undefined && prior !== mode) {
|
|
565
|
+
fail(at, `"${field}" is governed twice, as "${prior}" and as "${mode}"; govern each field once, with one mode`);
|
|
566
|
+
}
|
|
567
|
+
modes.set(field, mode);
|
|
568
|
+
}
|
|
569
|
+
govern[resource] = [...modes.keys()];
|
|
570
|
+
const moded = [...modes].filter(([, mode]) => mode !== "both");
|
|
571
|
+
if (moded.length > 0) {
|
|
572
|
+
governModes[resource] = Object.fromEntries(moded);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const policies = {
|
|
576
|
+
format: POLICIES_FORMAT,
|
|
577
|
+
...(definition.namespace !== undefined
|
|
578
|
+
? { namespace: definition.namespace }
|
|
579
|
+
: {}),
|
|
580
|
+
claims: Object.fromEntries(Object.entries(claimTypes).sort(([a], [b]) => a.localeCompare(b))),
|
|
581
|
+
schema: schemaFacts(definition.schema),
|
|
582
|
+
roles,
|
|
583
|
+
govern: govern,
|
|
584
|
+
...(Object.keys(governModes).length > 0 ? { governModes } : {}),
|
|
585
|
+
grants,
|
|
586
|
+
denies,
|
|
587
|
+
};
|
|
588
|
+
checkPolicies(policies);
|
|
589
|
+
warnReadGovernedUnique(policies);
|
|
590
|
+
return policies;
|
|
591
|
+
};
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// Checks every policies file passes (a compiled one, or one loaded from JSON)
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
const walk = (predicate, visit, depth = 0) => {
|
|
596
|
+
visit(predicate, depth);
|
|
597
|
+
switch (predicate.op) {
|
|
598
|
+
case "and":
|
|
599
|
+
case "or":
|
|
600
|
+
for (const arg of predicate.args) {
|
|
601
|
+
walk(arg, visit, depth);
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
case "not":
|
|
605
|
+
walk(predicate.arg, visit, depth);
|
|
606
|
+
return;
|
|
607
|
+
case "rel":
|
|
608
|
+
case "some":
|
|
609
|
+
case "every":
|
|
610
|
+
case "none":
|
|
611
|
+
walk(predicate.where, visit, depth + 1);
|
|
612
|
+
return;
|
|
613
|
+
default:
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
/** Refuses a member of `node` that is not one of `keys`, naming the node. */
|
|
618
|
+
const only = (rule, node, what, keys) => {
|
|
619
|
+
for (const key of Object.keys(node)) {
|
|
620
|
+
if (!keys.includes(key)) {
|
|
621
|
+
fail(rule, `"${key}" is not a member of ${what} (${keys.join(", ")})`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
const COMPARISONS = new Set(["eq", "ne", "in", "notIn"]);
|
|
626
|
+
const isFiniteLiteral = (value) => value === null ||
|
|
627
|
+
typeof value === "string" ||
|
|
628
|
+
typeof value === "boolean" ||
|
|
629
|
+
(typeof value === "number" && Number.isFinite(value));
|
|
630
|
+
/** Whether `value` is a non-empty list of non-empty strings. */
|
|
631
|
+
const isPath = (value) => Array.isArray(value) &&
|
|
632
|
+
value.length > 0 &&
|
|
633
|
+
value.every((segment) => typeof segment === "string" && segment !== "");
|
|
634
|
+
/**
|
|
635
|
+
* One operand of a comparison on `resource.field`: a finite literal in the form the
|
|
636
|
+
* compiler writes for the field's type, or a `me` value (`{ principal: [path] }`).
|
|
637
|
+
*/
|
|
638
|
+
const checkOperand = (rule, at, type, value) => {
|
|
639
|
+
if (isPlainObject(value)) {
|
|
640
|
+
only(rule, value, `${at}'s \`me\` value`, ["principal"]);
|
|
641
|
+
if (!isPath(value.principal)) {
|
|
642
|
+
fail(rule, `${at}: a \`me\` value is \`{ "principal": [path] }\`, a non-empty list of names`);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!isFiniteLiteral(value)) {
|
|
647
|
+
fail(rule, `${at} compares with a string, a finite number, a boolean, null or a \`me\` value`);
|
|
648
|
+
}
|
|
649
|
+
if (value === null || type === undefined) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const canonical = type === "datetime"
|
|
653
|
+
? typeof value === "string" &&
|
|
654
|
+
instantOf(value) !== undefined &&
|
|
655
|
+
new Date(value).toISOString() === value
|
|
656
|
+
: type === "decimal"
|
|
657
|
+
? typeof value === "string" &&
|
|
658
|
+
value.length <= DECIMAL_TEXT_MAX &&
|
|
659
|
+
(() => {
|
|
660
|
+
const parts = decimalParts(value);
|
|
661
|
+
return parts !== undefined && decimalText(parts) === value;
|
|
662
|
+
})()
|
|
663
|
+
: typeof value === "number"
|
|
664
|
+
? Number.isSafeInteger(value)
|
|
665
|
+
: typeof value === "string" &&
|
|
666
|
+
/^-?[1-9]\d*$/.test(value) &&
|
|
667
|
+
!Number.isSafeInteger(Number(value));
|
|
668
|
+
if (!canonical) {
|
|
669
|
+
fail(rule, `${at} is a ${type} field: it compares with ${type === "datetime" ? "an instant as toISOString() writes it" : type === "decimal" ? "a decimal string with no exponent and no leading or trailing zero" : "a safe integer, or the digits of a bigint past it"}; it was ${JSON.stringify(value)}`);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Checks a predicate member by member, and its Resources, fields, relations and role
|
|
674
|
+
* references against the file's own schema facts, starting at `resource`.
|
|
675
|
+
*/
|
|
676
|
+
const checkPredicate = (policies, rule, resource, predicate) => {
|
|
677
|
+
const facts = ownOf(policies.schema, resource) ??
|
|
678
|
+
fail(rule, `unknown Resource "${resource}"`);
|
|
679
|
+
if (!isPlainObject(predicate)) {
|
|
680
|
+
fail(rule, `a condition on ${resource} is an object`);
|
|
681
|
+
}
|
|
682
|
+
const node = predicate;
|
|
683
|
+
const what = `a "${String(node.op)}" condition`;
|
|
684
|
+
switch (predicate.op) {
|
|
685
|
+
case "true":
|
|
686
|
+
case "false":
|
|
687
|
+
only(rule, node, what, ["op"]);
|
|
688
|
+
return;
|
|
689
|
+
case "and":
|
|
690
|
+
case "or":
|
|
691
|
+
only(rule, node, what, ["op", "args"]);
|
|
692
|
+
if (!Array.isArray(node.args)) {
|
|
693
|
+
fail(rule, `${what} takes an \`args\` list`);
|
|
694
|
+
}
|
|
695
|
+
for (const arg of predicate.args) {
|
|
696
|
+
checkPredicate(policies, rule, resource, arg);
|
|
697
|
+
}
|
|
698
|
+
return;
|
|
699
|
+
case "not":
|
|
700
|
+
only(rule, node, what, ["op", "arg"]);
|
|
701
|
+
checkPredicate(policies, rule, resource, predicate.arg);
|
|
702
|
+
return;
|
|
703
|
+
case "cmp": {
|
|
704
|
+
only(rule, node, what, ["op", "field", "cmp", "value"]);
|
|
705
|
+
const field = String(predicate.field);
|
|
706
|
+
if (typeof predicate.field !== "string" ||
|
|
707
|
+
!facts.fields.includes(field) ||
|
|
708
|
+
ownOf(facts.relations, field) !== undefined) {
|
|
709
|
+
fail(rule, `${resource} has no scalar field "${field}"`);
|
|
710
|
+
}
|
|
711
|
+
const at = `${resource}.${field}`;
|
|
712
|
+
if (!COMPARISONS.has(predicate.cmp)) {
|
|
713
|
+
fail(rule, `${at}: unknown comparison ${JSON.stringify(predicate.cmp)} (eq, ne, in, notIn)`);
|
|
714
|
+
}
|
|
715
|
+
const type = ownOf(facts.types, field);
|
|
716
|
+
const list = predicate.cmp === "in" || predicate.cmp === "notIn";
|
|
717
|
+
if (list !== Array.isArray(predicate.value)) {
|
|
718
|
+
fail(rule, list
|
|
719
|
+
? `${at}: "${predicate.cmp}" compares with a list`
|
|
720
|
+
: `${at}: "${predicate.cmp}" compares with one value, not a list`);
|
|
721
|
+
}
|
|
722
|
+
for (const operand of list
|
|
723
|
+
? predicate.value
|
|
724
|
+
: [predicate.value]) {
|
|
725
|
+
checkOperand(rule, at, type, operand);
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "rel":
|
|
730
|
+
case "some":
|
|
731
|
+
case "every":
|
|
732
|
+
case "none": {
|
|
733
|
+
only(rule, node, what, ["op", "relation", "target", "where"]);
|
|
734
|
+
const relation = typeof predicate.relation === "string"
|
|
735
|
+
? ownOf(facts.relations, predicate.relation)
|
|
736
|
+
: undefined;
|
|
737
|
+
if (relation === undefined || relation.target !== predicate.target) {
|
|
738
|
+
fail(rule, `${resource} has no relation "${String(predicate.relation)}" to ${String(predicate.target)}`);
|
|
739
|
+
}
|
|
740
|
+
const expected = predicate.op === "rel" ? "one" : "many";
|
|
741
|
+
if (relation?.cardinality !== expected) {
|
|
742
|
+
fail(rule, `${resource}.${predicate.relation} is to-${relation?.cardinality}; "${predicate.op}" needs to-${expected}`);
|
|
743
|
+
}
|
|
744
|
+
checkPredicate(policies, rule, predicate.target, predicate.where);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case "role": {
|
|
748
|
+
only(rule, node, what, ["op", "role"]);
|
|
749
|
+
const role = (typeof predicate.role === "string"
|
|
750
|
+
? ownOf(policies.roles, predicate.role)
|
|
751
|
+
: undefined) ??
|
|
752
|
+
fail(rule, `unknown role "${String(predicate.role)}"`);
|
|
753
|
+
if (!("on" in role)) {
|
|
754
|
+
fail(rule, `$role "${predicate.role}" is a global role; $role names a role held on a row`);
|
|
755
|
+
}
|
|
756
|
+
if ("on" in role && role.on !== resource) {
|
|
757
|
+
fail(rule, `$role "${predicate.role}" is held on ${role.on}, not on ${resource}`);
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
default:
|
|
762
|
+
fail(rule, `unknown condition ${JSON.stringify(node.op)} (true, false, and, or, not, cmp, rel, some, every, none, role)`);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
/** The roles a predicate references directly (not through other roles). */
|
|
766
|
+
const rolesIn = (predicate) => {
|
|
767
|
+
const found = [];
|
|
768
|
+
walk(predicate, (node) => {
|
|
769
|
+
if (node.op === "role") {
|
|
770
|
+
found.push(node.role);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
return found;
|
|
774
|
+
};
|
|
775
|
+
const checkRoleCycles = (policies) => {
|
|
776
|
+
const state = new Map();
|
|
777
|
+
const visit = (role, trail) => {
|
|
778
|
+
if (state.get(role) === "done") {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (state.get(role) === "visiting") {
|
|
782
|
+
fail(`role "${role}"`, `role cycle: ${[...trail, role].join(" → ")}`);
|
|
783
|
+
}
|
|
784
|
+
state.set(role, "visiting");
|
|
785
|
+
const definition = ownOf(policies.roles, role);
|
|
786
|
+
if (definition !== undefined && "on" in definition) {
|
|
787
|
+
for (const next of rolesIn(definition.where)) {
|
|
788
|
+
visit(next, [...trail, role]);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
state.set(role, "done");
|
|
792
|
+
};
|
|
793
|
+
for (const role of Object.keys(policies.roles)) {
|
|
794
|
+
visit(role, []);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* A created row carries its reference fields, never its relations: a to-one step
|
|
799
|
+
* at the row's own level is asked through `exists` on the target, which needs the
|
|
800
|
+
* relation's `references`. Without them the rule is refused.
|
|
801
|
+
* Roles are followed, since a role's condition is part of the rule.
|
|
802
|
+
*/
|
|
803
|
+
const checkCreateReferences = (policies, rule, resource, predicate, seenRoles = new Set()) => {
|
|
804
|
+
walk(predicate, (node, depth) => {
|
|
805
|
+
if (depth !== 0) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (node.op === "rel") {
|
|
809
|
+
const relation = ownOf(ownOf(policies.schema, resource)?.relations, node.relation);
|
|
810
|
+
if (relation?.references === undefined ||
|
|
811
|
+
relation.referenceFields === undefined) {
|
|
812
|
+
fail(rule, `a create rule goes through ${resource}.${node.relation}, whose references discovery does not state; Access never guesses them (needs @aventara/* rc.5 and a regenerated discovery artifact)`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (node.op === "role" && !seenRoles.has(node.role)) {
|
|
816
|
+
const role = ownOf(policies.roles, node.role);
|
|
817
|
+
if (role !== undefined && "on" in role) {
|
|
818
|
+
checkCreateReferences(policies, rule, resource, role.where, new Set([...seenRoles, node.role]));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* A condition on the principal, member by member: a non-empty `and`, an `origin`, or
|
|
825
|
+
* an `eq` on one claim the file's `claims` block declares, with a value of its type.
|
|
826
|
+
*/
|
|
827
|
+
const checkPrincipalCondition = (rule, condition, claims) => {
|
|
828
|
+
if (!isPlainObject(condition)) {
|
|
829
|
+
fail(rule, "a condition on the principal is an object");
|
|
830
|
+
}
|
|
831
|
+
const node = condition;
|
|
832
|
+
const what = `a "${String(node.op)}" condition on the principal`;
|
|
833
|
+
switch (condition.op) {
|
|
834
|
+
case "and":
|
|
835
|
+
only(rule, node, what, ["op", "args"]);
|
|
836
|
+
if (!Array.isArray(node.args) || node.args.length === 0) {
|
|
837
|
+
fail(rule, 'an "and" condition takes a non-empty `args` list: an empty one would hold for everyone');
|
|
838
|
+
}
|
|
839
|
+
for (const arg of condition.args) {
|
|
840
|
+
checkPrincipalCondition(rule, arg, claims);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
case "origin":
|
|
844
|
+
only(rule, node, what, ["op", "value"]);
|
|
845
|
+
if (!ORIGINS.includes(condition.value)) {
|
|
846
|
+
fail(rule, `unknown origin ${JSON.stringify(condition.value)} (application, client, agent)`);
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
case "cmp": {
|
|
850
|
+
only(rule, node, what, ["op", "claim", "cmp", "value"]);
|
|
851
|
+
const path = condition.claim;
|
|
852
|
+
if (!Array.isArray(path) ||
|
|
853
|
+
path.length !== 1 ||
|
|
854
|
+
typeof path[0] !== "string") {
|
|
855
|
+
fail(rule, 'a claim comparison names one claim: `"claim": ["<name>"]`');
|
|
856
|
+
}
|
|
857
|
+
const claim = path[0];
|
|
858
|
+
if (condition.cmp !== "eq") {
|
|
859
|
+
fail(rule, `a claim comparison is "eq"; it was ${JSON.stringify(condition.cmp)}`);
|
|
860
|
+
}
|
|
861
|
+
const value = condition.value;
|
|
862
|
+
if (typeof value !== "string" &&
|
|
863
|
+
typeof value !== "boolean" &&
|
|
864
|
+
!(typeof value === "number" && Number.isFinite(value))) {
|
|
865
|
+
fail(rule, `claim "${claim}" compares with a string, a finite number or a boolean`);
|
|
866
|
+
}
|
|
867
|
+
const declared = ownOf(claims, claim);
|
|
868
|
+
if (declared === undefined) {
|
|
869
|
+
fail(rule, `compares the claim "${claim}", which the file's "claims" block does not list`);
|
|
870
|
+
}
|
|
871
|
+
if (declared?.scalar !== typeof value) {
|
|
872
|
+
fail(rule, `compares the claim "${claim}" with a ${typeof value}, and the file's "claims" block lists it as ${declared?.scalar}`);
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
default:
|
|
877
|
+
fail(rule, `unknown condition ${JSON.stringify(node.op)}: a condition on the principal is "and", "cmp" on a claim, or "origin"`);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
export const checkActions = (rule, actions) => {
|
|
881
|
+
if (actions.length === 0) {
|
|
882
|
+
fail(rule, "names no action");
|
|
883
|
+
}
|
|
884
|
+
for (const action of actions) {
|
|
885
|
+
if (!ACTIONS.includes(action)) {
|
|
886
|
+
fail(rule, `unknown action "${action}" (read, create, update, delete)`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
/** The subject condition of a grant, as a predicate on its Resource (for the create-reference check). */
|
|
891
|
+
const subjectPredicate = (policies, grant, subject) => {
|
|
892
|
+
const role = ownOf(policies.roles, subject);
|
|
893
|
+
if (role === undefined || !("on" in role)) {
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
if (grant.in !== undefined) {
|
|
897
|
+
return {
|
|
898
|
+
op: "rel",
|
|
899
|
+
relation: grant.in,
|
|
900
|
+
target: role.on,
|
|
901
|
+
where: { op: "role", role: subject },
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
return { op: "role", role: subject };
|
|
905
|
+
};
|
|
906
|
+
const GRANT_KEYS = [
|
|
907
|
+
"id",
|
|
908
|
+
"subjects",
|
|
909
|
+
"actions",
|
|
910
|
+
"resource",
|
|
911
|
+
"in",
|
|
912
|
+
"where",
|
|
913
|
+
"fields",
|
|
914
|
+
"write",
|
|
915
|
+
"after",
|
|
916
|
+
];
|
|
917
|
+
const DENY_KEYS = ["id", "actions", "resource", "where", "if"];
|
|
918
|
+
/** Whether `value` is a list of strings. */
|
|
919
|
+
const isNames = (value) => Array.isArray(value) && value.every((name) => typeof name === "string");
|
|
920
|
+
/** A rule's members and their types, before what they name is checked. */
|
|
921
|
+
const checkRuleShape = (rule, index, kind) => {
|
|
922
|
+
const at = `${kind} ${index + 1}`;
|
|
923
|
+
if (!isPlainObject(rule)) {
|
|
924
|
+
return fail(at, `a ${kind} is an object`);
|
|
925
|
+
}
|
|
926
|
+
if (typeof rule.id !== "string" || rule.id === "") {
|
|
927
|
+
return fail(at, "`id` is a non-empty string");
|
|
928
|
+
}
|
|
929
|
+
const id = rule.id;
|
|
930
|
+
only(id, rule, `a ${kind}`, kind === "grant" ? GRANT_KEYS : DENY_KEYS);
|
|
931
|
+
if (typeof rule.resource !== "string" || rule.resource === "") {
|
|
932
|
+
fail(id, '`resource` is a Resource name or "*"');
|
|
933
|
+
}
|
|
934
|
+
if (kind === "grant"
|
|
935
|
+
? !isNames(rule.actions)
|
|
936
|
+
: rule.actions !== "*" && !isNames(rule.actions)) {
|
|
937
|
+
fail(id, kind === "grant"
|
|
938
|
+
? "`actions` is a list of actions"
|
|
939
|
+
: '`actions` is "*" or a list of actions');
|
|
940
|
+
}
|
|
941
|
+
if (kind === "deny") {
|
|
942
|
+
return id;
|
|
943
|
+
}
|
|
944
|
+
if (!isNames(rule.subjects)) {
|
|
945
|
+
fail(id, "`subjects` is a list of subjects");
|
|
946
|
+
}
|
|
947
|
+
if (rule.in !== undefined && typeof rule.in !== "string") {
|
|
948
|
+
fail(id, "`in` names a to-one relation");
|
|
949
|
+
}
|
|
950
|
+
if (rule.fields !== undefined && !isNames(rule.fields)) {
|
|
951
|
+
fail(id, "`fields` is a list of `Resource.field`");
|
|
952
|
+
}
|
|
953
|
+
if (rule.write !== undefined &&
|
|
954
|
+
!(isPlainObject(rule.write) &&
|
|
955
|
+
Object.keys(rule.write).every((key) => key === "except") &&
|
|
956
|
+
isNames(rule.write.except))) {
|
|
957
|
+
fail(id, "`write` is `{ except: [fields] }`");
|
|
958
|
+
}
|
|
959
|
+
return id;
|
|
960
|
+
};
|
|
961
|
+
const checkGrant = (policies, grant) => {
|
|
962
|
+
const rule = grant.id;
|
|
963
|
+
checkActions(rule, grant.actions);
|
|
964
|
+
if (grant.subjects.length === 0) {
|
|
965
|
+
fail(rule, "names no subject");
|
|
966
|
+
}
|
|
967
|
+
const star = grant.resource === "*";
|
|
968
|
+
if (!star && ownOf(policies.schema, grant.resource) === undefined) {
|
|
969
|
+
fail(rule, `unknown Resource "${grant.resource}"`);
|
|
970
|
+
}
|
|
971
|
+
const owner = star
|
|
972
|
+
? undefined
|
|
973
|
+
: ownOf(policies.schema, grant.resource)?.foreign;
|
|
974
|
+
if (owner !== undefined) {
|
|
975
|
+
fail(rule, `grants on ${grant.resource}, which namespace "${owner}" owns (foreign). A file reads a foreign Resource in conditions and never grants on it; ask "${owner}" to add the grant`);
|
|
976
|
+
}
|
|
977
|
+
for (const subject of grant.subjects) {
|
|
978
|
+
if (BUILT_IN_SUBJECTS.has(subject)) {
|
|
979
|
+
if (grant.in !== undefined) {
|
|
980
|
+
fail(rule, `\`in\` needs every subject to be a role held on a row; "${subject}" is not`);
|
|
981
|
+
}
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const role = ownOf(policies.roles, subject) ??
|
|
985
|
+
fail(rule, `unknown subject "${subject}"`);
|
|
986
|
+
if ("global" in role) {
|
|
987
|
+
if (grant.in !== undefined) {
|
|
988
|
+
fail(rule, `\`in\` needs every subject to be a role held on a row; "${subject}" is global`);
|
|
989
|
+
}
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (star) {
|
|
993
|
+
fail(rule, `"${subject}" is held on ${role.on}; a grant on "*" takes global roles and built-in subjects only`);
|
|
994
|
+
}
|
|
995
|
+
if (grant.in === undefined) {
|
|
996
|
+
if (role.on !== grant.resource) {
|
|
997
|
+
fail(rule, `"${subject}" is held on ${role.on}; on ${grant.resource} name the relation that leads there with \`in\``);
|
|
998
|
+
}
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const relation = ownOf(ownOf(policies.schema, grant.resource)?.relations, grant.in);
|
|
1002
|
+
if (relation === undefined || relation.cardinality !== "one") {
|
|
1003
|
+
fail(rule, `\`in: "${grant.in}"\` is not a to-one relation of ${grant.resource}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (relation?.target !== role.on) {
|
|
1006
|
+
fail(rule, `\`in: "${grant.in}"\` leads to ${relation?.target}, but "${subject}" is held on ${role.on}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (grant.where !== undefined) {
|
|
1010
|
+
checkPredicate(policies, rule, grant.resource, grant.where);
|
|
1011
|
+
}
|
|
1012
|
+
if (grant.after !== undefined) {
|
|
1013
|
+
if (!grant.actions.includes("update")) {
|
|
1014
|
+
fail(rule, "`after` applies to updates; this grant names no update");
|
|
1015
|
+
}
|
|
1016
|
+
checkPredicate(policies, rule, grant.resource, grant.after);
|
|
1017
|
+
}
|
|
1018
|
+
if (grant.write !== undefined) {
|
|
1019
|
+
if (!grant.actions.includes("create") &&
|
|
1020
|
+
!grant.actions.includes("update")) {
|
|
1021
|
+
fail(rule, "`write.except` applies to creates and updates; this grant names neither");
|
|
1022
|
+
}
|
|
1023
|
+
for (const field of grant.write.except) {
|
|
1024
|
+
if (!ownOf(policies.schema, grant.resource)?.fields.includes(field)) {
|
|
1025
|
+
fail(rule, `write.except: ${grant.resource} has no field "${field}"`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
for (const ref of grant.fields ?? []) {
|
|
1030
|
+
const [resource, field, ...rest] = ref.split(".");
|
|
1031
|
+
if (resource === undefined || field === undefined || rest.length > 0) {
|
|
1032
|
+
fail(rule, `fields: "${ref}" is not \`Resource.field\``);
|
|
1033
|
+
}
|
|
1034
|
+
if (!star && resource !== grant.resource) {
|
|
1035
|
+
fail(rule, `fields: "${ref}" is not a field of ${grant.resource}`);
|
|
1036
|
+
}
|
|
1037
|
+
const fieldOwner = ownOf(policies.schema, resource)?.foreign;
|
|
1038
|
+
if (fieldOwner !== undefined) {
|
|
1039
|
+
fail(rule, `fields: "${ref}" opens a field of ${resource}, which namespace "${fieldOwner}" owns (foreign); a file never grants on a foreign Resource`);
|
|
1040
|
+
}
|
|
1041
|
+
if (!(ownOf(policies.govern, resource) ?? []).includes(field)) {
|
|
1042
|
+
fail(rule, `fields: "${ref}" is not governed; only governed fields are named in a grant`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (grant.actions.includes("create") && !star) {
|
|
1046
|
+
const parts = [
|
|
1047
|
+
grant.where,
|
|
1048
|
+
...grant.subjects.map((subject) => subjectPredicate(policies, grant, subject)),
|
|
1049
|
+
];
|
|
1050
|
+
for (const part of parts) {
|
|
1051
|
+
if (part !== undefined) {
|
|
1052
|
+
checkCreateReferences(policies, rule, grant.resource, part);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
const checkDeny = (policies, deny) => {
|
|
1058
|
+
const rule = deny.id;
|
|
1059
|
+
if (deny.actions !== "*") {
|
|
1060
|
+
checkActions(rule, deny.actions);
|
|
1061
|
+
}
|
|
1062
|
+
if (deny.resource !== "*" &&
|
|
1063
|
+
ownOf(policies.schema, deny.resource) === undefined) {
|
|
1064
|
+
fail(rule, `unknown Resource "${deny.resource}"`);
|
|
1065
|
+
}
|
|
1066
|
+
const owner = deny.resource === "*"
|
|
1067
|
+
? undefined
|
|
1068
|
+
: ownOf(policies.schema, deny.resource)?.foreign;
|
|
1069
|
+
if (owner !== undefined) {
|
|
1070
|
+
fail(rule, `denies on ${deny.resource}, which namespace "${owner}" owns (foreign). A file's denies narrow only its own Resources; narrowing another namespace's is an org guardrail`);
|
|
1071
|
+
}
|
|
1072
|
+
if (deny.where !== undefined) {
|
|
1073
|
+
if (deny.resource === "*") {
|
|
1074
|
+
fail(rule, 'a deny on "*" takes no where');
|
|
1075
|
+
}
|
|
1076
|
+
checkPredicate(policies, rule, deny.resource, deny.where);
|
|
1077
|
+
if (deny.actions === "*" || deny.actions.includes("create")) {
|
|
1078
|
+
checkCreateReferences(policies, rule, deny.resource, deny.where);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (deny.if !== undefined) {
|
|
1082
|
+
checkPrincipalCondition(rule, deny.if, policies.claims);
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
/** The format, the namespace, the members of the file and of its schema entries, and the claim types. */
|
|
1086
|
+
const checkShape = (policies) => {
|
|
1087
|
+
if (!isRecord(policies)) {
|
|
1088
|
+
fail("policies", "a policies file is a JSON object");
|
|
1089
|
+
}
|
|
1090
|
+
const format = policies.format;
|
|
1091
|
+
if (format === RETIRED_POLICIES_FORMAT) {
|
|
1092
|
+
fail("policies", `this file is "${RETIRED_POLICIES_FORMAT}", a format Access no longer reads. Compile the rules again with definePolicies(model).contract() to get an "${POLICIES_FORMAT}" file`);
|
|
1093
|
+
}
|
|
1094
|
+
if (format !== POLICIES_FORMAT) {
|
|
1095
|
+
fail("policies", `format is ${JSON.stringify(format) ?? "missing"}, expected "${POLICIES_FORMAT}"`);
|
|
1096
|
+
}
|
|
1097
|
+
for (const key of Object.keys(policies)) {
|
|
1098
|
+
if (!FILE_KEYS.has(key)) {
|
|
1099
|
+
fail("policies", `"${key}" is not a member of ${POLICIES_FORMAT}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const namespace = policies.namespace;
|
|
1103
|
+
if (namespace !== undefined && !isNamespace(namespace)) {
|
|
1104
|
+
fail("policies", `namespace ${JSON.stringify(namespace)} is not a namespace name (a lowercase letter, then lowercase letters, digits, "_" or "-"; at most 63 characters)`);
|
|
1105
|
+
}
|
|
1106
|
+
for (const block of ["claims", "schema", "roles", "govern"]) {
|
|
1107
|
+
if (!isRecord(policies[block])) {
|
|
1108
|
+
fail("policies", `"${block}" is an object`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (policies.governModes !== undefined && !isRecord(policies.governModes)) {
|
|
1112
|
+
fail("policies", '"governModes" is an object');
|
|
1113
|
+
}
|
|
1114
|
+
// One set of rules, one file: a file whose fields are all governed for both
|
|
1115
|
+
// leaves the member out.
|
|
1116
|
+
if (policies.governModes !== undefined &&
|
|
1117
|
+
Object.keys(policies.governModes).length === 0) {
|
|
1118
|
+
fail("policies", '"governModes" names at least one Resource; a file whose governed fields are all governed for both has no "governModes"');
|
|
1119
|
+
}
|
|
1120
|
+
for (const block of ["grants", "denies"]) {
|
|
1121
|
+
if (!Array.isArray(policies[block])) {
|
|
1122
|
+
fail("policies", `"${block}" is a list`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
for (const [claim, type] of Object.entries(policies.claims)) {
|
|
1126
|
+
if (!isRecord(type) ||
|
|
1127
|
+
Object.keys(type).some((key) => key !== "scalar") ||
|
|
1128
|
+
!CLAIM_SCALARS.includes(type.scalar)) {
|
|
1129
|
+
fail(`claim "${claim}"`, 'a claim\'s type is { "scalar": "string" | "number" | "boolean" }');
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
for (const [resource, facts] of Object.entries(policies.schema)) {
|
|
1133
|
+
if (!isRecord(facts)) {
|
|
1134
|
+
fail(`schema "${resource}"`, "a schema entry is an object");
|
|
1135
|
+
}
|
|
1136
|
+
for (const key of Object.keys(facts)) {
|
|
1137
|
+
if (!SCHEMA_ENTRY_KEYS.has(key)) {
|
|
1138
|
+
fail(`schema "${resource}"`, `"${key}" is not a member of a schema entry (identifiers, fields, nullable, types, relations, foreign)`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
checkFacts(resource, facts);
|
|
1142
|
+
checkNullable(resource, facts);
|
|
1143
|
+
checkTypes(resource, facts);
|
|
1144
|
+
const owner = facts.foreign;
|
|
1145
|
+
if (owner === undefined) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (!isNamespace(owner)) {
|
|
1149
|
+
fail(`schema "${resource}"`, `foreign ${JSON.stringify(owner)} is not a namespace name`);
|
|
1150
|
+
}
|
|
1151
|
+
if (owner === namespace) {
|
|
1152
|
+
fail(`schema "${resource}"`, `is marked foreign to namespace "${owner}", which is this file's own namespace`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
checkRelationTargets(policies);
|
|
1156
|
+
};
|
|
1157
|
+
/**
|
|
1158
|
+
* A schema entry's `nullable`: a non-empty list of its own scalar fields, each named
|
|
1159
|
+
* once. An entry without it has no nullable field, so each meaning has one encoding.
|
|
1160
|
+
*/
|
|
1161
|
+
const checkNullable = (resource, facts) => {
|
|
1162
|
+
const nullable = facts.nullable;
|
|
1163
|
+
if (nullable === undefined) {
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (!Array.isArray(nullable) ||
|
|
1167
|
+
nullable.some((field) => typeof field !== "string")) {
|
|
1168
|
+
fail(`schema "${resource}"`, "nullable is a list of field names");
|
|
1169
|
+
}
|
|
1170
|
+
if (nullable.length === 0) {
|
|
1171
|
+
fail(`schema "${resource}"`, "nullable is never empty: an entry with no nullable field has no nullable member");
|
|
1172
|
+
}
|
|
1173
|
+
const seen = [];
|
|
1174
|
+
for (const field of nullable) {
|
|
1175
|
+
if (!Array.isArray(facts.fields) ||
|
|
1176
|
+
!facts.fields.includes(field) ||
|
|
1177
|
+
(isRecord(facts.relations) && field in facts.relations)) {
|
|
1178
|
+
fail(`schema "${resource}"`, `nullable names "${field}", which is not a scalar field of ${resource}`);
|
|
1179
|
+
}
|
|
1180
|
+
if (seen.includes(field)) {
|
|
1181
|
+
fail(`schema "${resource}"`, `nullable names "${field}" twice`);
|
|
1182
|
+
}
|
|
1183
|
+
seen.push(field);
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
/** A schema entry's identifiers, fields and relations, member by member. */
|
|
1187
|
+
const checkFacts = (resource, facts) => {
|
|
1188
|
+
const at = `schema "${resource}"`;
|
|
1189
|
+
checkName(at, "a Resource", resource);
|
|
1190
|
+
if (!isNames(facts.fields)) {
|
|
1191
|
+
fail(at, "fields is a list of field names");
|
|
1192
|
+
}
|
|
1193
|
+
for (const field of facts.fields) {
|
|
1194
|
+
checkName(at, "a field", field);
|
|
1195
|
+
}
|
|
1196
|
+
if (!Array.isArray(facts.identifiers) ||
|
|
1197
|
+
!facts.identifiers.every((identifier) => isNames(identifier) &&
|
|
1198
|
+
identifier.every((field) => facts.fields.includes(field)))) {
|
|
1199
|
+
fail(at, "identifiers is a list of lists of its own field names");
|
|
1200
|
+
}
|
|
1201
|
+
if (!isPlainObject(facts.relations)) {
|
|
1202
|
+
fail(at, "relations is an object");
|
|
1203
|
+
}
|
|
1204
|
+
for (const [name, fact] of Object.entries(facts.relations)) {
|
|
1205
|
+
const where = `${at}: relation "${name}"`;
|
|
1206
|
+
if (!facts.fields.includes(name)) {
|
|
1207
|
+
fail(where, "is not one of the entry's fields");
|
|
1208
|
+
}
|
|
1209
|
+
if (!isPlainObject(fact)) {
|
|
1210
|
+
fail(where, "is an object");
|
|
1211
|
+
}
|
|
1212
|
+
const relation = fact;
|
|
1213
|
+
only(where, relation, "a relation", [
|
|
1214
|
+
"target",
|
|
1215
|
+
"cardinality",
|
|
1216
|
+
"referenceFields",
|
|
1217
|
+
"references",
|
|
1218
|
+
]);
|
|
1219
|
+
if (typeof relation.target !== "string" ||
|
|
1220
|
+
(relation.cardinality !== "one" && relation.cardinality !== "many")) {
|
|
1221
|
+
fail(where, 'is `{ target, cardinality: "one" | "many" }`');
|
|
1222
|
+
}
|
|
1223
|
+
const pairs = [relation.referenceFields, relation.references];
|
|
1224
|
+
if (pairs.some((list) => list !== undefined) &&
|
|
1225
|
+
!(isPath(relation.referenceFields) &&
|
|
1226
|
+
isPath(relation.references) &&
|
|
1227
|
+
relation.referenceFields.length === relation.references.length)) {
|
|
1228
|
+
fail(where, "referenceFields and references are two lists of field names of the same length, or neither is there");
|
|
1229
|
+
}
|
|
1230
|
+
for (const field of isPath(relation.referenceFields)
|
|
1231
|
+
? relation.referenceFields
|
|
1232
|
+
: []) {
|
|
1233
|
+
if (!facts.fields.includes(field) ||
|
|
1234
|
+
ownOf(facts.relations, field) !== undefined) {
|
|
1235
|
+
fail(where, `referenceFields names "${field}", which is not a scalar field of ${resource}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
/**
|
|
1241
|
+
* A relation's `references` are fields of its target. A target the schema does not
|
|
1242
|
+
* list is left alone here (an author may trim the model); a rule that steps there is
|
|
1243
|
+
* refused as an unknown Resource.
|
|
1244
|
+
*/
|
|
1245
|
+
const checkRelationTargets = (policies) => {
|
|
1246
|
+
for (const [resource, facts] of Object.entries(policies.schema)) {
|
|
1247
|
+
for (const [name, relation] of Object.entries(facts.relations)) {
|
|
1248
|
+
const where = `schema "${resource}": relation "${name}"`;
|
|
1249
|
+
const target = ownOf(policies.schema, relation.target);
|
|
1250
|
+
if (target === undefined) {
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
for (const field of relation.references ?? []) {
|
|
1254
|
+
if (!target.fields.includes(field)) {
|
|
1255
|
+
fail(where, `references names "${field}", which is not a field of ${relation.target}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
/** A name a file uses for a Resource or a field: an identifier, never `__proto__`. */
|
|
1262
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1263
|
+
/** Whether `value` is a name a file may give a Resource or a field. */
|
|
1264
|
+
export const isName = (value) => typeof value === "string" && NAME.test(value) && value !== "__proto__";
|
|
1265
|
+
const checkName = (at, what, name) => {
|
|
1266
|
+
if (!isName(name)) {
|
|
1267
|
+
fail(at, `${JSON.stringify(name)} is not ${what} name: a name is a letter or "_", then letters, digits or "_", and never "__proto__"`);
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
/**
|
|
1271
|
+
* A schema entry's `types`: a non-empty map of its own scalar fields to `bigint`,
|
|
1272
|
+
* `datetime` or `decimal`. An entry without it has none of them.
|
|
1273
|
+
*/
|
|
1274
|
+
const checkTypes = (resource, facts) => {
|
|
1275
|
+
const types = facts.types;
|
|
1276
|
+
if (types === undefined) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const at = `schema "${resource}"`;
|
|
1280
|
+
if (!isPlainObject(types) || Object.keys(types).length === 0) {
|
|
1281
|
+
fail(at, "types is never empty: an entry with no bigint, datetime or decimal field has no types member");
|
|
1282
|
+
}
|
|
1283
|
+
for (const [field, type] of Object.entries(types)) {
|
|
1284
|
+
if (!facts.fields.includes(field) ||
|
|
1285
|
+
ownOf(facts.relations, field) !== undefined) {
|
|
1286
|
+
fail(at, `types names "${field}", which is not a scalar field of ${resource}`);
|
|
1287
|
+
}
|
|
1288
|
+
if (typeof type !== "string" || !COMPARED_TYPES.has(type)) {
|
|
1289
|
+
fail(at, `types: "${field}" is ${JSON.stringify(type)}; a type there is "bigint", "datetime" or "decimal"`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
/**
|
|
1294
|
+
* A governed relation seals its reference fields, and a governed reference field its
|
|
1295
|
+
* relation: reading either one tells the other. So `govern` names both or neither.
|
|
1296
|
+
*/
|
|
1297
|
+
const checkGovernPairs = (policies, resource) => {
|
|
1298
|
+
const relations = ownOf(policies.schema, resource)?.relations ?? {};
|
|
1299
|
+
const read = governedFor(policies, resource, "read");
|
|
1300
|
+
const write = governedFor(policies, resource, "write");
|
|
1301
|
+
for (const [relation, fact] of Object.entries(relations)) {
|
|
1302
|
+
const references = fact.referenceFields ?? [];
|
|
1303
|
+
if (references.length === 0) {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
const pair = [relation, ...references];
|
|
1307
|
+
// Checked for each access: reading either one tells the other, and writing
|
|
1308
|
+
// either one sets the other.
|
|
1309
|
+
const gapIn = (fields) => {
|
|
1310
|
+
const named = pair.filter((field) => fields.includes(field));
|
|
1311
|
+
return named.length > 0 && named.length < pair.length ? named : undefined;
|
|
1312
|
+
};
|
|
1313
|
+
const readGap = gapIn(read);
|
|
1314
|
+
const writeGap = gapIn(write);
|
|
1315
|
+
const named = readGap ?? writeGap;
|
|
1316
|
+
if (named === undefined) {
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
const fields = readGap !== undefined ? read : write;
|
|
1320
|
+
const missing = pair.filter((field) => !fields.includes(field));
|
|
1321
|
+
const access = readGap !== undefined &&
|
|
1322
|
+
writeGap !== undefined &&
|
|
1323
|
+
readGap.join() === writeGap.join()
|
|
1324
|
+
? ""
|
|
1325
|
+
: readGap !== undefined
|
|
1326
|
+
? " for reading"
|
|
1327
|
+
: " for writing";
|
|
1328
|
+
fail(`govern("${resource}")`, `governs ${named.map((field) => `"${field}"`).join(", ")} and not ${missing.map((field) => `"${field}"`).join(", ")}${access}: the relation "${relation}" and its reference fields (${references.join(", ")}) each tell the other, so govern them together`);
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1332
|
+
* `governModes`: each entry a Resource's fields `govern` lists, each `"read"` or
|
|
1333
|
+
* `"write"`; a field governed for both has no entry, so one set of rules has one file.
|
|
1334
|
+
*/
|
|
1335
|
+
const checkGovernModes = (policies) => {
|
|
1336
|
+
for (const [resource, modes] of Object.entries(policies.governModes ?? {})) {
|
|
1337
|
+
const at = `governModes("${resource}")`;
|
|
1338
|
+
if (!isRecord(modes)) {
|
|
1339
|
+
fail(at, 'an entry is an object of fields to "read" or "write"');
|
|
1340
|
+
}
|
|
1341
|
+
if (Object.keys(modes).length === 0) {
|
|
1342
|
+
fail(at, "an entry names at least one field");
|
|
1343
|
+
}
|
|
1344
|
+
const governed = ownOf(policies.govern, resource) ?? [];
|
|
1345
|
+
for (const [field, mode] of Object.entries(modes)) {
|
|
1346
|
+
if (!governed.includes(field)) {
|
|
1347
|
+
fail(at, `"${field}" is not governed; govern("${resource}") lists every governed field`);
|
|
1348
|
+
}
|
|
1349
|
+
if (mode !== "read" && mode !== "write") {
|
|
1350
|
+
fail(at, `"${field}" is ${JSON.stringify(mode)}; a mode there is "read" or "write" (a field governed for both has no entry)`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
/** Whether `value` is a namespace name. */
|
|
1356
|
+
export const isNamespace = (value) => typeof value === "string" && NAMESPACE.test(value);
|
|
1357
|
+
/**
|
|
1358
|
+
* The prefixes of the deny ids the engine composes itself: the org's guardrails and
|
|
1359
|
+
* the built-in rules over Better Auth's tables. A file's own deny never takes one.
|
|
1360
|
+
*/
|
|
1361
|
+
const RESERVED_IDS = ["guardrail:", "better-auth:"];
|
|
1362
|
+
/**
|
|
1363
|
+
* Checks a policies file before an engine runs from it. Throws an `Error` naming
|
|
1364
|
+
* the rule. The file is trusted for nothing it can be checked for. `composed`: the
|
|
1365
|
+
* file the engine composed itself, whose denies may take the reserved ids.
|
|
1366
|
+
*/
|
|
1367
|
+
export const checkPolicies = (policies, composed = false) => {
|
|
1368
|
+
checkShape(policies);
|
|
1369
|
+
for (const [name, role] of Object.entries(policies.roles)) {
|
|
1370
|
+
const rule = `role "${name}"`;
|
|
1371
|
+
if (BUILT_IN_SUBJECTS.has(name)) {
|
|
1372
|
+
fail(rule, "is a built-in subject");
|
|
1373
|
+
}
|
|
1374
|
+
if (!isPlainObject(role)) {
|
|
1375
|
+
fail(rule, "a role is `{ global }` or `{ on, where }`");
|
|
1376
|
+
}
|
|
1377
|
+
if ("global" in role) {
|
|
1378
|
+
only(rule, role, "a global role", ["global"]);
|
|
1379
|
+
checkPrincipalCondition(rule, role.global, policies.claims);
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
only(rule, role, "a role held on a row", ["on", "where"]);
|
|
1383
|
+
if (typeof role.on !== "string") {
|
|
1384
|
+
fail(rule, "`on` names the Resource the role is held on");
|
|
1385
|
+
}
|
|
1386
|
+
checkPredicate(policies, rule, role.on, role.where);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
checkRoleCycles(policies);
|
|
1390
|
+
for (const [resource, fields] of Object.entries(policies.govern)) {
|
|
1391
|
+
const at = `govern("${resource}")`;
|
|
1392
|
+
if (!isNames(fields)) {
|
|
1393
|
+
fail(at, "a governed list is a list of field names");
|
|
1394
|
+
}
|
|
1395
|
+
const facts = ownOf(policies.schema, resource) ??
|
|
1396
|
+
fail(at, `unknown Resource "${resource}"`);
|
|
1397
|
+
if (facts.foreign !== undefined) {
|
|
1398
|
+
fail(at, `${resource} is namespace "${facts.foreign}"'s (foreign); only the owner governs a Resource's fields`);
|
|
1399
|
+
}
|
|
1400
|
+
for (const field of fields) {
|
|
1401
|
+
if (!facts.fields.includes(field)) {
|
|
1402
|
+
fail(at, `${resource} has no field "${field}"`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
checkGovernModes(policies);
|
|
1407
|
+
for (const resource of Object.keys(policies.govern)) {
|
|
1408
|
+
checkGovernPairs(policies, resource);
|
|
1409
|
+
}
|
|
1410
|
+
policies.grants.forEach((grant, index) => {
|
|
1411
|
+
checkRuleShape(grant, index, "grant");
|
|
1412
|
+
});
|
|
1413
|
+
policies.denies.forEach((deny, index) => {
|
|
1414
|
+
checkRuleShape(deny, index, "deny");
|
|
1415
|
+
});
|
|
1416
|
+
const ids = new Set();
|
|
1417
|
+
for (const rule of [...policies.grants, ...policies.denies]) {
|
|
1418
|
+
if (ids.has(rule.id)) {
|
|
1419
|
+
fail(rule.id, "two rules have this id");
|
|
1420
|
+
}
|
|
1421
|
+
ids.add(rule.id);
|
|
1422
|
+
if (!composed &&
|
|
1423
|
+
RESERVED_IDS.some((prefix) => rule.id.startsWith(prefix))) {
|
|
1424
|
+
fail(rule.id, `the id is reserved: "guardrail:" and "better-auth:" name the denies the engine adds itself (the org's guardrails, Better Auth's tables)`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const grant of policies.grants) {
|
|
1428
|
+
checkGrant(policies, grant);
|
|
1429
|
+
}
|
|
1430
|
+
for (const deny of policies.denies) {
|
|
1431
|
+
checkDeny(policies, deny);
|
|
1432
|
+
}
|
|
1433
|
+
};
|