@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,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,243 @@
|
|
|
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
|
+
import { governedFor } from "../../scope.resolver.js";
|
|
22
|
+
|
|
23
|
+
/** A Prisma `where`: plain data any `<Model>WhereInput` accepts. */
|
|
24
|
+
export type PrismaWhere = { [key: string]: unknown };
|
|
25
|
+
|
|
26
|
+
/** A Prisma `select` of scalar fields. */
|
|
27
|
+
export type PrismaSelect = { [field: string]: true };
|
|
28
|
+
|
|
29
|
+
/** What `prismaExists` needs of a model's delegate on the Prisma client. */
|
|
30
|
+
export type PrismaFindFirst = {
|
|
31
|
+
findFirst(args: {
|
|
32
|
+
where: PrismaWhere;
|
|
33
|
+
select: PrismaSelect;
|
|
34
|
+
}): PromiseLike<unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isWhereNode = (value: unknown): value is AccessWhere =>
|
|
38
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
|
|
40
|
+
/** A record's own member, never one it inherits. */
|
|
41
|
+
const own = <T>(
|
|
42
|
+
record: { readonly [key: string]: T } | undefined,
|
|
43
|
+
key: string,
|
|
44
|
+
): T | undefined =>
|
|
45
|
+
record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A scalar field's operators, with each `in` list copied (Prisma's lists are
|
|
49
|
+
* mutable). A bigint past a safe integer, which the file writes as its digits, is
|
|
50
|
+
* handed to Prisma as a `bigint`.
|
|
51
|
+
*/
|
|
52
|
+
const operatorsOf = (value: unknown, bigint: boolean): unknown => {
|
|
53
|
+
const operandOf = (operand: unknown): unknown =>
|
|
54
|
+
bigint && typeof operand === "string" ? BigInt(operand) : operand;
|
|
55
|
+
return isWhereNode(value)
|
|
56
|
+
? Object.fromEntries(
|
|
57
|
+
Object.entries(value).map(([operator, operand]) => [
|
|
58
|
+
operator,
|
|
59
|
+
Array.isArray(operand) ? operand.map(operandOf) : operandOf(operand),
|
|
60
|
+
]),
|
|
61
|
+
)
|
|
62
|
+
: value;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Aventara's `where` on a Resource of `policies`, as Prisma's `where` on its model. */
|
|
66
|
+
export const toPrismaWhere = (
|
|
67
|
+
policies: AccessPolicies,
|
|
68
|
+
resource: string,
|
|
69
|
+
where: AccessWhere,
|
|
70
|
+
): PrismaWhere => {
|
|
71
|
+
const facts = own(policies.schema, resource);
|
|
72
|
+
const relations = facts?.relations ?? {};
|
|
73
|
+
const translated: PrismaWhere = {};
|
|
74
|
+
for (const [key, value] of Object.entries(where)) {
|
|
75
|
+
if (key === "AND" || key === "OR") {
|
|
76
|
+
defineMember(
|
|
77
|
+
translated,
|
|
78
|
+
key,
|
|
79
|
+
(value as readonly AccessWhere[]).map((node) =>
|
|
80
|
+
toPrismaWhere(policies, resource, node),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (key === "NOT") {
|
|
86
|
+
defineMember(
|
|
87
|
+
translated,
|
|
88
|
+
key,
|
|
89
|
+
toPrismaWhere(policies, resource, value as AccessWhere),
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const relation = own(relations, key);
|
|
94
|
+
if (relation === undefined || !isWhereNode(value)) {
|
|
95
|
+
defineMember(
|
|
96
|
+
translated,
|
|
97
|
+
key,
|
|
98
|
+
operatorsOf(value, own(facts?.types, key) === "bigint"),
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (relation.cardinality === "one") {
|
|
103
|
+
defineMember(translated, key, {
|
|
104
|
+
is: toPrismaWhere(policies, relation.target, value),
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
defineMember(
|
|
109
|
+
translated,
|
|
110
|
+
key,
|
|
111
|
+
Object.fromEntries(
|
|
112
|
+
Object.entries(value).map(([quantifier, node]) => [
|
|
113
|
+
quantifier,
|
|
114
|
+
toPrismaWhere(policies, relation.target, node as AccessWhere),
|
|
115
|
+
]),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return translated;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A scope (`scopeOf`'s answer) as Prisma's `where`: every row (`{}`), no row
|
|
124
|
+
* (`{ OR: [] }`, which Prisma matches with nothing), or the translated condition.
|
|
125
|
+
*/
|
|
126
|
+
export const toPrismaScope = (
|
|
127
|
+
policies: AccessPolicies,
|
|
128
|
+
resource: string,
|
|
129
|
+
scope: boolean | AccessWhere,
|
|
130
|
+
): PrismaWhere =>
|
|
131
|
+
scope === true
|
|
132
|
+
? {}
|
|
133
|
+
: scope === false
|
|
134
|
+
? { OR: [] }
|
|
135
|
+
: toPrismaWhere(policies, resource, scope);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A `fieldsOf` read answer as Prisma's `select`: the Resource's scalar fields a read
|
|
139
|
+
* shows on every row. A field governed for reading is selected only when the answer
|
|
140
|
+
* opens it on every row (one governed for writing only is read as any other); a
|
|
141
|
+
* field it opens on some rows only is left out, since a `select` cannot depend on
|
|
142
|
+
* the row (ask `fieldsOf` for those). Relations are never selected: a related row
|
|
143
|
+
* would not be scoped.
|
|
144
|
+
*/
|
|
145
|
+
export const toPrismaSelect = (
|
|
146
|
+
policies: AccessPolicies,
|
|
147
|
+
resource: string,
|
|
148
|
+
fields: AccessFields,
|
|
149
|
+
): PrismaSelect => {
|
|
150
|
+
const facts = own(policies.schema, resource);
|
|
151
|
+
const governed = governedFor(policies, resource, "read");
|
|
152
|
+
const scalars = (facts?.fields ?? []).filter(
|
|
153
|
+
(field) => own(facts?.relations, field) === undefined,
|
|
154
|
+
);
|
|
155
|
+
const select: PrismaSelect = {};
|
|
156
|
+
for (const field of scalars) {
|
|
157
|
+
const opened = own(fields.perRow, field);
|
|
158
|
+
const everyRow = opened !== undefined && Object.keys(opened).length === 0;
|
|
159
|
+
const always =
|
|
160
|
+
!governed.includes(field) &&
|
|
161
|
+
(fields.always === "all" || fields.always.includes(field));
|
|
162
|
+
if (always || everyRow) {
|
|
163
|
+
select[field] = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return select;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The property of the Prisma client that holds a model's delegate: its name, first letter lowered. */
|
|
170
|
+
export const prismaDelegateOf = (resource: string): string =>
|
|
171
|
+
`${resource.charAt(0).toLowerCase()}${resource.slice(1)}`;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Core's `exists`, through the service's Prisma client: whether a row of `resource`
|
|
175
|
+
* meets `where`, unscoped. It selects the row's first identifier and nothing else.
|
|
176
|
+
*/
|
|
177
|
+
export const existsThroughPrisma = async (
|
|
178
|
+
client: object,
|
|
179
|
+
policies: AccessPolicies,
|
|
180
|
+
resource: string,
|
|
181
|
+
where: AccessWhere,
|
|
182
|
+
): Promise<boolean> => {
|
|
183
|
+
const delegateName = prismaDelegateOf(resource);
|
|
184
|
+
const delegate = (client as { readonly [name: string]: unknown })[
|
|
185
|
+
delegateName
|
|
186
|
+
] as PrismaFindFirst | undefined;
|
|
187
|
+
if (delegate === undefined || typeof delegate.findFirst !== "function") {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`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`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const facts = own(policies.schema, resource);
|
|
193
|
+
const identifier = facts?.identifiers[0] ?? facts?.fields.slice(0, 1) ?? [];
|
|
194
|
+
const select: PrismaSelect = {};
|
|
195
|
+
for (const field of identifier) {
|
|
196
|
+
select[field] = true;
|
|
197
|
+
}
|
|
198
|
+
const found = await delegate.findFirst({
|
|
199
|
+
where: toPrismaWhere(policies, resource, where),
|
|
200
|
+
select,
|
|
201
|
+
});
|
|
202
|
+
return found !== null && found !== undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The Prisma client the ownership beacon is read and written through: a model over
|
|
207
|
+
* the `_aventara_beacon` table, keyed by the namespace,
|
|
208
|
+
* `model AventaraBeacon { id String @id namespace String @unique value String createdAt DateTime @default(now()) @@map("_aventara_beacon") }`.
|
|
209
|
+
*/
|
|
210
|
+
export type PrismaBeaconClient = {
|
|
211
|
+
readonly aventaraBeacon: {
|
|
212
|
+
findUnique(args: {
|
|
213
|
+
where: { namespace: string };
|
|
214
|
+
select: { value: true };
|
|
215
|
+
}): PromiseLike<unknown>;
|
|
216
|
+
create(args: {
|
|
217
|
+
data: { namespace: string; value: string };
|
|
218
|
+
}): PromiseLike<unknown>;
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The beacon store over the service's Prisma client: `read` a namespace's row in
|
|
224
|
+
* `_aventara_beacon`, `create` one. What `avaccess push --beacon` and `pull --beacon`
|
|
225
|
+
* load, and what the client's `checkBeacons` reads through.
|
|
226
|
+
*/
|
|
227
|
+
export const beaconThroughPrisma = (
|
|
228
|
+
client: PrismaBeaconClient,
|
|
229
|
+
): {
|
|
230
|
+
read(namespace: string): Promise<string | null>;
|
|
231
|
+
create(namespace: string, value: string): Promise<void>;
|
|
232
|
+
} => ({
|
|
233
|
+
read: async (namespace) => {
|
|
234
|
+
const row = (await client.aventaraBeacon.findUnique({
|
|
235
|
+
where: { namespace },
|
|
236
|
+
select: { value: true },
|
|
237
|
+
})) as { readonly value?: unknown } | null | undefined;
|
|
238
|
+
return typeof row?.value === "string" ? row.value : null;
|
|
239
|
+
},
|
|
240
|
+
create: async (namespace, value) => {
|
|
241
|
+
await client.aventaraBeacon.create({ data: { namespace, value } });
|
|
242
|
+
},
|
|
243
|
+
});
|