@aventara/access 0.0.0-stage → 0.1.0-rc.0
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 +831 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2439 -2
- package/dist/access/access-options.d.ts +66 -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 +273 -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 +12 -0
- package/dist/access/principal.subject.js +60 -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 +196 -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 +2 -0
- package/dist/access-warning.emitter.js +20 -0
- package/dist/access.builder.d.ts +299 -0
- package/dist/access.builder.js +66 -0
- package/dist/access.engine.d.ts +142 -0
- package/dist/access.engine.js +470 -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 +78 -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 +83 -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 +12 -0
- package/dist/foreign-references.check.js +134 -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 +72 -0
- package/dist/generator/prisma7/prisma-where.translator.js +136 -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 +11 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +36 -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 +192 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +36 -0
- package/dist/policies.compiler.js +1358 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +382 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +53 -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/reference-reads.check.d.ts +12 -0
- package/dist/reference-reads.check.js +148 -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 +215 -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 +91 -0
- package/dist/resources/auth-tables.catalog.js +102 -0
- package/dist/resources/auth-tables.guard.d.ts +95 -0
- package/dist/resources/auth-tables.guard.js +405 -0
- package/dist/scope.resolver.d.ts +138 -0
- package/dist/scope.resolver.js +512 -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 +101 -3
- 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 +241 -0
- package/src/policies-contract.ts +184 -0
- package/src/policies.decider.ts +650 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +791 -0
- package/src/sha256.digest.ts +85 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The ownership beacon's check, as a service runs it when it starts: each namespace
|
|
2
|
+
// whose Resources the rules read as `foreign` has one row in `_aventara_beacon`,
|
|
3
|
+
// written once by that namespace's own service with a random value, and the
|
|
4
|
+
// registry knows the value's hash. A database's beacons are its own, so their
|
|
5
|
+
// hashes are pinned per environment, apart from `avAccess.lock.json` (which holds
|
|
6
|
+
// against any registry): `avaccess pull --env <name>` writes them to
|
|
7
|
+
// `avAccess.beacons.<name>.json` (`AccessBeaconPins`), or the deploy hands the same
|
|
8
|
+
// object in from its environment. At startup the service reads each owner's row
|
|
9
|
+
// through its own connection: the row is there and hashes to the pin only when the
|
|
10
|
+
// service reaches the owner's database. Anything else (no row, another value, a
|
|
11
|
+
// value that is no beacon, an owner with no pin) means the foreign facts would come
|
|
12
|
+
// from somewhere the owner does not write, and the service refuses to decide.
|
|
13
|
+
//
|
|
14
|
+
// The check reads through an object the app hands in (`AccessBeaconReader`): the
|
|
15
|
+
// engine and the generated client have no database connection of their own and
|
|
16
|
+
// name no ORM. This module imports nothing and reaches no runtime API but WebCrypto's
|
|
17
|
+
// digest, so `avaccess generate` copies it into a generated client as it is.
|
|
18
|
+
|
|
19
|
+
/** Reads a namespace's beacon row, through the service's own connection. */
|
|
20
|
+
export type AccessBeaconReader = {
|
|
21
|
+
/**
|
|
22
|
+
* The `value` of `namespace`'s row in `_aventara_beacon`, as this service's
|
|
23
|
+
* connection reads it; `null` when there is none.
|
|
24
|
+
*/
|
|
25
|
+
read(namespace: string): Promise<string | null>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One environment's beacon pins, `avAccess.beacons.<environment>.json` as
|
|
30
|
+
* `avaccess pull --env <environment>` writes it: the hash of the beacon each
|
|
31
|
+
* namespace the rules read as foreign pushed from that environment's database.
|
|
32
|
+
* `format` is typed as a string so the file imported as JSON is one as it is.
|
|
33
|
+
*/
|
|
34
|
+
export type AccessBeaconPins = {
|
|
35
|
+
/** `"aventara-access/beacons@1"`. */
|
|
36
|
+
readonly format: string;
|
|
37
|
+
/** The namespace that reads the others: the lock's. */
|
|
38
|
+
readonly namespace: string;
|
|
39
|
+
/** The environment the pins were pulled for: `production`, `staging`, … */
|
|
40
|
+
readonly environment: string;
|
|
41
|
+
/** Each owner namespace's beacon hash, `sha256:<hex>`. */
|
|
42
|
+
readonly beacons: { readonly [namespace: string]: string };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The format marker of every beacon pins file. */
|
|
46
|
+
export const BEACON_PINS_FORMAT = "aventara-access/beacons@1";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The error a generated client throws when its beacons have not passed, shaped as
|
|
50
|
+
* `AccessError` is (`name`, `code`, a message that starts `Aventara Access:`): the
|
|
51
|
+
* client imports nothing, so it cannot throw the class itself.
|
|
52
|
+
*/
|
|
53
|
+
export const beaconMismatch = (
|
|
54
|
+
message: string,
|
|
55
|
+
): Error & { readonly code: "ACCESS_BEACON_MISMATCH" } =>
|
|
56
|
+
Object.assign(new Error(`Aventara Access: ${message}`), {
|
|
57
|
+
name: "AccessError",
|
|
58
|
+
code: "ACCESS_BEACON_MISMATCH" as const,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** A beacon: `avb_` and 32 random bytes in base64url. */
|
|
62
|
+
export const BEACON = /^avb_[A-Za-z0-9_-]{43}$/;
|
|
63
|
+
|
|
64
|
+
/** A pinned beacon hash: `sha256:` and 64 lowercase hex digits. */
|
|
65
|
+
export const BEACON_HASH = /^sha256:[0-9a-f]{64}$/;
|
|
66
|
+
|
|
67
|
+
/** A namespace or environment name: a lowercase letter, then lowercase letters, digits, `_` or `-`. */
|
|
68
|
+
const NAME = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
69
|
+
|
|
70
|
+
const PIN_MEMBERS = ["format", "namespace", "environment", "beacons"];
|
|
71
|
+
|
|
72
|
+
/** What the check reads of WebCrypto, typed here so the module needs no DOM or Node types. */
|
|
73
|
+
type Digest = {
|
|
74
|
+
readonly crypto: {
|
|
75
|
+
readonly subtle: {
|
|
76
|
+
digest(algorithm: "SHA-256", data: Uint8Array): Promise<ArrayBuffer>;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
readonly TextEncoder: new () => { encode(text: string): Uint8Array };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A beacon's hash, as the registry and the pins name it: `sha256:<hex>` of
|
|
84
|
+
* `<namespace>:<value>`. The namespace is bound in: a value registered under one
|
|
85
|
+
* namespace never matches, or collides with, another namespace's beacon.
|
|
86
|
+
*/
|
|
87
|
+
export const beaconHashOf = async (
|
|
88
|
+
namespace: string,
|
|
89
|
+
value: string,
|
|
90
|
+
): Promise<string> => {
|
|
91
|
+
const runtime = globalThis as unknown as Digest;
|
|
92
|
+
const digest = await runtime.crypto.subtle.digest(
|
|
93
|
+
"SHA-256",
|
|
94
|
+
new runtime.TextEncoder().encode(`${namespace}:${value}`),
|
|
95
|
+
);
|
|
96
|
+
let hex = "";
|
|
97
|
+
for (const byte of new Uint8Array(digest)) {
|
|
98
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
99
|
+
}
|
|
100
|
+
return `sha256:${hex}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What is wrong with `pins` as `namespace`'s beacon pins of `environment` for the
|
|
105
|
+
* `owners` its rules read as foreign: its format and members, its namespace, its
|
|
106
|
+
* environment, a `sha256:` hash for every owner; `undefined` when nothing is.
|
|
107
|
+
*/
|
|
108
|
+
export const beaconPinsProblem = (
|
|
109
|
+
namespace: string,
|
|
110
|
+
owners: readonly string[],
|
|
111
|
+
pins: unknown,
|
|
112
|
+
environment: unknown,
|
|
113
|
+
): string | undefined => {
|
|
114
|
+
if (typeof environment !== "string" || !NAME.test(environment)) {
|
|
115
|
+
return `the environment the service runs in is ${JSON.stringify(environment) ?? "missing"}, which is not an environment name (a lowercase letter, then lowercase letters, digits, "_" or "-"): name it, such as "production", and hand in that environment's pins`;
|
|
116
|
+
}
|
|
117
|
+
const file = pins as {
|
|
118
|
+
readonly [member: string]: unknown;
|
|
119
|
+
readonly beacons?: { readonly [namespace: string]: unknown };
|
|
120
|
+
} | null;
|
|
121
|
+
if (typeof file !== "object" || file === null || Array.isArray(file)) {
|
|
122
|
+
return "the beacon pins are an object, as `avaccess pull --env <environment>` writes avAccess.beacons.<environment>.json";
|
|
123
|
+
}
|
|
124
|
+
if (file.format !== BEACON_PINS_FORMAT) {
|
|
125
|
+
return `the beacon pins' format is "${BEACON_PINS_FORMAT}"; it was ${JSON.stringify(file.format) ?? "missing"}`;
|
|
126
|
+
}
|
|
127
|
+
const unknown = Object.keys(file).filter(
|
|
128
|
+
(member) => !PIN_MEMBERS.includes(member),
|
|
129
|
+
);
|
|
130
|
+
if (unknown.length > 0) {
|
|
131
|
+
return `the beacon pins have \`format\`, \`namespace\`, \`environment\` and \`beacons\`; they do not take ${unknown.map((member) => `\`${member}\``).join(", ")}`;
|
|
132
|
+
}
|
|
133
|
+
if (file.namespace !== namespace) {
|
|
134
|
+
return `the beacon pins are namespace ${JSON.stringify(file.namespace)}'s, and the rules are namespace "${namespace}"'s: pull this project's own pins with \`avaccess pull --env <environment>\``;
|
|
135
|
+
}
|
|
136
|
+
if (file.environment !== environment) {
|
|
137
|
+
return `the beacon pins are environment ${JSON.stringify(file.environment)}'s, and the service runs in environment "${environment}": hand in avAccess.beacons.${environment}.json`;
|
|
138
|
+
}
|
|
139
|
+
const beacons = file.beacons;
|
|
140
|
+
if (
|
|
141
|
+
typeof beacons !== "object" ||
|
|
142
|
+
beacons === null ||
|
|
143
|
+
Array.isArray(beacons) ||
|
|
144
|
+
Object.entries(beacons).some(
|
|
145
|
+
([owner, hash]) =>
|
|
146
|
+
!NAME.test(owner) ||
|
|
147
|
+
owner === namespace ||
|
|
148
|
+
typeof hash !== "string" ||
|
|
149
|
+
!BEACON_HASH.test(hash),
|
|
150
|
+
)
|
|
151
|
+
) {
|
|
152
|
+
return "the beacon pins' `beacons` maps each other namespace whose Resources the rules read to its beacon's `sha256:` hash";
|
|
153
|
+
}
|
|
154
|
+
const missing = owners.filter((owner) => !Object.hasOwn(beacons, owner));
|
|
155
|
+
if (missing.length > 0) {
|
|
156
|
+
return `the rules read ${missing.map((owner) => `namespace "${owner}"'s`).join(" and ")} Resources as foreign, and the beacon pins of environment "${file.environment}" hold no beacon of ${missing.length === 1 ? "it" : "them"}: pull again with \`avaccess pull --env ${file.environment} --beacon <module>\``;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What is wrong with the beacons of `owners` (the namespaces `namespace`'s rules
|
|
163
|
+
* read as foreign), held to `pins` (which must be `environment`'s) and read through
|
|
164
|
+
* `reader`; `undefined` when every
|
|
165
|
+
* owner's row is there and hashes to its pin. Each owner is read once, in order.
|
|
166
|
+
*/
|
|
167
|
+
export const beaconProblem = async (
|
|
168
|
+
namespace: string,
|
|
169
|
+
owners: readonly string[],
|
|
170
|
+
pins: unknown,
|
|
171
|
+
environment: unknown,
|
|
172
|
+
reader: AccessBeaconReader,
|
|
173
|
+
): Promise<string | undefined> => {
|
|
174
|
+
const invalid = beaconPinsProblem(namespace, owners, pins, environment);
|
|
175
|
+
if (invalid !== undefined) {
|
|
176
|
+
return invalid;
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
typeof reader !== "object" ||
|
|
180
|
+
reader === null ||
|
|
181
|
+
typeof reader.read !== "function"
|
|
182
|
+
) {
|
|
183
|
+
return "the beacon reader is `{ read(namespace) }`, which reads a namespace's row in _aventara_beacon through this service's own connection";
|
|
184
|
+
}
|
|
185
|
+
const { beacons } = pins as AccessBeaconPins;
|
|
186
|
+
for (const owner of [...owners].sort()) {
|
|
187
|
+
let value: unknown;
|
|
188
|
+
try {
|
|
189
|
+
value = await reader.read(owner);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return `reading namespace "${owner}"'s row in _aventara_beacon failed (${String((error as Error)?.message ?? error)}): this service cannot show it reaches "${owner}"'s database`;
|
|
192
|
+
}
|
|
193
|
+
if (value === null || value === undefined) {
|
|
194
|
+
return `this service's connection reads no row of namespace "${owner}" in _aventara_beacon, so it does not reach "${owner}"'s database: the rules read "${owner}"'s Resources as foreign, which works only within one database. Point the service at that database, or carry the fact in a JWT claim instead`;
|
|
195
|
+
}
|
|
196
|
+
if (typeof value !== "string" || !BEACON.test(value)) {
|
|
197
|
+
return `namespace "${owner}"'s row in _aventara_beacon holds no beacon (avb_ and 43 base64url characters): it was changed outside the service that writes it`;
|
|
198
|
+
}
|
|
199
|
+
if ((await beaconHashOf(owner, value)) !== beacons[owner]) {
|
|
200
|
+
return `namespace "${owner}"'s beacon, as this service's connection reads it, is not the one the beacon pins of environment "${environment}" hold: the service reaches another database than "${owner}"'s in that environment. Check that the pins are this environment's, pull them again with \`avaccess pull --env ${environment} --beacon <module>\` (it refuses while the databases differ), or carry the fact in a JWT claim instead`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The canonical JSON text both Access file formats hash (`policies.hash.ts`,
|
|
2
|
+
// `guardrails/guardrails.hash.ts`): every object's members sorted by key (UTF-16
|
|
3
|
+
// code units), whitespace removed, members whose value is `undefined` left out, and
|
|
4
|
+
// strings and numbers written as `JSON.stringify` writes them (RFC 8785's rules for
|
|
5
|
+
// the values these files hold). A number JSON cannot carry (NaN, Infinity) throws. Which lists a format treats as sets is the format's
|
|
6
|
+
// own decision; this writer keeps every list in the order it is given.
|
|
7
|
+
|
|
8
|
+
/** A JSON value, as the canonical writer reads it. */
|
|
9
|
+
export type CanonicalJson =
|
|
10
|
+
| string
|
|
11
|
+
| number
|
|
12
|
+
| boolean
|
|
13
|
+
| null
|
|
14
|
+
| readonly CanonicalJson[]
|
|
15
|
+
| { readonly [key: string]: CanonicalJson | undefined };
|
|
16
|
+
|
|
17
|
+
/** JSON text with every object's members sorted by key and no whitespace. */
|
|
18
|
+
export const canonicalJson = (value: CanonicalJson): string => {
|
|
19
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
20
|
+
// `JSON.stringify` writes NaN and Infinity as `null`: two files that decide
|
|
21
|
+
// differently would share one hash.
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Aventara Access: a file holds the number ${value}, and JSON carries finite numbers only`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (value === null || typeof value !== "object") {
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return `[${(value as readonly CanonicalJson[]).map(canonicalJson).join(",")}]`;
|
|
31
|
+
}
|
|
32
|
+
const record = value as {
|
|
33
|
+
readonly [key: string]: CanonicalJson | undefined;
|
|
34
|
+
};
|
|
35
|
+
const members = Object.keys(record)
|
|
36
|
+
.filter((key) => record[key] !== undefined)
|
|
37
|
+
.sort()
|
|
38
|
+
.map(
|
|
39
|
+
(key) =>
|
|
40
|
+
`${JSON.stringify(key)}:${canonicalJson(record[key] as CanonicalJson)}`,
|
|
41
|
+
);
|
|
42
|
+
return `{${members.join(",")}}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A copy of a list of names, sorted: the order of a list the format calls a set. */
|
|
46
|
+
export const sortedNames = <T extends string>(list: readonly T[]): T[] =>
|
|
47
|
+
[...list].sort();
|
|
48
|
+
|
|
49
|
+
/** Orders rules by their `id`. */
|
|
50
|
+
export const byId = <T extends { readonly id: string }>(a: T, b: T): number =>
|
|
51
|
+
a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The Prisma 7 translator: the answers the decisions give, in Prisma's words. A scope
|
|
2
|
+
// (`scopeOf`'s `where`, the shape the engine hands Aventara) becomes a Prisma
|
|
3
|
+
// `where`, a `fieldsOf` read answer becomes a Prisma `select`, and an `exists`
|
|
4
|
+
// question is asked through the service's own Prisma client. `avaccess generate
|
|
5
|
+
// --orm prisma7` copies this module into the generated client beside the decisions;
|
|
6
|
+
// like them, it imports nothing but them, and no Prisma module: what it builds is
|
|
7
|
+
// plain data that Prisma's own input types accept.
|
|
8
|
+
//
|
|
9
|
+
// Each step mirrors how the framework's Prisma 7 adapter writes Aventara's `where`:
|
|
10
|
+
// boolean nodes (`AND`, `OR`, `NOT`) and the scalar operators (`equals`, `not`,
|
|
11
|
+
// `in`, `notIn`) keep their spelling; a to-one relation's condition is wrapped in
|
|
12
|
+
// Prisma's `is`, because Prisma reads an empty unwrapped one as no condition at all;
|
|
13
|
+
// a to-many relation's `some`, `every` and `none` keep theirs.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type AccessWhere,
|
|
17
|
+
defineMember,
|
|
18
|
+
} from "../../aventara-where.translator.js";
|
|
19
|
+
import type { AccessFields } from "../../policies.decider.js";
|
|
20
|
+
import type { AccessPolicies } from "../../policies-contract.js";
|
|
21
|
+
|
|
22
|
+
/** A Prisma `where`: plain data any `<Model>WhereInput` accepts. */
|
|
23
|
+
export type PrismaWhere = { [key: string]: unknown };
|
|
24
|
+
|
|
25
|
+
/** A Prisma `select` of scalar fields. */
|
|
26
|
+
export type PrismaSelect = { [field: string]: true };
|
|
27
|
+
|
|
28
|
+
/** What `prismaExists` needs of a model's delegate on the Prisma client. */
|
|
29
|
+
export type PrismaFindFirst = {
|
|
30
|
+
findFirst(args: {
|
|
31
|
+
where: PrismaWhere;
|
|
32
|
+
select: PrismaSelect;
|
|
33
|
+
}): PromiseLike<unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isWhereNode = (value: unknown): value is AccessWhere =>
|
|
37
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
/** A record's own member, never one it inherits. */
|
|
40
|
+
const own = <T>(
|
|
41
|
+
record: { readonly [key: string]: T } | undefined,
|
|
42
|
+
key: string,
|
|
43
|
+
): T | undefined =>
|
|
44
|
+
record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A scalar field's operators, with each `in` list copied (Prisma's lists are
|
|
48
|
+
* mutable). A bigint past a safe integer, which the file writes as its digits, is
|
|
49
|
+
* handed to Prisma as a `bigint`.
|
|
50
|
+
*/
|
|
51
|
+
const operatorsOf = (value: unknown, bigint: boolean): unknown => {
|
|
52
|
+
const operandOf = (operand: unknown): unknown =>
|
|
53
|
+
bigint && typeof operand === "string" ? BigInt(operand) : operand;
|
|
54
|
+
return isWhereNode(value)
|
|
55
|
+
? Object.fromEntries(
|
|
56
|
+
Object.entries(value).map(([operator, operand]) => [
|
|
57
|
+
operator,
|
|
58
|
+
Array.isArray(operand) ? operand.map(operandOf) : operandOf(operand),
|
|
59
|
+
]),
|
|
60
|
+
)
|
|
61
|
+
: value;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Aventara's `where` on a Resource of `policies`, as Prisma's `where` on its model. */
|
|
65
|
+
export const toPrismaWhere = (
|
|
66
|
+
policies: AccessPolicies,
|
|
67
|
+
resource: string,
|
|
68
|
+
where: AccessWhere,
|
|
69
|
+
): PrismaWhere => {
|
|
70
|
+
const facts = own(policies.schema, resource);
|
|
71
|
+
const relations = facts?.relations ?? {};
|
|
72
|
+
const translated: PrismaWhere = {};
|
|
73
|
+
for (const [key, value] of Object.entries(where)) {
|
|
74
|
+
if (key === "AND" || key === "OR") {
|
|
75
|
+
defineMember(
|
|
76
|
+
translated,
|
|
77
|
+
key,
|
|
78
|
+
(value as readonly AccessWhere[]).map((node) =>
|
|
79
|
+
toPrismaWhere(policies, resource, node),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (key === "NOT") {
|
|
85
|
+
defineMember(
|
|
86
|
+
translated,
|
|
87
|
+
key,
|
|
88
|
+
toPrismaWhere(policies, resource, value as AccessWhere),
|
|
89
|
+
);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const relation = own(relations, key);
|
|
93
|
+
if (relation === undefined || !isWhereNode(value)) {
|
|
94
|
+
defineMember(
|
|
95
|
+
translated,
|
|
96
|
+
key,
|
|
97
|
+
operatorsOf(value, own(facts?.types, key) === "bigint"),
|
|
98
|
+
);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (relation.cardinality === "one") {
|
|
102
|
+
defineMember(translated, key, {
|
|
103
|
+
is: toPrismaWhere(policies, relation.target, value),
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
defineMember(
|
|
108
|
+
translated,
|
|
109
|
+
key,
|
|
110
|
+
Object.fromEntries(
|
|
111
|
+
Object.entries(value).map(([quantifier, node]) => [
|
|
112
|
+
quantifier,
|
|
113
|
+
toPrismaWhere(policies, relation.target, node as AccessWhere),
|
|
114
|
+
]),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return translated;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A scope (`scopeOf`'s answer) as Prisma's `where`: every row (`{}`), no row
|
|
123
|
+
* (`{ OR: [] }`, which Prisma matches with nothing), or the translated condition.
|
|
124
|
+
*/
|
|
125
|
+
export const toPrismaScope = (
|
|
126
|
+
policies: AccessPolicies,
|
|
127
|
+
resource: string,
|
|
128
|
+
scope: boolean | AccessWhere,
|
|
129
|
+
): PrismaWhere =>
|
|
130
|
+
scope === true
|
|
131
|
+
? {}
|
|
132
|
+
: scope === false
|
|
133
|
+
? { OR: [] }
|
|
134
|
+
: toPrismaWhere(policies, resource, scope);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A `fieldsOf` read answer as Prisma's `select`: the Resource's scalar fields a read
|
|
138
|
+
* shows on every row. A governed field is selected only when the answer opens it on
|
|
139
|
+
* every row; a field it opens on some rows only is left out, since a `select` cannot
|
|
140
|
+
* depend on the row (ask `fieldsOf` for those). Relations are never selected: a
|
|
141
|
+
* related row would not be scoped.
|
|
142
|
+
*/
|
|
143
|
+
export const toPrismaSelect = (
|
|
144
|
+
policies: AccessPolicies,
|
|
145
|
+
resource: string,
|
|
146
|
+
fields: AccessFields,
|
|
147
|
+
): PrismaSelect => {
|
|
148
|
+
const facts = own(policies.schema, resource);
|
|
149
|
+
const governed = own(policies.govern, resource) ?? [];
|
|
150
|
+
const scalars = (facts?.fields ?? []).filter(
|
|
151
|
+
(field) => own(facts?.relations, field) === undefined,
|
|
152
|
+
);
|
|
153
|
+
const select: PrismaSelect = {};
|
|
154
|
+
for (const field of scalars) {
|
|
155
|
+
const opened = own(fields.perRow, field);
|
|
156
|
+
const everyRow = opened !== undefined && Object.keys(opened).length === 0;
|
|
157
|
+
const always =
|
|
158
|
+
!governed.includes(field) &&
|
|
159
|
+
(fields.always === "all" || fields.always.includes(field));
|
|
160
|
+
if (always || everyRow) {
|
|
161
|
+
select[field] = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return select;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The property of the Prisma client that holds a model's delegate: its name, first letter lowered. */
|
|
168
|
+
export const prismaDelegateOf = (resource: string): string =>
|
|
169
|
+
`${resource.charAt(0).toLowerCase()}${resource.slice(1)}`;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Core's `exists`, through the service's Prisma client: whether a row of `resource`
|
|
173
|
+
* meets `where`, unscoped. It selects the row's first identifier and nothing else.
|
|
174
|
+
*/
|
|
175
|
+
export const existsThroughPrisma = async (
|
|
176
|
+
client: object,
|
|
177
|
+
policies: AccessPolicies,
|
|
178
|
+
resource: string,
|
|
179
|
+
where: AccessWhere,
|
|
180
|
+
): Promise<boolean> => {
|
|
181
|
+
const delegateName = prismaDelegateOf(resource);
|
|
182
|
+
const delegate = (client as { readonly [name: string]: unknown })[
|
|
183
|
+
delegateName
|
|
184
|
+
] as PrismaFindFirst | undefined;
|
|
185
|
+
if (delegate === undefined || typeof delegate.findFirst !== "function") {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Aventara Access: the rules ask whether a ${resource} row exists, and the Prisma client has no \`${delegateName}\`: add the model ${resource} to the Prisma schema this service queries`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const facts = own(policies.schema, resource);
|
|
191
|
+
const identifier = facts?.identifiers[0] ?? facts?.fields.slice(0, 1) ?? [];
|
|
192
|
+
const select: PrismaSelect = {};
|
|
193
|
+
for (const field of identifier) {
|
|
194
|
+
select[field] = true;
|
|
195
|
+
}
|
|
196
|
+
const found = await delegate.findFirst({
|
|
197
|
+
where: toPrismaWhere(policies, resource, where),
|
|
198
|
+
select,
|
|
199
|
+
});
|
|
200
|
+
return found !== null && found !== undefined;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The Prisma client the ownership beacon is read and written through: a model over
|
|
205
|
+
* the `_aventara_beacon` table, keyed by the namespace,
|
|
206
|
+
* `model AventaraBeacon { id String @id namespace String @unique value String createdAt DateTime @default(now()) @@map("_aventara_beacon") }`.
|
|
207
|
+
*/
|
|
208
|
+
export type PrismaBeaconClient = {
|
|
209
|
+
readonly aventaraBeacon: {
|
|
210
|
+
findUnique(args: {
|
|
211
|
+
where: { namespace: string };
|
|
212
|
+
select: { value: true };
|
|
213
|
+
}): PromiseLike<unknown>;
|
|
214
|
+
create(args: {
|
|
215
|
+
data: { namespace: string; value: string };
|
|
216
|
+
}): PromiseLike<unknown>;
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The beacon store over the service's Prisma client: `read` a namespace's row in
|
|
222
|
+
* `_aventara_beacon`, `create` one. What `avaccess push --beacon` and `pull --beacon`
|
|
223
|
+
* load, and what the client's `checkBeacons` reads through.
|
|
224
|
+
*/
|
|
225
|
+
export const beaconThroughPrisma = (
|
|
226
|
+
client: PrismaBeaconClient,
|
|
227
|
+
): {
|
|
228
|
+
read(namespace: string): Promise<string | null>;
|
|
229
|
+
create(namespace: string, value: string): Promise<void>;
|
|
230
|
+
} => ({
|
|
231
|
+
read: async (namespace) => {
|
|
232
|
+
const row = (await client.aventaraBeacon.findUnique({
|
|
233
|
+
where: { namespace },
|
|
234
|
+
select: { value: true },
|
|
235
|
+
})) as { readonly value?: unknown } | null | undefined;
|
|
236
|
+
return typeof row?.value === "string" ? row.value : null;
|
|
237
|
+
},
|
|
238
|
+
create: async (namespace, value) => {
|
|
239
|
+
await client.aventaraBeacon.create({ data: { namespace, value } });
|
|
240
|
+
},
|
|
241
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The declarative policies file, format `aventara-access/policies@1`. The builder
|
|
2
|
+
// compiles to it and the engine runs from it alone. It is passive JSON: no closures,
|
|
3
|
+
// no claim values, no provider names. `@1` is frozen: a change to its shape is `policies@2` beside it, never an
|
|
4
|
+
// edit of `@1`. Its canonical byte form and hash are `policies.hash.ts`'s.
|
|
5
|
+
|
|
6
|
+
/** The format marker of every `@1` file. */
|
|
7
|
+
export const POLICIES_FORMAT = "aventara-access/policies@1";
|
|
8
|
+
|
|
9
|
+
/** The format marker of the unstable v0 files, refused by name (no users, no shim). */
|
|
10
|
+
export const RETIRED_POLICIES_FORMAT = "aventara-access/policies@0";
|
|
11
|
+
|
|
12
|
+
/** The four standard operations a rule names. */
|
|
13
|
+
export type AccessAction = "read" | "create" | "update" | "delete";
|
|
14
|
+
|
|
15
|
+
/** The layer a request runs under (core's `AvRequestOrigin`, copied by shape). */
|
|
16
|
+
export type AccessOrigin = "application" | "client" | "agent";
|
|
17
|
+
|
|
18
|
+
/** The two built-in subjects: `signedIn` (a non-null principal) and `anyone` (null included). */
|
|
19
|
+
export type AccessBuiltInSubject = "signedIn" | "anyone";
|
|
20
|
+
|
|
21
|
+
/** A literal a rule compares with. */
|
|
22
|
+
export type AccessLiteral = string | number | boolean | null;
|
|
23
|
+
|
|
24
|
+
/** A value read off the principal at question time (`me.id` → `{ principal: ["id"] }`). */
|
|
25
|
+
export type AccessPrincipalOperand = {
|
|
26
|
+
readonly principal: readonly [string, ...string[]];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** What a field comparison compares with. */
|
|
30
|
+
export type AccessOperand = AccessLiteral | AccessPrincipalOperand;
|
|
31
|
+
|
|
32
|
+
/** The comparisons a rule may make. Each maps one-to-one to an Aventara filter operator. */
|
|
33
|
+
export type AccessComparison = "eq" | "ne" | "in" | "notIn";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A row condition in Access's neutral form. Field names are the Resource's own;
|
|
37
|
+
* a relation step names its target so the file needs no schema to walk it.
|
|
38
|
+
*/
|
|
39
|
+
export type AccessPredicate =
|
|
40
|
+
| { readonly op: "true" }
|
|
41
|
+
| { readonly op: "false" }
|
|
42
|
+
| { readonly op: "and"; readonly args: readonly AccessPredicate[] }
|
|
43
|
+
| { readonly op: "or"; readonly args: readonly AccessPredicate[] }
|
|
44
|
+
| { readonly op: "not"; readonly arg: AccessPredicate }
|
|
45
|
+
| {
|
|
46
|
+
readonly op: "cmp";
|
|
47
|
+
readonly field: string;
|
|
48
|
+
readonly cmp: AccessComparison;
|
|
49
|
+
readonly value: AccessOperand | readonly AccessOperand[];
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
readonly op: "rel";
|
|
53
|
+
readonly relation: string;
|
|
54
|
+
readonly target: string;
|
|
55
|
+
readonly where: AccessPredicate;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly op: "some" | "every" | "none";
|
|
59
|
+
readonly relation: string;
|
|
60
|
+
readonly target: string;
|
|
61
|
+
readonly where: AccessPredicate;
|
|
62
|
+
}
|
|
63
|
+
| { readonly op: "role"; readonly role: string };
|
|
64
|
+
|
|
65
|
+
/** A condition on the principal alone: its claims and the request's origin. */
|
|
66
|
+
export type AccessPrincipalCondition =
|
|
67
|
+
| { readonly op: "and"; readonly args: readonly AccessPrincipalCondition[] }
|
|
68
|
+
| {
|
|
69
|
+
readonly op: "cmp";
|
|
70
|
+
readonly claim: readonly [string, ...string[]];
|
|
71
|
+
readonly cmp: "eq";
|
|
72
|
+
readonly value: AccessLiteral;
|
|
73
|
+
}
|
|
74
|
+
| { readonly op: "origin"; readonly value: AccessOrigin };
|
|
75
|
+
|
|
76
|
+
/** A role: global (read from claims) or held on a row of one Resource. */
|
|
77
|
+
export type AccessRoleDefinition =
|
|
78
|
+
| { readonly global: AccessPrincipalCondition }
|
|
79
|
+
| { readonly on: string; readonly where: AccessPredicate };
|
|
80
|
+
|
|
81
|
+
/** One grant. Any grant allows (default deny). */
|
|
82
|
+
export type AccessGrantRule = {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly subjects: readonly string[];
|
|
85
|
+
readonly actions: readonly AccessAction[];
|
|
86
|
+
readonly resource: string;
|
|
87
|
+
/** The to-one relation whose target holds each subject's row role. */
|
|
88
|
+
readonly in?: string;
|
|
89
|
+
readonly where?: AccessPredicate;
|
|
90
|
+
/** Governed fields this grant opens, as `Resource.field`. */
|
|
91
|
+
readonly fields?: readonly string[];
|
|
92
|
+
/** Fields this grant does not let a create or update write. */
|
|
93
|
+
readonly write?: { readonly except: readonly string[] };
|
|
94
|
+
/** A condition the row an update leaves must still meet. */
|
|
95
|
+
readonly after?: AccessPredicate;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** One deny. Any deny wins over every grant. */
|
|
99
|
+
export type AccessDenyRule = {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly actions: "*" | readonly AccessAction[];
|
|
102
|
+
readonly resource: string;
|
|
103
|
+
readonly where?: AccessPredicate;
|
|
104
|
+
readonly if?: AccessPrincipalCondition;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A relation as the engine needs it: where it leads, and the reference pairs when this end holds them. */
|
|
108
|
+
export type AccessSchemaRelationFact = {
|
|
109
|
+
readonly target: string;
|
|
110
|
+
readonly cardinality: "one" | "many";
|
|
111
|
+
readonly referenceFields?: readonly [string, ...string[]];
|
|
112
|
+
readonly references?: readonly [string, ...string[]];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The schema facts the engine reads, per Resource. */
|
|
116
|
+
export type AccessSchemaResourceFacts = {
|
|
117
|
+
readonly identifiers: readonly (readonly string[])[];
|
|
118
|
+
readonly fields: readonly string[];
|
|
119
|
+
/**
|
|
120
|
+
* The scalar fields that may hold NULL, when there are any. A scope negates a
|
|
121
|
+
* comparison on one of them null-safely (`NOT (f = v) OR f IS NULL`), so it keeps
|
|
122
|
+
* the rows `can` keeps.
|
|
123
|
+
*/
|
|
124
|
+
readonly nullable?: readonly string[];
|
|
125
|
+
/**
|
|
126
|
+
* The scalar fields `can` compares by their type rather than by their JavaScript
|
|
127
|
+
* value, when there are any: a `decimal` as its canonical decimal digits (a decimal
|
|
128
|
+
* object, a string and a number alike), a `datetime` as its instant, a `bigint`
|
|
129
|
+
* exactly. A file without `types` has none of them.
|
|
130
|
+
*/
|
|
131
|
+
readonly types?: {
|
|
132
|
+
readonly [field: string]: "bigint" | "datetime" | "decimal";
|
|
133
|
+
};
|
|
134
|
+
readonly relations: { readonly [relation: string]: AccessSchemaRelationFact };
|
|
135
|
+
/**
|
|
136
|
+
* The namespace that owns this Resource, when it is not this file's. A foreign
|
|
137
|
+
* Resource is read in conditions and never granted on.
|
|
138
|
+
*/
|
|
139
|
+
readonly foreign?: string;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A claim's type, never its value. */
|
|
143
|
+
export type AccessClaimType = {
|
|
144
|
+
readonly scalar: "string" | "number" | "boolean";
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The `@1` policies file. `Governed` keeps the `govern` block's literal type through the builder. */
|
|
148
|
+
export type AccessPolicies<
|
|
149
|
+
Governed extends { readonly [resource: string]: readonly string[] } = {
|
|
150
|
+
readonly [resource: string]: readonly string[];
|
|
151
|
+
},
|
|
152
|
+
> = {
|
|
153
|
+
readonly format: typeof POLICIES_FORMAT;
|
|
154
|
+
/** The namespace that owns this file's Resources. A single app needs none. */
|
|
155
|
+
readonly namespace?: string;
|
|
156
|
+
readonly claims: { readonly [claim: string]: AccessClaimType };
|
|
157
|
+
readonly schema: { readonly [resource: string]: AccessSchemaResourceFacts };
|
|
158
|
+
readonly roles: { readonly [role: string]: AccessRoleDefinition };
|
|
159
|
+
readonly govern: Governed;
|
|
160
|
+
readonly grants: readonly AccessGrantRule[];
|
|
161
|
+
readonly denies: readonly AccessDenyRule[];
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A policies file as a JSON import types it: `resolveJsonModule` widens `format` to
|
|
166
|
+
* `string` and each tuple to an array, so the file `avaccess pull` wrote, imported
|
|
167
|
+
* with `import policies from "./access/blog.policies.json" with { type: "json" }`,
|
|
168
|
+
* is not an `AccessPolicies`. `policies.rules` and `createAccessEngine` take it as it
|
|
169
|
+
* is: only its outline is typed, and the engine checks the rest when it is built.
|
|
170
|
+
*/
|
|
171
|
+
export type AccessPoliciesJson<
|
|
172
|
+
Governed extends { readonly [resource: string]: readonly string[] } = {
|
|
173
|
+
readonly [resource: string]: readonly string[];
|
|
174
|
+
},
|
|
175
|
+
> = {
|
|
176
|
+
readonly format: string;
|
|
177
|
+
readonly namespace?: string;
|
|
178
|
+
readonly claims: { readonly [claim: string]: { readonly scalar: string } };
|
|
179
|
+
readonly schema: { readonly [resource: string]: object };
|
|
180
|
+
readonly roles: { readonly [role: string]: object };
|
|
181
|
+
readonly govern: Governed;
|
|
182
|
+
readonly grants: readonly object[];
|
|
183
|
+
readonly denies: readonly object[];
|
|
184
|
+
};
|