@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,30 @@
|
|
|
1
|
+
/** What a push carries: the file, and the beacons its service read (none for a bare file). */
|
|
2
|
+
export type Pushed = {
|
|
3
|
+
readonly file: unknown;
|
|
4
|
+
readonly beacons: {
|
|
5
|
+
readonly [namespace: string]: string;
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A beacon's hash, as the registry keeps it: of `<namespace>:<value>`, so a value is
|
|
10
|
+
* bound to the namespace it is pushed for. The same as `beaconHashOf` writes.
|
|
11
|
+
*/
|
|
12
|
+
export declare const storedBeaconHash: (namespace: string, beacon: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* A push's body, read: a policies file alone (it has `format`), or the file with the
|
|
15
|
+
* beacons its service read (`{ file, beacons }`). Anything else in the envelope, a
|
|
16
|
+
* namespace name that is not one, or a value that is no beacon is refused.
|
|
17
|
+
*/
|
|
18
|
+
export declare const pushedOf: (body: unknown) => Pushed;
|
|
19
|
+
/**
|
|
20
|
+
* Checks the beacons a push carries against what the registry keeps: each entry
|
|
21
|
+
* names the pushing namespace or one its file reads, every namespace the file reads
|
|
22
|
+
* has pushed a beacon, and the push carries that beacon. `registered` holds the
|
|
23
|
+
* beacon hash each namespace pushed (`null`: none, or no such namespace), at least
|
|
24
|
+
* for `owners`. The registry runs it before it checks who owns what the file reads.
|
|
25
|
+
*/
|
|
26
|
+
export declare const checkPushedBeacons: (namespace: string, owners: readonly string[], beacons: {
|
|
27
|
+
readonly [namespace: string]: string;
|
|
28
|
+
}, registered: ReadonlyMap<string, string | null>) => void;
|
|
29
|
+
/** A namespace's own beacon it pushed before and replaced since: a rollback, refused. */
|
|
30
|
+
export declare const beaconRolledBack: (namespace: string) => never;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The registry's side of the ownership beacon: which database each namespace lives
|
|
2
|
+
// in, as the hash of the beacon its service pushed, and the check that a file
|
|
3
|
+
// reading another namespace's Resources as foreign comes from a service that reaches
|
|
4
|
+
// that namespace's database.
|
|
5
|
+
//
|
|
6
|
+
// A push's body is the namespace's policies file, or `{ "file": …, "beacons": { … } }`:
|
|
7
|
+
// the file, and the beacons the service read through its own connection, by
|
|
8
|
+
// namespace. Its own namespace's entry is its own beacon, which the registry keeps
|
|
9
|
+
// (its hash only) as the namespace's; each other entry is the row of a namespace its
|
|
10
|
+
// file reads as foreign, as this service's connection sees it. A file that reads
|
|
11
|
+
// another namespace's Resources is stored only when, for each such namespace, the
|
|
12
|
+
// push carries that namespace's beacon and it hashes to the one that namespace
|
|
13
|
+
// pushed: a service on another database cannot read it. The registry never stores,
|
|
14
|
+
// logs or answers a beacon; a refusal never quotes one.
|
|
15
|
+
import { isBeacon } from "../beacon/beacon.store.js";
|
|
16
|
+
import { isNamespace, isRecord } from "../policies.compiler.js";
|
|
17
|
+
import { sha256Hex } from "../sha256.digest.js";
|
|
18
|
+
import { AccessRegistryRefusal } from "./registry.refusal.js";
|
|
19
|
+
const ENVELOPE_KEYS = ["file", "beacons"];
|
|
20
|
+
const invalid = (message) => {
|
|
21
|
+
throw new AccessRegistryRefusal("ACCESS_POLICIES_INVALID", message, 400);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A beacon's hash, as the registry keeps it: of `<namespace>:<value>`, so a value is
|
|
25
|
+
* bound to the namespace it is pushed for. The same as `beaconHashOf` writes.
|
|
26
|
+
*/
|
|
27
|
+
export const storedBeaconHash = (namespace, beacon) => `sha256:${sha256Hex(`${namespace}:${beacon}`)}`;
|
|
28
|
+
/**
|
|
29
|
+
* A push's body, read: a policies file alone (it has `format`), or the file with the
|
|
30
|
+
* beacons its service read (`{ file, beacons }`). Anything else in the envelope, a
|
|
31
|
+
* namespace name that is not one, or a value that is no beacon is refused.
|
|
32
|
+
*/
|
|
33
|
+
export const pushedOf = (body) => {
|
|
34
|
+
if (!isRecord(body) || !("file" in body) || "format" in body) {
|
|
35
|
+
return { file: body, beacons: {} };
|
|
36
|
+
}
|
|
37
|
+
const unknown = Object.keys(body).filter((key) => !ENVELOPE_KEYS.includes(key));
|
|
38
|
+
if (unknown.length > 0) {
|
|
39
|
+
invalid(`a push is the policies file, or \`{ file, beacons }\`; it does not take ${unknown.map((key) => `\`${key}\``).join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
const beacons = body.beacons ?? {};
|
|
42
|
+
if (!isRecord(beacons)) {
|
|
43
|
+
invalid("`beacons` maps a namespace to its beacon, as this service's connection reads its row in _aventara_beacon");
|
|
44
|
+
}
|
|
45
|
+
for (const [namespace, beacon] of Object.entries(beacons)) {
|
|
46
|
+
if (!isNamespace(namespace)) {
|
|
47
|
+
invalid(`\`beacons\` names ${JSON.stringify(namespace)}, which is not a namespace name`);
|
|
48
|
+
}
|
|
49
|
+
if (!isBeacon(beacon)) {
|
|
50
|
+
invalid(`\`beacons\` holds no beacon for namespace "${namespace}" (avb_ and 43 base64url characters)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
file: body.file,
|
|
55
|
+
beacons: beacons,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** Why a file reading `owner`'s Resources is refused, beside the fix the refusal names. */
|
|
59
|
+
const crossDatabase = (message) => {
|
|
60
|
+
throw new AccessRegistryRefusal("ACCESS_FOREIGN_CROSS_DATABASE", `${message}. A foreign Resource is read only while both services share one database; across databases, carry the fact in a JWT claim instead (\`policies.claims\`)`, 403);
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Checks the beacons a push carries against what the registry keeps: each entry
|
|
64
|
+
* names the pushing namespace or one its file reads, every namespace the file reads
|
|
65
|
+
* has pushed a beacon, and the push carries that beacon. `registered` holds the
|
|
66
|
+
* beacon hash each namespace pushed (`null`: none, or no such namespace), at least
|
|
67
|
+
* for `owners`. The registry runs it before it checks who owns what the file reads.
|
|
68
|
+
*/
|
|
69
|
+
export const checkPushedBeacons = (namespace, owners, beacons, registered) => {
|
|
70
|
+
for (const named of Object.keys(beacons)) {
|
|
71
|
+
if (named !== namespace && !owners.includes(named)) {
|
|
72
|
+
invalid(`\`beacons\` names namespace "${named}", whose Resources the file does not read: a push carries its own namespace's beacon and those of the namespaces it reads as foreign`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// The same refusal whether the owner exists, has pushed a beacon, or owns what the
|
|
76
|
+
// file reads: a service that cannot show it shares the owner's database learns
|
|
77
|
+
// nothing of the registry's namespaces from it.
|
|
78
|
+
for (const owner of owners) {
|
|
79
|
+
const pushed = registered.get(owner) ?? null;
|
|
80
|
+
const carried = beacons[owner];
|
|
81
|
+
if (carried === undefined) {
|
|
82
|
+
crossDatabase(`the file reads namespace "${owner}"'s Resources as foreign, and the push carries no beacon of "${owner}" as this service's connection reads it: push with \`avaccess push --beacon <module>\``);
|
|
83
|
+
}
|
|
84
|
+
if (pushed === null ||
|
|
85
|
+
storedBeaconHash(owner, carried) !== pushed) {
|
|
86
|
+
crossDatabase(`the file reads namespace "${owner}"'s Resources as foreign, and "${owner}"'s beacon as this service's connection reads it is not one "${owner}" pushed: the two services are on different databases, or "${owner}"'s service has not pushed its file with \`avaccess push --beacon <module>\` yet`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** A namespace's own beacon it pushed before and replaced since: a rollback, refused. */
|
|
91
|
+
export const beaconRolledBack = (namespace) => {
|
|
92
|
+
throw new AccessRegistryRefusal("ACCESS_BEACON_MISMATCH", `the push carries, as namespace "${namespace}"'s own beacon, one it pushed before and has replaced since: a beacon never comes back (an old database's row, or a replayed push). An operator verifies which database "${namespace}" runs on, and deletes the old row there; the next push writes a new one`, 409);
|
|
93
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AccessGuardrails } from "../guardrails/guardrails-contract.js";
|
|
2
|
+
import type { AccessPolicies } from "../policies-contract.js";
|
|
3
|
+
/** Who owns each Resource the registry knows of: Resource name → namespace. */
|
|
4
|
+
type ResourceOwners = ReadonlyMap<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* How a Resource comes to be owned: by the first push that names it as its own
|
|
7
|
+
* (`first-push`, the default), or only by the operator's `assignResource`
|
|
8
|
+
* (`assigned`).
|
|
9
|
+
*/
|
|
10
|
+
export type OwnershipMode = "first-push" | "assigned";
|
|
11
|
+
/** The Resources a file names as its own: its schema entries not marked foreign. */
|
|
12
|
+
export declare const ownResources: (file: AccessPolicies) => readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Every Resource a file names, read defensively: its schema's, and those its grants,
|
|
15
|
+
* denies and governed fields name. The registry looks up the owners of these only.
|
|
16
|
+
*/
|
|
17
|
+
export declare const resourcesNamed: (file: unknown) => readonly string[];
|
|
18
|
+
/** The namespaces a file's foreign markers name, read defensively. */
|
|
19
|
+
export declare const foreignNamespaces: (file: unknown) => readonly string[];
|
|
20
|
+
/**
|
|
21
|
+
* Checks a file pushed to `namespace`: it is that namespace's file, it claims no
|
|
22
|
+
* Resource another namespace owns (and, with `assigned` ownership, only Resources
|
|
23
|
+
* the operator assigned to it), and its grants, denies and governed fields name only
|
|
24
|
+
* its own Resources. Throws `ACCESS_OWNERSHIP` naming the rule; the owner only when
|
|
25
|
+
* the file itself names it. `owners` holds at least every Resource the file names
|
|
26
|
+
* (`resourcesNamed`). Its foreign markers are checked apart
|
|
27
|
+
* ({@link checkForeignMarkers}), once the push's beacons have shown it shares each
|
|
28
|
+
* owner's database.
|
|
29
|
+
*/
|
|
30
|
+
export declare const checkNamespaceOwnership: (namespace: string, file: unknown, owners: ResourceOwners, ownership?: OwnershipMode) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Checks a file's foreign markers: each names a namespace the registry has, and the
|
|
33
|
+
* one that owns the Resource when it has an owner. Throws `ACCESS_OWNERSHIP`. The
|
|
34
|
+
* registry asks it only after the push's beacons showed the service shares each
|
|
35
|
+
* marked namespace's database (`checkPushedBeacons`): before that, its answer would
|
|
36
|
+
* tell any service which namespaces exist and who owns what. `owners` holds at least
|
|
37
|
+
* every Resource the file names, and `namespaces` at least every namespace its
|
|
38
|
+
* markers name that the registry has.
|
|
39
|
+
*/
|
|
40
|
+
export declare const checkForeignMarkers: (namespace: string, file: unknown, owners: ResourceOwners, namespaces: ReadonlySet<string>) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Checks the org's guardrails file grants nothing: it holds no `grants` and no
|
|
43
|
+
* guardrail with `subjects`. Throws `ACCESS_OWNERSHIP` naming what grants. (The
|
|
44
|
+
* format's own checks, which run after this, refuse every other member it does not
|
|
45
|
+
* define.)
|
|
46
|
+
*/
|
|
47
|
+
export declare const checkGuardrailsOwnership: (file: unknown) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Checks every namespace a guardrail names a Resource list for is one the registry
|
|
50
|
+
* has: a guardrail for a misspelled namespace would narrow nothing. Throws
|
|
51
|
+
* `ACCESS_NAMESPACE` naming the guardrail and the namespace.
|
|
52
|
+
*/
|
|
53
|
+
export declare const checkGuardrailNamespaces: (file: AccessGuardrails, namespaces: ReadonlySet<string>) => void;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Ownership at push. The data owner writes the grants: a namespace's file names as
|
|
2
|
+
// its own only Resources no other namespace owns, and its grants, denies and
|
|
3
|
+
// governed fields name only those. A Resource another namespace owns appears in the
|
|
4
|
+
// file as `{ "foreign": "<owner>" }` and is read in conditions only. The org's
|
|
5
|
+
// guardrails file (`guardrails@1`) holds no grant: central Access may only narrow.
|
|
6
|
+
//
|
|
7
|
+
// The builder's types and the compiler already refuse most of this for a file
|
|
8
|
+
// written with `definePolicies`; a hand-written file gets past both, so the registry
|
|
9
|
+
// checks every pushed file again, against what the namespaces' current files claim.
|
|
10
|
+
// The checks read the file defensively: anything malformed is left to the compiler's
|
|
11
|
+
// checks, which run after these.
|
|
12
|
+
import { AccessError } from "../access/access.error.js";
|
|
13
|
+
import { GUARDRAILS_FORMAT, } from "../guardrails/guardrails-contract.js";
|
|
14
|
+
import { isRecord } from "../policies.compiler.js";
|
|
15
|
+
const refuse = (message) => {
|
|
16
|
+
throw new AccessError("ACCESS_OWNERSHIP", message);
|
|
17
|
+
};
|
|
18
|
+
const listOf = (value) => Array.isArray(value) ? value : [];
|
|
19
|
+
const ruleId = (rule) => typeof rule.id === "string" ? `"${rule.id}"` : "(no id)";
|
|
20
|
+
/** The Resources a file names as its own: its schema entries not marked foreign. */
|
|
21
|
+
export const ownResources = (file) => Object.entries(file.schema)
|
|
22
|
+
.filter(([, facts]) => facts.foreign === undefined)
|
|
23
|
+
.map(([resource]) => resource);
|
|
24
|
+
/** The Resource a field reference (`Resource.field`) is on, or `""`. */
|
|
25
|
+
const resourceOfField = (field) => typeof field === "string" && field.includes(".")
|
|
26
|
+
? field.slice(0, field.indexOf("."))
|
|
27
|
+
: "";
|
|
28
|
+
/**
|
|
29
|
+
* Every Resource a file names, read defensively: its schema's, and those its grants,
|
|
30
|
+
* denies and governed fields name. The registry looks up the owners of these only.
|
|
31
|
+
*/
|
|
32
|
+
export const resourcesNamed = (file) => {
|
|
33
|
+
if (!isRecord(file)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const named = new Set(Object.keys(isRecord(file.schema) ? file.schema : {}));
|
|
37
|
+
for (const rule of [...listOf(file.grants), ...listOf(file.denies)]) {
|
|
38
|
+
if (!isRecord(rule)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (typeof rule.resource === "string" && rule.resource !== "*") {
|
|
42
|
+
named.add(rule.resource);
|
|
43
|
+
}
|
|
44
|
+
for (const field of listOf(rule.fields)) {
|
|
45
|
+
const resource = resourceOfField(field);
|
|
46
|
+
if (resource !== "") {
|
|
47
|
+
named.add(resource);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const resource of Object.keys(isRecord(file.govern) ? file.govern : {})) {
|
|
52
|
+
named.add(resource);
|
|
53
|
+
}
|
|
54
|
+
return [...named];
|
|
55
|
+
};
|
|
56
|
+
/** The namespaces a file's foreign markers name, read defensively. */
|
|
57
|
+
export const foreignNamespaces = (file) => {
|
|
58
|
+
const schema = isRecord(file) && isRecord(file.schema) ? file.schema : {};
|
|
59
|
+
const named = new Set();
|
|
60
|
+
for (const facts of Object.values(schema)) {
|
|
61
|
+
const foreign = isRecord(facts) ? facts.foreign : undefined;
|
|
62
|
+
if (typeof foreign === "string") {
|
|
63
|
+
named.add(foreign);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...named];
|
|
67
|
+
};
|
|
68
|
+
const owns = (owner) => owner.named === undefined
|
|
69
|
+
? "another namespace owns"
|
|
70
|
+
: `namespace "${owner.named}" owns`;
|
|
71
|
+
const askOwner = (owner) => owner.named === undefined ? "its owner" : `"${owner.named}"`;
|
|
72
|
+
/**
|
|
73
|
+
* Checks a file pushed to `namespace`: it is that namespace's file, it claims no
|
|
74
|
+
* Resource another namespace owns (and, with `assigned` ownership, only Resources
|
|
75
|
+
* the operator assigned to it), and its grants, denies and governed fields name only
|
|
76
|
+
* its own Resources. Throws `ACCESS_OWNERSHIP` naming the rule; the owner only when
|
|
77
|
+
* the file itself names it. `owners` holds at least every Resource the file names
|
|
78
|
+
* (`resourcesNamed`). Its foreign markers are checked apart
|
|
79
|
+
* ({@link checkForeignMarkers}), once the push's beacons have shown it shares each
|
|
80
|
+
* owner's database.
|
|
81
|
+
*/
|
|
82
|
+
export const checkNamespaceOwnership = (namespace, file, owners, ownership = "first-push") => {
|
|
83
|
+
if (!isRecord(file)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (file.namespace !== namespace) {
|
|
87
|
+
refuse(file.namespace === undefined
|
|
88
|
+
? `the file pushed to namespace "${namespace}" names no namespace. Compile it with definePolicies(model, { namespace: "${namespace}" })`
|
|
89
|
+
: `the file pushed to namespace "${namespace}" is namespace ${JSON.stringify(file.namespace)}'s. A namespace's key pushes only its own file`);
|
|
90
|
+
}
|
|
91
|
+
const schema = isRecord(file.schema) ? file.schema : {};
|
|
92
|
+
/** The namespace that owns `resource`, when it is not this one; `undefined` when it is. */
|
|
93
|
+
const ownerOf = (resource) => {
|
|
94
|
+
const facts = schema[resource];
|
|
95
|
+
const foreign = isRecord(facts) ? facts.foreign : undefined;
|
|
96
|
+
if (typeof foreign === "string") {
|
|
97
|
+
return { named: foreign };
|
|
98
|
+
}
|
|
99
|
+
const owner = owners.get(resource);
|
|
100
|
+
return owner === undefined || owner === namespace
|
|
101
|
+
? undefined
|
|
102
|
+
: { named: undefined };
|
|
103
|
+
};
|
|
104
|
+
for (const [resource, facts] of Object.entries(schema)) {
|
|
105
|
+
const foreign = isRecord(facts) ? facts.foreign : undefined;
|
|
106
|
+
const owner = owners.get(resource);
|
|
107
|
+
if (foreign === undefined) {
|
|
108
|
+
if (owner !== undefined && owner !== namespace) {
|
|
109
|
+
refuse(`namespace "${namespace}" names ${resource} as its own, and another namespace owns it. Mark it { "foreign": "<its owner>" } to read it in conditions; the registry's operator can tell you which namespace owns it`);
|
|
110
|
+
}
|
|
111
|
+
if (owner === undefined && ownership === "assigned") {
|
|
112
|
+
refuse(`namespace "${namespace}" names ${resource} as its own, and the operator has not assigned ${resource} to it. This registry takes ownership from its operator (\`ownership: "assigned"\`): ask them to run access.registry.assignResource("${resource}", "${namespace}")`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const grant of listOf(file.grants)) {
|
|
117
|
+
if (!isRecord(grant)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const resource = grant.resource;
|
|
121
|
+
const owner = typeof resource === "string" && resource !== "*"
|
|
122
|
+
? ownerOf(resource)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (owner !== undefined) {
|
|
125
|
+
refuse(`grant ${ruleId(grant)} in namespace "${namespace}" names ${String(resource)}, which ${owns(owner)}. A namespace grants only on its own Resources. Ask ${askOwner(owner)} to add the grant, or add a deny guardrail on the Access server`);
|
|
126
|
+
}
|
|
127
|
+
for (const field of listOf(grant.fields)) {
|
|
128
|
+
const fieldResource = resourceOfField(field);
|
|
129
|
+
const fieldOwner = fieldResource === "" ? undefined : ownerOf(fieldResource);
|
|
130
|
+
if (fieldOwner !== undefined) {
|
|
131
|
+
refuse(`grant ${ruleId(grant)} in namespace "${namespace}" opens ${String(field)}, a field of ${fieldResource}, which ${owns(fieldOwner)}. A namespace grants only on its own Resources`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const deny of listOf(file.denies)) {
|
|
136
|
+
if (!isRecord(deny)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const resource = deny.resource;
|
|
140
|
+
const owner = typeof resource === "string" && resource !== "*"
|
|
141
|
+
? ownerOf(resource)
|
|
142
|
+
: undefined;
|
|
143
|
+
if (owner !== undefined) {
|
|
144
|
+
refuse(`deny ${ruleId(deny)} in namespace "${namespace}" names ${String(resource)}, which ${owns(owner)}. A namespace's denies narrow only its own Resources; narrowing another namespace's is an org guardrail`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const resource of Object.keys(isRecord(file.govern) ? file.govern : {})) {
|
|
148
|
+
const owner = ownerOf(resource);
|
|
149
|
+
if (owner !== undefined) {
|
|
150
|
+
refuse(`namespace "${namespace}" governs fields of ${resource}, which ${owns(owner)}. Only the owner governs a Resource's fields`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Checks a file's foreign markers: each names a namespace the registry has, and the
|
|
156
|
+
* one that owns the Resource when it has an owner. Throws `ACCESS_OWNERSHIP`. The
|
|
157
|
+
* registry asks it only after the push's beacons showed the service shares each
|
|
158
|
+
* marked namespace's database (`checkPushedBeacons`): before that, its answer would
|
|
159
|
+
* tell any service which namespaces exist and who owns what. `owners` holds at least
|
|
160
|
+
* every Resource the file names, and `namespaces` at least every namespace its
|
|
161
|
+
* markers name that the registry has.
|
|
162
|
+
*/
|
|
163
|
+
export const checkForeignMarkers = (namespace, file, owners, namespaces) => {
|
|
164
|
+
const schema = isRecord(file) && isRecord(file.schema) ? file.schema : {};
|
|
165
|
+
for (const [resource, facts] of Object.entries(schema)) {
|
|
166
|
+
const foreign = isRecord(facts) ? facts.foreign : undefined;
|
|
167
|
+
if (typeof foreign !== "string") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!namespaces.has(foreign)) {
|
|
171
|
+
refuse(`namespace "${namespace}" marks ${resource} foreign to "${foreign}", which is not a namespace of this registry`);
|
|
172
|
+
}
|
|
173
|
+
const owner = owners.get(resource);
|
|
174
|
+
if (owner !== undefined && owner !== foreign) {
|
|
175
|
+
refuse(`namespace "${namespace}" marks ${resource} foreign to "${foreign}", which does not own it`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Checks the org's guardrails file grants nothing: it holds no `grants` and no
|
|
181
|
+
* guardrail with `subjects`. Throws `ACCESS_OWNERSHIP` naming what grants. (The
|
|
182
|
+
* format's own checks, which run after this, refuse every other member it does not
|
|
183
|
+
* define.)
|
|
184
|
+
*/
|
|
185
|
+
export const checkGuardrailsOwnership = (file) => {
|
|
186
|
+
// A file of another format is refused by the format's checks, by its format.
|
|
187
|
+
if (!isRecord(file) || file.format !== GUARDRAILS_FORMAT) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (file.grants !== undefined) {
|
|
191
|
+
refuse(`the org's guardrails file holds "grants". Guardrails only deny; a grant belongs in the file of the namespace that owns its Resource`);
|
|
192
|
+
}
|
|
193
|
+
for (const guardrail of listOf(file.denies)) {
|
|
194
|
+
if (isRecord(guardrail) && guardrail.subjects !== undefined) {
|
|
195
|
+
refuse(`guardrail ${ruleId(guardrail)} in the org's guardrails file names subjects, as a grant does. Guardrails only deny; a grant belongs in the file of the namespace that owns its Resource`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Checks every namespace a guardrail names a Resource list for is one the registry
|
|
201
|
+
* has: a guardrail for a misspelled namespace would narrow nothing. Throws
|
|
202
|
+
* `ACCESS_NAMESPACE` naming the guardrail and the namespace.
|
|
203
|
+
*/
|
|
204
|
+
export const checkGuardrailNamespaces = (file, namespaces) => {
|
|
205
|
+
for (const guardrail of file.denies) {
|
|
206
|
+
if (guardrail.resources === "*") {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const namespace of Object.keys(guardrail.resources)) {
|
|
210
|
+
if (!namespaces.has(namespace)) {
|
|
211
|
+
throw new AccessError("ACCESS_NAMESPACE", `guardrail "${guardrail.id}" names Resources of namespace "${namespace}", which this registry does not have; a guardrail on a namespace that does not exist would narrow nothing. createNamespace adds it, or correct the guardrail`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Registry } from "./access.registry.js";
|
|
2
|
+
/** Where every registry route is, under Better Auth's `basePath`: no other plugin's route may be. */
|
|
3
|
+
export declare const REGISTRY_ROUTES = "/access/v1";
|
|
4
|
+
/** The registry routes' prefix, under Better Auth's `basePath`. */
|
|
5
|
+
export declare const REGISTRY_PATH = "/access/v1/namespaces";
|
|
6
|
+
/** The route that names the namespace a key belongs to, under Better Auth's `basePath`. */
|
|
7
|
+
export declare const KEY_OWNER_PATH = "/access/v1/key";
|
|
8
|
+
/** Every registry route's path, under Better Auth's `basePath`, as its endpoint declares it. */
|
|
9
|
+
export declare const REGISTRY_ROUTE_PATHS: readonly ["/access/v1/namespaces/:ns", "/access/v1/namespaces/:ns/revisions", "/access/v1/namespaces/:ns/revisions/:hash", "/access/v1/namespaces/:ns/head", "/access/v1/namespaces/:ns/guardrails", "/access/v1/namespaces/:ns/foreign/:owner", "/access/v1/key"];
|
|
10
|
+
/** The registry's endpoints by name, as Better Auth keys them (`auth.api.<name>`): no other plugin's endpoint may take one. */
|
|
11
|
+
export declare const REGISTRY_ENDPOINT_NAMES: readonly ["accessNamespace", "accessRevisions", "accessRevision", "accessHead", "accessGuardrails", "accessForeign", "accessKeyOwner", "accessPush"];
|
|
12
|
+
/** The request header that carries a registry key. */
|
|
13
|
+
export declare const KEY_HEADER = "x-access-key";
|
|
14
|
+
/** What a registry endpoint reads of Better Auth's endpoint context. */
|
|
15
|
+
type EndpointContext = {
|
|
16
|
+
readonly params: {
|
|
17
|
+
readonly [name: string]: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
readonly query?: {
|
|
20
|
+
readonly [name: string]: string | readonly string[] | undefined;
|
|
21
|
+
} | undefined;
|
|
22
|
+
readonly headers?: Headers | undefined;
|
|
23
|
+
readonly body?: unknown;
|
|
24
|
+
};
|
|
25
|
+
/** Better Auth's `createAuthEndpoint`, as the registry calls it. */
|
|
26
|
+
export type EndpointFactory = (path: string, options: {
|
|
27
|
+
readonly method: "GET" | "POST";
|
|
28
|
+
}, handler: (context: EndpointContext) => Promise<Response>) => unknown;
|
|
29
|
+
/**
|
|
30
|
+
* The registry's `onRequest`, which Better Auth's router runs on every request
|
|
31
|
+
* before a route reads its body: a push (`POST …/namespaces/:ns/revisions`) whose
|
|
32
|
+
* declared length is over {@link MAX_PUSH_BODY_BYTES} is answered `413`, and one
|
|
33
|
+
* whose key is not the namespace's push key is answered the refusal the route would
|
|
34
|
+
* give, both before the body is read, on any mount. Anything else goes on.
|
|
35
|
+
*/
|
|
36
|
+
export declare const registryOnRequest: (registry: Registry) => (request: Request, context: unknown) => Promise<{
|
|
37
|
+
readonly response: Response;
|
|
38
|
+
} | undefined>;
|
|
39
|
+
/** The registry's endpoints, keyed as Better Auth keys a plugin's (`access.api.<key>`). */
|
|
40
|
+
export declare const registryEndpoints: (registry: Registry, endpoint: EndpointFactory) => {
|
|
41
|
+
accessNamespace: unknown;
|
|
42
|
+
accessRevisions: unknown;
|
|
43
|
+
accessRevision: unknown;
|
|
44
|
+
accessHead: unknown;
|
|
45
|
+
accessGuardrails: unknown;
|
|
46
|
+
accessForeign: unknown;
|
|
47
|
+
accessKeyOwner: unknown;
|
|
48
|
+
accessPush: unknown;
|
|
49
|
+
};
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The registry's HTTP routes, as Better Auth plugin endpoints: Better Auth's router
|
|
2
|
+
// serves them under its `basePath` beside its own routes, so `access.handler` carries
|
|
3
|
+
// them and every mount of it (a Next.js route file, a Node.js handler, `serve`) serves
|
|
4
|
+
// them with no wiring of its own. Each one is thin: it reads the key header, the path
|
|
5
|
+
// and the body, asks the registry, and writes the answer or the refusal as JSON.
|
|
6
|
+
//
|
|
7
|
+
// GET /access/v1/namespaces/:ns the namespace and its head's hash
|
|
8
|
+
// GET /access/v1/namespaces/:ns/revisions its revisions, newest first
|
|
9
|
+
// GET /access/v1/namespaces/:ns/revisions/:hash one revision, with its file
|
|
10
|
+
// GET /access/v1/namespaces/:ns/head the current revision, with its file
|
|
11
|
+
// GET /access/v1/namespaces/:ns/guardrails the org's current guardrails file
|
|
12
|
+
// GET /access/v1/namespaces/:ns/foreign/:owner what :ns reads of :owner as foreign:
|
|
13
|
+
// its beacon's hash, and its entry of
|
|
14
|
+
// each Resource `?resources=A,B` names
|
|
15
|
+
// POST /access/v1/namespaces/:ns/revisions push: the body is the namespace's file
|
|
16
|
+
// GET /access/v1/key the namespace the key belongs to, and its kind
|
|
17
|
+
//
|
|
18
|
+
// The key travels in the `x-access-key` header, as Better Auth's API keys travel in
|
|
19
|
+
// `x-api-key`: a pull or push key for the reads, the push key for a push. Revisions
|
|
20
|
+
// are never updated, so no route writes to one.
|
|
21
|
+
import { AccessError } from "../access/access.error.js";
|
|
22
|
+
import { AccessRegistryRefusal, MAX_POLICIES_BYTES, } from "./access.registry.js";
|
|
23
|
+
/** Where every registry route is, under Better Auth's `basePath`: no other plugin's route may be. */
|
|
24
|
+
export const REGISTRY_ROUTES = "/access/v1";
|
|
25
|
+
/** The registry routes' prefix, under Better Auth's `basePath`. */
|
|
26
|
+
export const REGISTRY_PATH = `${REGISTRY_ROUTES}/namespaces`;
|
|
27
|
+
/** The route that names the namespace a key belongs to, under Better Auth's `basePath`. */
|
|
28
|
+
export const KEY_OWNER_PATH = `${REGISTRY_ROUTES}/key`;
|
|
29
|
+
/** Every registry route's path, under Better Auth's `basePath`, as its endpoint declares it. */
|
|
30
|
+
export const REGISTRY_ROUTE_PATHS = [
|
|
31
|
+
`${REGISTRY_PATH}/:ns`,
|
|
32
|
+
`${REGISTRY_PATH}/:ns/revisions`,
|
|
33
|
+
`${REGISTRY_PATH}/:ns/revisions/:hash`,
|
|
34
|
+
`${REGISTRY_PATH}/:ns/head`,
|
|
35
|
+
`${REGISTRY_PATH}/:ns/guardrails`,
|
|
36
|
+
`${REGISTRY_PATH}/:ns/foreign/:owner`,
|
|
37
|
+
KEY_OWNER_PATH,
|
|
38
|
+
];
|
|
39
|
+
/** The registry's endpoints by name, as Better Auth keys them (`auth.api.<name>`): no other plugin's endpoint may take one. */
|
|
40
|
+
export const REGISTRY_ENDPOINT_NAMES = [
|
|
41
|
+
"accessNamespace",
|
|
42
|
+
"accessRevisions",
|
|
43
|
+
"accessRevision",
|
|
44
|
+
"accessHead",
|
|
45
|
+
"accessGuardrails",
|
|
46
|
+
"accessForeign",
|
|
47
|
+
"accessKeyOwner",
|
|
48
|
+
"accessPush",
|
|
49
|
+
];
|
|
50
|
+
/** The request header that carries a registry key. */
|
|
51
|
+
export const KEY_HEADER = "x-access-key";
|
|
52
|
+
const json = (status, body) => new Response(JSON.stringify(body), {
|
|
53
|
+
status,
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
"cache-control": "no-store",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const STATUS_OF = {
|
|
60
|
+
ACCESS_OWNERSHIP: 403,
|
|
61
|
+
ACCESS_POLICIES_INVALID: 400,
|
|
62
|
+
ACCESS_NAMESPACE: 404,
|
|
63
|
+
};
|
|
64
|
+
/** A refusal as JSON: `{ code, message }`, as Better Auth answers its own. */
|
|
65
|
+
const refusal = (error) => {
|
|
66
|
+
if (error instanceof AccessError) {
|
|
67
|
+
const status = error instanceof AccessRegistryRefusal
|
|
68
|
+
? error.status
|
|
69
|
+
: (STATUS_OF[error.code] ?? 400);
|
|
70
|
+
return json(status, { code: error.code, message: error.message });
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
};
|
|
74
|
+
/** A path segment as sent: Better Auth's router hands it over still percent-encoded. */
|
|
75
|
+
const decoded = (segment) => {
|
|
76
|
+
try {
|
|
77
|
+
return decodeURIComponent(segment);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return segment;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const notFound = (message) => json(404, { code: "NOT_FOUND", message: `Aventara Access: ${message}` });
|
|
84
|
+
/**
|
|
85
|
+
* The most a push's body may declare before it is read: twice the largest file the
|
|
86
|
+
* registry stores, which leaves room for the beacons beside it and for formatting.
|
|
87
|
+
* A longer declared length is refused unread; the registry still holds the file to
|
|
88
|
+
* its own cap once it is read.
|
|
89
|
+
*/
|
|
90
|
+
const MAX_PUSH_BODY_BYTES = 2 * MAX_POLICIES_BYTES;
|
|
91
|
+
/** A push's route under the base path, the namespace its one segment. */
|
|
92
|
+
const PUSH_ROUTE = new RegExp(`^${REGISTRY_PATH}/([^/]+)/revisions/?$`, "i");
|
|
93
|
+
/** The path Better Auth's router strips before it routes: its base URL's path. */
|
|
94
|
+
const basePathOf = (context) => {
|
|
95
|
+
const { baseURL, options } = (context ?? {});
|
|
96
|
+
try {
|
|
97
|
+
return new URL(String(baseURL)).pathname.replace(/\/+$/, "");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
const basePath = options?.basePath;
|
|
101
|
+
return (typeof basePath === "string" ? basePath : "/api/auth").replace(/\/+$/, "");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The registry's `onRequest`, which Better Auth's router runs on every request
|
|
106
|
+
* before a route reads its body: a push (`POST …/namespaces/:ns/revisions`) whose
|
|
107
|
+
* declared length is over {@link MAX_PUSH_BODY_BYTES} is answered `413`, and one
|
|
108
|
+
* whose key is not the namespace's push key is answered the refusal the route would
|
|
109
|
+
* give, both before the body is read, on any mount. Anything else goes on.
|
|
110
|
+
*/
|
|
111
|
+
export const registryOnRequest = (registry) => async (request, context) => {
|
|
112
|
+
if (request.method !== "POST") {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
let pathname;
|
|
116
|
+
try {
|
|
117
|
+
pathname = new URL(request.url).pathname;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const base = basePathOf(context);
|
|
123
|
+
if (base !== "" && !pathname.startsWith(`${base}/`)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const namespace = PUSH_ROUTE.exec(pathname.slice(base.length))?.[1];
|
|
127
|
+
if (namespace === undefined) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const declared = Number(request.headers.get("content-length"));
|
|
131
|
+
if (Number.isFinite(declared) && declared > MAX_PUSH_BODY_BYTES) {
|
|
132
|
+
return {
|
|
133
|
+
response: json(413, {
|
|
134
|
+
code: "ACCESS_POLICIES_INVALID",
|
|
135
|
+
message: `Aventara Access: the push's body is ${declared} bytes; the registry reads a push of at most ${MAX_PUSH_BODY_BYTES} bytes, and stores a policies file of at most ${MAX_POLICIES_BYTES} (1 MiB)`,
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await registry.admitPush(namespace, request.headers.get(KEY_HEADER) ?? undefined);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return { response: refusal(error) };
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
};
|
|
147
|
+
/** The registry's endpoints, keyed as Better Auth keys a plugin's (`access.api.<key>`). */
|
|
148
|
+
export const registryEndpoints = (registry, endpoint) => {
|
|
149
|
+
const route = (answer) => async (context) => {
|
|
150
|
+
try {
|
|
151
|
+
return await answer(context, context.headers?.get(KEY_HEADER) ?? undefined, context.params.ns ?? "");
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return refusal(error);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const endpoints = {
|
|
158
|
+
accessNamespace: endpoint(REGISTRY_ROUTE_PATHS[0], { method: "GET" }, route(async (_, key, namespace) => json(200, await registry.namespace(namespace, key)))),
|
|
159
|
+
accessRevisions: endpoint(REGISTRY_ROUTE_PATHS[1], { method: "GET" }, route(async (_, key, namespace) => json(200, { revisions: await registry.revisions(namespace, key) }))),
|
|
160
|
+
accessRevision: endpoint(REGISTRY_ROUTE_PATHS[2], { method: "GET" }, route(async (context, key, namespace) => {
|
|
161
|
+
const hash = decoded(context.params.hash ?? "");
|
|
162
|
+
const revision = await registry.revision(namespace, key, hash);
|
|
163
|
+
return revision === null
|
|
164
|
+
? notFound(`namespace "${namespace}" has no revision ${JSON.stringify(hash)}`)
|
|
165
|
+
: json(200, revision);
|
|
166
|
+
})),
|
|
167
|
+
accessHead: endpoint(REGISTRY_ROUTE_PATHS[3], { method: "GET" }, route(async (_, key, namespace) => {
|
|
168
|
+
const head = await registry.head(namespace, key);
|
|
169
|
+
return head === null
|
|
170
|
+
? notFound(`namespace "${namespace}" has no file pushed yet`)
|
|
171
|
+
: json(200, head);
|
|
172
|
+
})),
|
|
173
|
+
accessGuardrails: endpoint(REGISTRY_ROUTE_PATHS[4], { method: "GET" }, route(async (_, key, namespace) => {
|
|
174
|
+
const guardrails = await registry.guardrails(namespace, key);
|
|
175
|
+
return guardrails === null
|
|
176
|
+
? notFound("the org has no guardrails file pushed yet")
|
|
177
|
+
: json(200, guardrails);
|
|
178
|
+
})),
|
|
179
|
+
accessForeign: endpoint(REGISTRY_ROUTE_PATHS[5], { method: "GET" }, route(async (context, key, namespace) => {
|
|
180
|
+
const asked = context.query?.resources;
|
|
181
|
+
const resources = (asked === undefined ? [] : typeof asked === "string" ? [asked] : asked)
|
|
182
|
+
.flatMap((list) => String(list).split(","))
|
|
183
|
+
.filter((name) => name !== "");
|
|
184
|
+
return json(200, await registry.foreign(namespace, key, decoded(context.params.owner ?? ""), resources));
|
|
185
|
+
})),
|
|
186
|
+
accessKeyOwner: endpoint(REGISTRY_ROUTE_PATHS[6], { method: "GET" }, route(async (_, key) => json(200, await registry.keyOwner(key)))),
|
|
187
|
+
accessPush: endpoint(REGISTRY_ROUTE_PATHS[1], { method: "POST" }, route(async (context, key, namespace) => {
|
|
188
|
+
const pushed = await registry.push(namespace, key, context.body);
|
|
189
|
+
return json(pushed.created ? 201 : 200, { namespace, ...pushed });
|
|
190
|
+
})),
|
|
191
|
+
};
|
|
192
|
+
return endpoints;
|
|
193
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** What a registry key may do: `push` pushes the namespace's file and reads; `pull` only reads. */
|
|
2
|
+
export type AccessKeyKind = "push" | "pull";
|
|
3
|
+
export declare const KEY_KINDS: readonly AccessKeyKind[];
|
|
4
|
+
/** A new key of `kind`: 256 random bits from the runtime's CSPRNG. */
|
|
5
|
+
export declare const newKey: (kind: AccessKeyKind) => string;
|
|
6
|
+
/** The hash the registry stores and looks a key up by; `undefined` for text that is no key. */
|
|
7
|
+
export declare const keyHashOf: (key: string) => string | undefined;
|