@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
# @aventara/access
|
|
2
|
+
|
|
3
|
+
## 1.0.0-rc.1
|
|
4
|
+
|
|
5
|
+
The first release candidate of 1.0, released beside `@aventara/core` `1.0.0-rc.11`: Access moves to 1.0 with the
|
|
6
|
+
framework. It is tested with `@aventara/core` `1.0.0-rc.11`, and still works with `1.0.0-rc.10` or
|
|
7
|
+
later.
|
|
8
|
+
|
|
9
|
+
- An engine whose beacons were not checked refuses with a message that names the call an app makes,
|
|
10
|
+
`await access.policy.checkBeacons(beacon, beaconPins, environment)` (`engine.checkBeacons` on an engine of one's own),
|
|
11
|
+
where it said `engine.checkBeacons(reader, pins, environment)`. The `code` is unchanged.
|
|
12
|
+
- `ACCESS_REFERENCE_UNREADABLE` names `resources: { users: true }` when the target is Better Auth's `user` table and
|
|
13
|
+
`createAccess` (or the plugin) keeps it closed, since no read grant on it is taken until the key opens it. The same
|
|
14
|
+
holds for the other tables a `resources` key opens.
|
|
15
|
+
- `ACCESS_REFERENCE_UNREADABLE` also warns when the only read grant that would reach the target is a `*` grant and the
|
|
16
|
+
target is one of Better Auth's tables that `createAccess` (or the plugin) keeps closed: the built-in deny refuses
|
|
17
|
+
every action on such a table, a `*` read included, so every write of a reference to it is refused. The warning
|
|
18
|
+
names the `resources` key that opens the table. It was silent before. Nothing the engine decides changes.
|
|
19
|
+
- `govern` takes a mode per field beside the list form: `.govern("Member", { role: "write" })` keeps the field readable
|
|
20
|
+
and lets only a grant that names it write it; `{ nationalId: "read" }` keeps it writable and lets only a grant that
|
|
21
|
+
names it read it; `"both"`, and the list form as before, govern each. A field governed twice with two modes is a type
|
|
22
|
+
error and refused when the rules compile. The policies file gains the optional member `governModes` (the `"read"`
|
|
23
|
+
and `"write"` fields; a file without it reads as before, and the list form compiles to the same file and hash). The
|
|
24
|
+
engine's `governed` lists the fields governed for reading only, typed as a list (`readonly "email"[]`, no longer
|
|
25
|
+
the tuple the rules wrote), and `avaccess diff` names a field whose mode changed. When `resources` opens Better Auth's tables, Access governs `User.role` and the organization `member`
|
|
26
|
+
table's `role` as `"write"`: a create or update grant sets one only when it names it (govern it in the rules too, so
|
|
27
|
+
a grant can name it). An update grant on `User` that named no field set the role before; it sets nothing now.
|
|
28
|
+
- The request state's `me` is a frozen copy of the plain data `principal` returns (plain objects and arrays), held
|
|
29
|
+
as a read-only member of the state: no code that runs during a request can replace the principal
|
|
30
|
+
or change it, an access token's scopes included. The app's own objects are never frozen or marked; one object
|
|
31
|
+
`principal` returns for two users is still refused (`ACCESS_OPTIONS_INVALID`), now over an access token too. Only
|
|
32
|
+
the returned object is checked. A class instance, `Map`, `Set`, `Date`, typed array or getter inside the principal is
|
|
33
|
+
not frozen, and a top-level one is `me` as returned. Under an OAuth access token a `Map` or `Set` principal is not
|
|
34
|
+
supported, and a class instance loses its `#private` fields.
|
|
35
|
+
- A field governed as `"read"` that is one of its Resource's identifiers warns `ACCESS_READ_GOVERNED_UNIQUE` when the
|
|
36
|
+
rules compile and when the engine is built: a write that sends a value another row holds fails on the duplicate,
|
|
37
|
+
which tells the writer the value exists. An empty `governModes` is refused.
|
|
38
|
+
- The README links the framework's documentation at `https://aventara.razor34.dev/framework`.
|
|
39
|
+
- The package ships an agent skill in `skill/` (`SKILL.md` and `reference/*.md`): setup and wiring, the rule
|
|
40
|
+
language, recipes and deny patterns, signed-out callers and cached reads, and the codes an agent meets. Copy it into
|
|
41
|
+
your agent's skills folder or point the agent at it; the README's "AI agents" section says how. Its TypeScript
|
|
42
|
+
examples compile in the packed-install gate. No new export and no new command.
|
|
43
|
+
- Proven against the framework's Operation Sets (`POST {api}/_all`) and cached reads, with nothing in Access changed
|
|
44
|
+
for either. Each entry of a set is decided as its single route would be, under one principal derived once for the
|
|
45
|
+
whole request; a refused entry changes nothing for the others. A cached read `for: "anonymous"` is `public` only
|
|
46
|
+
when Access finds no one in the request (its principal is `null`): a caller with a valid session cookie or bearer
|
|
47
|
+
token gets a `private` answer, and a token that verifies to no one gets the signed-out answer.
|
|
48
|
+
|
|
49
|
+
## 0.1.0-rc.0
|
|
50
|
+
|
|
51
|
+
The first release candidate: the policy engine, Better Auth with Access beside it (`@aventara/access/auth`), the
|
|
52
|
+
policy registry and the org's guardrails, the audit log, the `avaccess` bin with its generator, and the standalone
|
|
53
|
+
host (`@aventara/access/standalone`). The published package listed the first group of entries below under
|
|
54
|
+
"Unreleased": they were made after this candidate was first written up and before it was published, and they ship
|
|
55
|
+
in it (the container image the later entries describe was removed then).
|
|
56
|
+
|
|
57
|
+
- The engine warns, as a Node.js warning `ACCESS_REFERENCE_UNREADABLE`, when a role may create or update a reference
|
|
58
|
+
(a to-one relation's reference fields) to a Resource no read grant reaches for that role. Aventara holds a reference
|
|
59
|
+
a write sends to the rows the caller may read, so such a write is refused (`A4002`). It holds only references to the
|
|
60
|
+
Resources its Core Contract shows, and says which when it hosts the engine (`engine.hosted({ held })`, called by
|
|
61
|
+
`@aventara/core` `1.0.0-rc.10` or later), so the warning comes then, for those Resources only: one the app's root
|
|
62
|
+
exemptions hide is not warned about, and compiling the rules (`.contract()`, `avaccess push`) warns about nothing.
|
|
63
|
+
The warning changes nothing the engine decides. A reference the grant locks with `write: { except }` is not warned
|
|
64
|
+
about, nor is one to a foreign Resource (below).
|
|
65
|
+
- The engine lists the Resources its rules read as foreign in `unheld`. No caller reads a foreign Resource, so under
|
|
66
|
+
`@aventara/core` `1.0.0-rc.10` every write of a reference to one (a chat `Room`'s `campaignId`) was refused; with
|
|
67
|
+
`unheld`, Aventara holds no written reference to them, and the write grant decides which row may be linked: a
|
|
68
|
+
`where` on a create grant, an `after` (or the `where`) on an update grant, through the relation. Reads of a foreign
|
|
69
|
+
Resource are unchanged. When Aventara hosts the engine, a role whose create or update grant may write such a
|
|
70
|
+
reference with no condition through that relation is warned about (`ACCESS_FOREIGN_REFERENCE_UNCHECKED`, a Node.js
|
|
71
|
+
warning, once per process and namespace): any row of the foreign Resource could be linked by its identifier.
|
|
72
|
+
- The container image `aventara/access-server` is removed, with its `access-deps` script. Run `avaccess serve` in a
|
|
73
|
+
container you build: the README's "Running `avaccess serve`" shows a minimal Dockerfile. `avaccess serve` itself is
|
|
74
|
+
unchanged.
|
|
75
|
+
- Better Auth is taken at a patch range, `~1.7.12`: `better-auth`, `@better-auth/oauth-provider` and
|
|
76
|
+
`@better-auth/cimd` are optional peers at `~1.7.12`, and `createAccess`, `aventaraAccess()` and the `mcp`/`audience`
|
|
77
|
+
checks take 1.7.12 or any later 1.7 patch. An older version, 1.8 or a prerelease is still refused with
|
|
78
|
+
`ACCESS_BETTER_AUTH_VERSION`. A plain `npm install @aventara/access better-auth @better-auth/oauth-provider` now
|
|
79
|
+
resolves; `@better-auth/core` and `@better-auth/utils` no longer need pinning by hand. Tested on 1.7.14.
|
|
80
|
+
- `createAccess({ mcp: { resource } })` and `aventaraAccess({ mcp: { resource } })` make Access the authorization
|
|
81
|
+
layer of an MCP endpoint. Better Auth's own OAuth provider (`@better-auth/oauth-provider` `~1.7.12`, in Better Auth's
|
|
82
|
+
plugins with its options untouched) issues the tokens; Access issues none and serves no OAuth route.
|
|
83
|
+
`access.mcp` (and `accessOf(auth).mcp`) is the framework's `mcp.authorization`, `{ servers, verify }`, typed present
|
|
84
|
+
exactly when `mcp` is given. `verify` takes the provider's JWT access token for that endpoint alone (RFC 8707) and
|
|
85
|
+
resolves `{ sub, claims, clientId, scopes }`, the claims read from the user on every request; it refuses a session
|
|
86
|
+
token, a token for another resource, an opaque, DPoP-bound or client-credentials token, and one whose client is
|
|
87
|
+
disabled, whose user is gone or no longer consents to its scopes and its resource, whose session has ended or which
|
|
88
|
+
names none (`sid`), whose resource the provider no longer has, or which is signed with an algorithm `jwt()` does not
|
|
89
|
+
sign with. A consent stored under a reference never counts.
|
|
90
|
+
- The request state reads the same token. The principal it yields is held to it before the rules: refused every
|
|
91
|
+
question under any origin but `agent`; under `agent`, `aventara:read` admits reads and `aventara:write` writes, and
|
|
92
|
+
what they admit is the user's own grants. A refusal is an audit event with the reason `deny:mcp-audience` or
|
|
93
|
+
`deny:mcp-scope`. Access's `GET /me` route answers the token `null`; `access.client.verbs.me.call` (the MCP `me`
|
|
94
|
+
tool's) answers its identity `{ sub, claims }`, which `agentOf` tells is an agent's.
|
|
95
|
+
`agentOf(me)` (`/auth`, `/better-auth`) tells app code an agent's principal, and the identity `principal` maps for
|
|
96
|
+
one: `{ clientId, scopes }`, else `null`.
|
|
97
|
+
- `audience` (`createAccess` and `aventaraAccess()`) is a service's opt-in to its own token audience (RFC 8707): with
|
|
98
|
+
it, a bearer token is taken only when it is the provider's access token addressed to the service and carrying
|
|
99
|
+
`aventara:read` or `aventara:write`, held to those scopes under every origin; the one-org token `GET /token` issues
|
|
100
|
+
is refused. Off by default, with no change to the default path.
|
|
101
|
+
- Under `mcp` or `audience`, Access refuses to start on a provider that would let a token's authorization fall short:
|
|
102
|
+
none or two in the plugins, a version outside `~1.7.12` (`ACCESS_BETTER_AUTH_VERSION`), `disableJwtPlugin`, a
|
|
103
|
+
resource missing from its `resources`, no `aventara:read` among its `scopes`, a `refreshTokenReuseInterval`
|
|
104
|
+
other than 0, a `validateRedirectUri`, a `requestUriResolver`, a `postLogin.consentReferenceId`, open client
|
|
105
|
+
registration (`allowUnauthenticatedClientRegistration`) unless `mcp.openRegistration: true` acknowledges it (never
|
|
106
|
+
under `audience` alone), a resource that is the provider's UserInfo URL, or the `service` profile; with `audience`,
|
|
107
|
+
`enforcePerClientResources: false`, and beside `mcp`, the audience in `clientRegistrationDefaultResources` or
|
|
108
|
+
`clientRegistrationAllowedResources` (an agent's self-registered client would get tokens that pass for the
|
|
109
|
+
service's). A `cimd()` outside `~1.7.12` is refused with `ACCESS_BETTER_AUTH_VERSION`. Every authorization request
|
|
110
|
+
must use PKCE with its SHA-256 method and carry its own parameters (no `request_uri`). Under `mcp` the issuer must be https, or http on `127.0.0.1` or `[::1]` (`localhost` is refused
|
|
111
|
+
with the address to write).
|
|
112
|
+
- The plugin answers for `jwt()` in Better Auth's context (`ctx.getPlugin("jwt")`), so a plugin that looks `jwt()` up
|
|
113
|
+
by its id, as the OAuth provider does, finds the one `aventaraAccess()` carries. A provider listed before
|
|
114
|
+
`aventaraAccess()` is refused with a sentence naming the order.
|
|
115
|
+
- The session verifier refuses an OAuth access token (`typ: "at+jwt"`) whatever it carries.
|
|
116
|
+
- The session verifier and the access-token verifier take only the asymmetric algorithms `jwt()` signs with; a key
|
|
117
|
+
the set publishes without naming its algorithm no longer verifies a token signed with another.
|
|
118
|
+
- `@better-auth/oauth-provider` and `@better-auth/cimd` are optional peers at `~1.7.12`. New `/auth` and
|
|
119
|
+
`/better-auth` names: `AccessMcpOptions`, `AccessMcpAuthorization`, `AccessAgentIdentity`, `AccessAgent`, `agentOf`.
|
|
120
|
+
- `profile: { kind: "kiosk", canEnrollDevice, idleTimeout? }`: the web profile plus staff switching by PIN on enrolled
|
|
121
|
+
devices. A fresh full-credential session that `canEnrollDevice` allows enrolls a device (`POST
|
|
122
|
+
/kiosk/enroll-device`): the device gets a random credential in an HTTP-only cookie (a `__Host-` cookie on an https
|
|
123
|
+
base URL), stored as its SHA-256, and the enrolling session ends. `POST /kiosk/set-pin` sets a fresh
|
|
124
|
+
full-credential session's own PIN; your server sets anyone's with Better Auth's server-only endpoint
|
|
125
|
+
`access.api.kioskSetUserPin({ body: { userId, pin } })`. `POST /kiosk/switch-user` (`{ userId, pin }`) opens a
|
|
126
|
+
session for that user on an enrolled device and ends every session on it and every one the request carried; `POST
|
|
127
|
+
/kiosk/lock` ends the same; `POST /kiosk/revoke-device` revokes one and `GET /kiosk/list-devices` lists them, for a
|
|
128
|
+
manager. `access.kiosk` is the operator's API on the server: `listDevices()` and `revokeDevice(deviceId)`. On the
|
|
129
|
+
web and service profiles these routes answer `404`.
|
|
130
|
+
- A PIN is 6 to 12 digits and is refused when it is one a guesser tries first: iOS's 6-digit passcode blocklist (as
|
|
131
|
+
Markert et al. extracted it) widened to every length, and the most common 6-digit PINs of leaked passwords. It is
|
|
132
|
+
hashed with argon2id at the `passwords` profile, whatever hashes passwords. Wrong PINs are limited per user id on one
|
|
133
|
+
device (5, then a backoff doubling from 1 minute to 1 hour, and after 100 in a row the PIN stops working there,
|
|
134
|
+
`KIOSK_PIN_DISABLED`) and per device (10 at once for any users, then one more every 90 seconds), both counted in the
|
|
135
|
+
database before the PIN is checked. The per-user count is kept for any user id, one with no PIN or no user included,
|
|
136
|
+
so no answer tells who holds a PIN; it is per device, so it slows or disables a colleague's PIN on that device only,
|
|
137
|
+
and a full sign-in (not an admin's impersonation) or a new PIN clears it. While a device is held, a user with no
|
|
138
|
+
wrong PIN on it since their last right one still gets one try.
|
|
139
|
+
- Every session opened on an enrolled device (a PIN switch's, or a password sign-in's there) is bound to it: on every
|
|
140
|
+
call of Better Auth's that carries it, by cookie or by `Authorization: Bearer` (`access.state` and `GET /me`
|
|
141
|
+
included), it is ended unless the request brings the device's current credential, or when it has been idle for
|
|
142
|
+
`idleTimeout` (300 seconds by default, 60 to 3600) with no request that may change something (a read, such as a
|
|
143
|
+
page polling `/get-session`, does not count). A bound session is issued no token, and `/get-session` answers it
|
|
144
|
+
without its token; no answer on this profile carries `bearer()`'s `set-auth-token`, whatever the plugin's id. With
|
|
145
|
+
`mcp` or `audience`, a bound session is refused at the OAuth provider's grant and client- and consent-management
|
|
146
|
+
routes, and a sign-in on an enrolled device that would resume an authorization request (in its body, or in a social
|
|
147
|
+
sign-in's state begun elsewhere) opens no session. The credential is replaced at every switch, and every replaced one is kept as a hash: any of
|
|
148
|
+
them brought back revokes the device, except the one the last switch replaced within 30 seconds of it, which is
|
|
149
|
+
refused (`409 KIOSK_DEVICE_BUSY`) with the device and its cookie left as they are, a sign-in that brings it included. A PIN session reaches only
|
|
150
|
+
reading its session, signing out, `me`, the key set, the switch, the lock and the full-credential sign-ins (`403
|
|
151
|
+
KIOSK_SESSION_NOT_ALLOWED` otherwise), through `access.handler` and through the server's own `access.api` calls with
|
|
152
|
+
its cookie.
|
|
153
|
+
- A switch opens a new session with the switched-to user's own claims and ends the one before; nothing of it carries
|
|
154
|
+
over. The audit log gains `kiosk-switch` (with `previousUserId` and `deviceId`), `kiosk-lock` (`lock`, `idle`,
|
|
155
|
+
`device-mismatch`), `kiosk-device` (`enrolled`, `revoked`, `token-reuse`, `stale-in-grace`) and `kiosk-pin`; a wrong PIN is a
|
|
156
|
+
`sign-in-failed` on `/kiosk/switch-user` with its `deviceId`. With `audit: true` on this profile, the `accessAudit`
|
|
157
|
+
table gains `deviceId` and `previousUserId`.
|
|
158
|
+
- The kiosk adds the tables `accessKioskDevice`, `accessKioskPin`, `accessKioskLockout` and
|
|
159
|
+
`accessKioskRetiredSecret`, and `kioskDeviceId`, `kioskSeenAt` and `kioskPin` on Better Auth's `session`; the
|
|
160
|
+
credential and PIN hashes are secret columns no grant opens.
|
|
161
|
+
- On the kiosk profile `access.client.verbs` publishes the kiosk's verbs (`switchUser`, `lock`, `setPin`,
|
|
162
|
+
`enrollDevice`, `revokeDevice`) after Better Auth's, as the framework's `$access` takes them.
|
|
163
|
+
- The kiosk profile refuses a second kind (`kind: ["kiosk", "service"]`), its keys on another kind, Better Auth's
|
|
164
|
+
cookie cache, `secondaryStorage` without `session.storeSessionInDatabase`, `bearer()`, `multiSession()`,
|
|
165
|
+
`crossSubDomainCookies` and `SameSite=None`, a plain http: base URL off a loopback host, and a plugin with an
|
|
166
|
+
endpoint under `/kiosk/`
|
|
167
|
+
(`ACCESS_PLUGIN_DUPLICATE`). `/auth` exports `AccessProfileOptions`, `AccessKioskProfile`, `AccessKiosk` and
|
|
168
|
+
`AccessKioskDevice`.
|
|
169
|
+
- `profile.insecureLan` (kiosk only): for an on-prem till network without TLS, the private IPv4 ranges on whose
|
|
170
|
+
addresses the kiosk takes a plain http: base URL, such as `http://192.168.1.10:3000` under
|
|
171
|
+
`["192.168.1.0/24"]`. A range is an IPv4 CIDR written in full (no leading zeros, host bits clear), a /16 or
|
|
172
|
+
narrower, wholly inside 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16; a hostname, IPv6, shared, link-local,
|
|
173
|
+
loopback or public range, an empty list, a repeated range or one inside another is refused (`ACCESS_OPTIONS_INVALID`), and so is the
|
|
174
|
+
key on `web` or `service` (also a type error: the type is a non-empty list of `${number}.${number}.${number}.${number}/${number}`).
|
|
175
|
+
The base URL must be an IPv4 address inside a listed range, written as the URL parser writes it (no leading zeros,
|
|
176
|
+
octal, hex or single number) and not the range's network or broadcast address, never a hostname; no `advanced` cookie setting may make
|
|
177
|
+
a cookie `Secure` or name it `__Secure-`/`__Host-` there (a browser drops it over http); the device cookie is
|
|
178
|
+
then `better-auth.kiosk_device`, HTTP-only, `SameSite=Lax`, at `/`, without `Domain`. Better Auth's logger warns at
|
|
179
|
+
startup, naming the base URL and the ranges. An https: or loopback base URL ignores the option.
|
|
180
|
+
- The kiosk profile reads a dynamic `baseURL` whole: it needs `protocol: "https"` unless every allowed host is a
|
|
181
|
+
loopback host (its fallback alone was read before, so `protocol: "http"`, `"auto"` or unset served the allowed
|
|
182
|
+
hosts in the clear), and `insecureLan` takes a fixed `baseURL`.
|
|
183
|
+
- The kiosk ends a session bound to no device when the request brings an enrolled device's credential (current, in
|
|
184
|
+
its grace, or retired), as it ends one of another device (`kiosk-lock`, reason `device-mismatch`). Such a session
|
|
185
|
+
was opened off the device: from a React Native till on `@better-auth/expo`, a native ID-token sign-in (its client
|
|
186
|
+
sends no cookie with it) or a sign-in in the system browser handed to the app (a magic link, social sign-in's
|
|
187
|
+
redirect). Before, it went on unbound on the till: its token answered by `/get-session` and `GET /token`, and no idle
|
|
188
|
+
lock. The README's kiosk section documents a till that is an Expo app (`expo()`, the app's scheme in
|
|
189
|
+
`trustedOrigins`, the ID-token sign-in with its cookies passed, cleartext under `insecureLan`).
|
|
190
|
+
- `aventaraAccess({ registry: { enabled: true } })` makes the app that runs Better Auth the registry's hub, with no
|
|
191
|
+
Access server beside it. The block is `createAccess`'s (`enabled`, `ownership`). The registry's tables are the
|
|
192
|
+
plugin's schema (the same tables `createAccess` uses, created by Better Auth's `migrate`/`generate` through the app's
|
|
193
|
+
adapter) and its routes are the plugin's endpoints under `/access/v1`, served wherever the app mounts
|
|
194
|
+
`auth.handler`. Off by default: no tables, and `/access/v1/*` answers `404`.
|
|
195
|
+
- `accessOf(auth).registry` is the operator's API when the plugin serves the registry, typed present exactly then
|
|
196
|
+
(`AccessProvider`'s fourth parameter; the plugin's `$Infer.AccessRegistryEnabled`). No route opens it.
|
|
197
|
+
- `avaccess keys --server <module>` takes a module that exports `accessOf(auth)`. A module that exports only Better
|
|
198
|
+
Auth's own instance is refused with a sentence that names `accessOf(auth)`.
|
|
199
|
+
- With the registry on (`createAccess` or the plugin), another Better Auth plugin with an endpoint named like one of
|
|
200
|
+
the registry's (`accessPush`, `accessHead`, …) or at a path under `/access/v1`, or with a middleware whose path can
|
|
201
|
+
match one there (`/access/v1/**`, `/access/**`, `/**`, `/*/v1/key`), is refused with `ACCESS_PLUGIN_DUPLICATE`: it
|
|
202
|
+
would answer a service's push or pull and read the key it sends. Other plugins' `onRequest` and `hooks` are inside
|
|
203
|
+
the app's trust boundary and are not checked.
|
|
204
|
+
- An endpoint path is checked as Better Auth's router reads it, one leading slash and no trailing one, for the
|
|
205
|
+
registry's routes, `jwt()`'s `/jwks` and `/token`, and the `me` route: `token` or `access/v1/key` without the slash
|
|
206
|
+
was not refused before.
|
|
207
|
+
- The hub keeps Better Auth's one org token (issuer and audience the base URL): a token works at every service that
|
|
208
|
+
checks the hub's key set, and at the hub, until it expires. Access adds no token endpoint; a token bound to one
|
|
209
|
+
service is an opt-in from Better Auth's OAuth provider with resource indicators.
|
|
210
|
+
- `@aventara/access/better-auth` exports `AccessIdentity`, the default principal type: an app that leaves out
|
|
211
|
+
`principal` and emits declarations (`export const auth = betterAuth({...})`) failed with TS2742/TS2883 without it.
|
|
212
|
+
- A token Access issues carries every declared boolean claim: one the user's field leaves unset (absent, `null` or
|
|
213
|
+
`undefined`), or `policies.claimsOf` returns as `null` or `undefined`, is `false`. A token for a user with no ban
|
|
214
|
+
now carries `banned: false`.
|
|
215
|
+
- `policies.claimsOf` must return every declared boolean claim's key. An answer without one is refused with
|
|
216
|
+
`ACCESS_OPTIONS_INVALID`, naming the claim, and no token is issued. It is a type error too: `claimsOf` returns an
|
|
217
|
+
`AccessClaimsAnswer` (from `/auth`), where each declared boolean claim is required and the others stay optional, in
|
|
218
|
+
`createAccess` and in `aventaraAccess()`.
|
|
219
|
+
- `policies.claims` refuses a claim named `__proto__`. A claim name is matched against the declaration and a token's
|
|
220
|
+
payload by own key only, so `toString` or `constructor` is undeclared unless declared.
|
|
221
|
+
- Tokens issued before this version lack the boolean claims, so a deny such as `banned: true` refuses them until they
|
|
222
|
+
expire, within `policies.tokenTtl` (15 minutes by default). Users sign in again or fetch a new token.
|
|
223
|
+
- A deny's `if` (a guardrail's too) holds on a claim the principal lacks whatever value it compares with, `true`
|
|
224
|
+
included. `banned: true` still leaves alone a user whose ban is unset, since their token carries `banned: false`,
|
|
225
|
+
and now denies a token that lacks `banned` (one from another issuer, or issued before the claim was declared): it
|
|
226
|
+
fails closed. The anonymous caller is the exception: a comparison with `true` never holds for it, so a ban does
|
|
227
|
+
not close an `anyone` grant. A generated client copies the change; run `avaccess generate` again.
|
|
228
|
+
- A principal you build yourself (in tests, or from another issuer's token) must carry each boolean claim a deny
|
|
229
|
+
compares with `true`, as an issued token does, or that deny refuses it.
|
|
230
|
+
- An update is held to one grant. `can({ action: "update", row, before })`, with `before` the row as it was before
|
|
231
|
+
the update, allows it only when one update grant covers both rows: its `where` the row before, its `where` and
|
|
232
|
+
`after` the row after. A row that one update grant covers before the update and only another covers after it is
|
|
233
|
+
refused. Update denies are asked about the row after only, as before. Aventara hands `can` the row before every
|
|
234
|
+
update the engine lists in `afterChecks` from `@aventara/core` `1.0.0-rc.10` on; without `before` (an older core),
|
|
235
|
+
`can` answers as it did, on the row after alone.
|
|
236
|
+
- A generated client's `can` takes the same optional `before`, with the same answer; run `avaccess generate` again.
|
|
237
|
+
- The engine answers `grantsOf({ principal, origin, resource, action: "update", row, exists })`: the keys of the
|
|
238
|
+
update grants whose `where` and subject's row condition hold on `row` as it is now. `@aventara/core`
|
|
239
|
+
`1.0.0-rc.10` asks it before an update `afterChecks` lists writes, and hands the answer back to `can` as
|
|
240
|
+
`beforeGrants`. With `beforeGrants`, the grant that covers the row after must be one of them. With `before` too,
|
|
241
|
+
each listed grant's `where` is still checked on `before` in memory (a key list replayed from another row or
|
|
242
|
+
principal fails closed on a scalar condition), and a relation condition on it is not asked again: it is decided
|
|
243
|
+
before anything the same request writes. A row
|
|
244
|
+
handed to someone else and tagged "shared" in the same update is refused by a grant on shared rows. Without
|
|
245
|
+
`beforeGrants`, `can` answers as it did.
|
|
246
|
+
- The keys are opaque and stable for one policies file: a short hash of the file's grants, then a grant's place in
|
|
247
|
+
it and its subject's place in the grant, no rule id or condition; a list from another file covers nothing.
|
|
248
|
+
`grantsOf` answers `[]` on a row an update deny already refuses in memory. A `beforeGrants` that is not a list of strings fails closed, and a `grantsOf` question
|
|
249
|
+
about anything but an update is refused with `ACCESS_QUESTION_INVALID`. `AccessGrantsQuestion` is exported.
|
|
250
|
+
- A generated client has `grantsOf`, with `GrantsQuestionOf<R>`, and its `can` takes `beforeGrants` on an update
|
|
251
|
+
question only, with the same answers as the engine; a `grantsOf` about anything but an update throws an error
|
|
252
|
+
shaped as `AccessError` (`code: "ACCESS_QUESTION_INVALID"`). Run `avaccess generate` again.
|
|
253
|
+
- `access.client`: what the framework's `$access` reads from a `createAccess` instance. `url` is Better Auth's base URL
|
|
254
|
+
with its base path (the base path alone when `baseURL` is dynamic). `verbs` holds `signIn`, `signUp`, `signOut`,
|
|
255
|
+
`resetPassword` and `me`, each with its method, its path under `url`, the JSON Schema of its arguments and of its
|
|
256
|
+
result, and `call`. The arguments follow Better Auth's client; a result names the user's id and Better Auth's flags,
|
|
257
|
+
never the session token or a user field; `me` is the token identity, `{ sub, claims }`, with the declared claims.
|
|
258
|
+
- A verb the instance does not serve is left out: `signIn`, `signUp` and `resetPassword` without `emailAndPassword`,
|
|
259
|
+
`signUp` with `disableSignUp`, a verb whose path is in `disabledPaths`, and `signIn` and `signUp` on the `service`
|
|
260
|
+
profile.
|
|
261
|
+
- `call({ args, headers })` runs the verb in-process through Better Auth's API with the request's headers, so
|
|
262
|
+
`nextCookies()` sets the cookie from a Server Action. It is held to the route's checks: its arguments to the verb's
|
|
263
|
+
schema, Better Auth's origin check (which its router runs and a direct API call skips), the profile's refusals and the
|
|
264
|
+
audit log. Its answer is cut to the verb's result schema.
|
|
265
|
+
- `call` of `signIn`, `signUp` and `resetPassword` is rate-limited, since a call to Better Auth's API skips the limit
|
|
266
|
+
its router applies. The limit is Better Auth's own `rateLimit` options (on when they turn it on, the route's rule,
|
|
267
|
+
their storage), counted by client address (read as Better Auth reads it, `trustedProxies` included, under the
|
|
268
|
+
router's key) and by target (the email or reset token named, as an HMAC). The target bucket counts failures, 10
|
|
269
|
+
each within 5 minutes of the one before, and a success clears it, so others' failures cannot keep the owner out.
|
|
270
|
+
Over either, the call throws Better Auth's `429` with `X-Retry-After`, and the audit log records a
|
|
271
|
+
`sign-in-failed` with reason `rate-limited`.
|
|
272
|
+
- `call` runs every plugin's `onRequest`, as Better Auth's router does before a route, so `captcha()` and its kind
|
|
273
|
+
hold for server calls too: a refusal is thrown as Better Auth's `APIError` with the plugin's status and body.
|
|
274
|
+
- `GET /api/auth/me` answers the request's identity, `{ sub, claims }`, or `null`, uncached. A plugin in
|
|
275
|
+
`betterAuth.plugins` with an endpoint at `/me` or named `accessMe` is refused with `ACCESS_PLUGIN_DUPLICATE`.
|
|
276
|
+
- `@aventara/access/better-auth`: `aventaraAccess({ policies, principal?, resources?, organization?, audit? })` is Access as a
|
|
277
|
+
plugin of the Better Auth an app builds itself (`betterAuth({ plugins: [aventaraAccess(...)] })`). It carries Better
|
|
278
|
+
Auth's `jwt()`, so its tokens carry `sub` and the declared claims only, under the base URL's origin as issuer and
|
|
279
|
+
audience. It runs `createAccess`'s own checks on Better Auth's options when Better Auth starts it, and refuses what
|
|
280
|
+
`createAccess` refuses, with the same codes.
|
|
281
|
+
- `accessOf(auth)` is the framework's `access` option from that instance: the engine, with Better Auth's tables closed
|
|
282
|
+
and their secrets sealed as under `createAccess`, and each request's `me` from a bearer token or the session
|
|
283
|
+
cookie. `accessOf(auth).$Infer` types the principal and the claims.
|
|
284
|
+
- The organization bridge, `organization: { claim, organizationClaim, roles }`, puts a member's role in the session's
|
|
285
|
+
active organization into one claim and that organization into another, both declared strings; `roles` is an ordered
|
|
286
|
+
list of `[Better Auth role, value]`. It is read from Better Auth's store when a token is issued, so removing a member
|
|
287
|
+
removes both claims from the next token. A rule on the bridged role is held to the organization
|
|
288
|
+
(`where: { organizationId: me.org }`). The bridge refuses an organization plugin that lets any user create an
|
|
289
|
+
organization (`allowUserToCreateOrganization` must be `false` or a function), and, with dynamic access control, a
|
|
290
|
+
role that is not static. A member's roles are read as Better Auth reads them: `member, owner` gives nothing.
|
|
291
|
+
- `audit` (`true` or `{ sink }`) logs in plugin mode as under `createAccess`: sign-ins, refused sign-ins, sign-outs
|
|
292
|
+
and tokens, and the decisions of `accessOf(auth).policy`.
|
|
293
|
+
- One `aventaraAccess()` in two Better Auth instances whose options differ is refused (`ACCESS_PLUGIN_DUPLICATE`).
|
|
294
|
+
- `aventaraAccessClient({ api })` adds `authClient.access.can([...])` to Better Auth's client: one
|
|
295
|
+
`POST {api}/_access/can` to the framework for the whole page, one boolean per question, answered as Better Auth's
|
|
296
|
+
`{ data, error }`.
|
|
297
|
+
- Better Auth's advisory GHSA-fmh4-wcc4-5jm3 is unfixed as of `better-auth` 1.7.14 (the GitHub advisory database's entry
|
|
298
|
+
still says it was fixed in 1.6.11): its organization plugin lets a user whose email is not verified accept an
|
|
299
|
+
invitation sent to that email. `createAccess` with `resources: { members: true }`, and the organization bridge,
|
|
300
|
+
now refuse an `organization()` plugin without `requireEmailVerificationOnInvitation: true`
|
|
301
|
+
(`ACCESS_OPTIONS_INVALID`).
|
|
302
|
+
- `aventaraAccess()` inside `createAccess`'s `betterAuth.plugins` is refused by name (`ACCESS_PLUGIN_DUPLICATE`).
|
|
303
|
+
- `createAccess` hashes passwords with argon2id. Better Auth hashes with scrypt; Access now offers three ways:
|
|
304
|
+
- by default, when neither `passwords` nor `betterAuth.emailAndPassword.password` is set, argon2id at the
|
|
305
|
+
`"owasp-minimum"` profile (m=19456 KiB, t=2, p=1, the OWASP Password Storage Cheat Sheet's minimum);
|
|
306
|
+
- a named profile, `passwords: { argon2id: "owasp-minimum" | "rfc9106-low-memory" }` (the second is RFC 9106's
|
|
307
|
+
second recommended option, m=65536 KiB, t=3, p=4). A misspelled profile is a type error, and
|
|
308
|
+
`ACCESS_OPTIONS_INVALID` at runtime;
|
|
309
|
+
- the app's own `betterAuth.emailAndPassword.password` hook, which passes through untouched. Setting it and
|
|
310
|
+
`passwords` together is refused with `ACCESS_OPTIONS_INVALID`.
|
|
311
|
+
- argon2id runs on Node.js's `crypto.argon2` (Node.js 24.7.0 and later), with no native dependency. On Node.js 22 it
|
|
312
|
+
runs on `@node-rs/argon2` `2.2.2`, a new exact optional peer, loaded only when `crypto.argon2` is missing; without
|
|
313
|
+
it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID`. Both write the same PHC strings.
|
|
314
|
+
- Hashes are PHC strings (`$argon2id$v=19$m=…,t=…,p=…$salt$hash`), checked at the parameters they name with a
|
|
315
|
+
constant-time compare, so a later profile change keeps every older hash verifying. A stored hash that costs more
|
|
316
|
+
than the largest profile is refused without being computed. A malformed or tampered hash, or a failure inside the
|
|
317
|
+
computation, fails the check instead of throwing, and is logged through Better Auth's logger. A password over Better
|
|
318
|
+
Auth's `maxPasswordLength` is never hashed (`PASSWORD_TOO_LONG`).
|
|
319
|
+
- Better Auth's scrypt hashes fail the check unless `passwords.legacyScrypt` is `true`. With it, they verify through
|
|
320
|
+
Better Auth's own `verifyPassword`, and a sign-in that matches one stores the password again with argon2id. Every
|
|
321
|
+
check costs the same whatever an account holds: one argon2id computation, and with `legacyScrypt` one scrypt too.
|
|
322
|
+
- A sign-in whose password matches a hash stored at other parameters than the `passwords` profile's (an older profile,
|
|
323
|
+
cheaper or costlier) stores the password again at the profile, as a scrypt hash moves under `legacyScrypt`: the
|
|
324
|
+
password is checked against the stored hash again, and the write is a compare-and-set on that hash, so a password
|
|
325
|
+
changed meanwhile is never overwritten. A hash at the profile costs one read per password sign-in and no write.
|
|
326
|
+
Kiosk PINs move the same way at the next switch.
|
|
327
|
+
- `aventaraAccess()` takes the same `passwords` and hashes the same way, putting its hook in Better Auth's context when
|
|
328
|
+
Better Auth starts the plugin; an app's own `emailAndPassword.password` passes through, and setting both is refused.
|
|
329
|
+
- `@aventara/access/auth` exports `AccessPasswordsOptions` and `AccessArgon2idProfile`.
|
|
330
|
+
- The ownership beacon. A namespace reads another namespace's Resources in conditions (`{ foreign: "<owner>" }`)
|
|
331
|
+
only while both services run on one database, and Access now checks it. Each service writes one row in
|
|
332
|
+
`_aventara_beacon` (`namespace`, `value`, `createdAt`), a random `avb_…` value written once, through its own data
|
|
333
|
+
layer: Access takes an `AccessBeaconStore` (`read`, `create`) and writes no SQL.
|
|
334
|
+
- `avaccess push <rules> --beacon <module>` sends the namespace's own beacon with its file, and the beacon of each
|
|
335
|
+
namespace the file reads, as this service's connection reads it. The registry keeps only hashes bound to their
|
|
336
|
+
namespace (`sha256` of `<namespace>:<value>`, a new `accessBeacon` table that keeps each namespace's past ones), and
|
|
337
|
+
stores a file that reads another namespace only when the push carries that namespace's beacon as pushed: otherwise
|
|
338
|
+
`ACCESS_FOREIGN_CROSS_DATABASE` (`403`), which suggests a JWT claim instead. Only a namespace's own push registers
|
|
339
|
+
or replaces its beacon; one it replaced never comes back (`ACCESS_BEACON_MISMATCH`, `409`). A push never adopts a
|
|
340
|
+
row in `_aventara_beacon` it did not write and the registry does not hold: it is refused until an operator
|
|
341
|
+
verifies or deletes it. The push body may be the file, or `{ file, beacons }`; a namespace's info
|
|
342
|
+
(`GET /access/v1/namespaces/:ns`) carries its beacon's hash.
|
|
343
|
+
- `avaccess pull --foreign <owner>:<Resource>,… --beacon <module> --env <environment>` writes the owner's schema
|
|
344
|
+
entries of those Resources to `access/foreign.json` (format `aventara-access/foreign@1`, pinned in the lock as
|
|
345
|
+
`foreign`, checked by `verify`), and `avaccess schema` marks them as the owner's in the model it writes. A pull of
|
|
346
|
+
rules that read another namespace checks that namespace's beacon through `--beacon` and writes nothing on another
|
|
347
|
+
database. The new registry route `GET /access/v1/namespaces/:ns/foreign/:owner?resources=…` answers only for what
|
|
348
|
+
the namespace's current file reads as that owner's, refuses everything else with one and the same answer, and logs
|
|
349
|
+
every read.
|
|
350
|
+
- Beacon pins are per environment: `avaccess pull --env <environment>` (or `AVACCESS_ENV`) writes
|
|
351
|
+
`avAccess.beacons.<environment>.json` (format `aventara-access/beacons@1`). `avAccess.lock.json` holds no beacon, so
|
|
352
|
+
one lock still holds against any registry. `avaccess verify [--env]` checks every environment's pins too.
|
|
353
|
+
- An engine whose rules read another namespace, with a lock or without, decides nothing until
|
|
354
|
+
`await engine.checkBeacons(reader, pins, environment)` (`access.policy.checkBeacons` under `createAccess`) has
|
|
355
|
+
passed: the pins must be `environment`'s, and each owner's row, read through the service's own connection, must hash
|
|
356
|
+
to its pin, or every decision throws `ACCESS_BEACON_MISMATCH`. A failed check latches, and checks run one after
|
|
357
|
+
another. The generated client does the same with `access.checkBeacons(reader, pins, environment)` and throws an
|
|
358
|
+
error with that `code`; with `--orm prisma7` it adds `access.prismaBeacon(prisma)` (a store over the
|
|
359
|
+
`AventaraBeacon` Prisma model, `PrismaBeaconClient`). `avaccess schema` leaves that model out of the Resources.
|
|
360
|
+
- With the registry on, an Access server whose rules name a namespace declares the `aventaraBeacon` table through
|
|
361
|
+
Better Auth's plugin schema and exposes `access.beacon`, its store over Better Auth's adapter. The auth-tables guard
|
|
362
|
+
keeps every beacon table closed and its `value` sealed: the Access server's, and any Resource named like one.
|
|
363
|
+
- New exports: `AccessBeaconReader`, `AccessBeaconStore`, `AccessBeaconPins`; new codes `ACCESS_FOREIGN_CROSS_DATABASE`
|
|
364
|
+
and `ACCESS_BEACON_MISMATCH`. `aventara-access/lock@1` takes an optional `foreign` pin.
|
|
365
|
+
|
|
366
|
+
- `definePolicies(model)` writes role-first rules, typed from your app's schema (an Aventara discovery artifact's
|
|
367
|
+
`model`). Global roles come from the principal's claims; roles held on a row (`on`, `where`) come from your own
|
|
368
|
+
tables. `.govern()` keeps fields out of every result unless a grant names them. `.grants()` and `.denies()` take the
|
|
369
|
+
rules: nothing is allowed until a grant allows it, any deny wins, and order never matters.
|
|
370
|
+
- Every name a rule uses (a Resource, field, relation, enum value, role, claim or governed field) is checked against
|
|
371
|
+
the schema: a misspelled one is a type error at its key.
|
|
372
|
+
- `definePolicies(model, { namespace })` names the namespace that owns the rules' Resources. A Resource marked
|
|
373
|
+
`{ foreign: "<namespace>" }` in the schema can be read in conditions, and a grant on it is refused.
|
|
374
|
+
- `.contract()` compiles the rules to a policies file in the `aventara-access/policies@1` format: plain JSON with no
|
|
375
|
+
functions in it. Unknown members are refused, and so are files in the earlier unstable format.
|
|
376
|
+
- The file's parts are exported as types (`AccessGrantRule`, `AccessDenyRule`, `AccessRoleDefinition`,
|
|
377
|
+
`AccessPredicate` and the rest), so a project that emits declarations can export any part of a file or of an
|
|
378
|
+
explanation.
|
|
379
|
+
- `policiesHash(file)` returns the file's canonical hash (`"sha256:…"`), the same for the same rules in any order.
|
|
380
|
+
- `.engine()` and `createAccessEngine(file)` return the engine. It fits `@aventara/core`'s `AvPolicyEngine` by shape,
|
|
381
|
+
so it plugs into the framework's `policy` option or into an `access` provider. It answers `can`, `scopeOf` and
|
|
382
|
+
`fieldsOf`, and `explain` shows, in development, which rules took part in a decision.
|
|
383
|
+
- The engine (`@aventara/access`) imports nothing at runtime, and the package has no dependency on the
|
|
384
|
+
framework.
|
|
385
|
+
- A deny on a nullable field lets the NULL rows through in `scopeOf` too, as it does in `can`. A `policies@1` schema
|
|
386
|
+
entry lists its nullable scalar fields in an optional member, `nullable`, which `.contract()` writes from the schema
|
|
387
|
+
and the hash reads as a set; a file without it has no nullable field, and an empty list is refused. `scopeOf` writes
|
|
388
|
+
a negated comparison on one of them as `NOT (f = v) OR f IS NULL`; a field that is not nullable keeps the plain
|
|
389
|
+
`NOT`. The same holds for `ne`, `in` and `notIn`, for a deny on several fields, under `every`, for guardrail denies,
|
|
390
|
+
and in the generated client. A to-one relation is nullable when one of its reference fields is, and a negated step
|
|
391
|
+
on it adds `OR <reference> IS NULL`. The startup `paths` name the operators this adds, so an app that took one away
|
|
392
|
+
fails at startup; a create-only deny, which no scope negates, names none of them.
|
|
393
|
+
- The package ships `LICENSE-ADDITIONAL-PERMISSION.md` beside `LICENSE`: PolyForm Shield 1.0.0 stays the license, and the
|
|
394
|
+
additional permission limits its Noncompete section to products that compete with Aventara Access itself.
|
|
395
|
+
- `@aventara/access/auth`: `createAccess({ betterAuth, policies, principal?, profile?, registry?, audit?, resources? })`
|
|
396
|
+
builds Better Auth with Access beside it. Its options, its instance and its blocks' types are exported from
|
|
397
|
+
`/auth` too; `@aventara/access` holds the engine alone, so nothing it declares names `better-auth`, and an
|
|
398
|
+
engine-only project type-checks without it with `skipLibCheck: false`.
|
|
399
|
+
`betterAuth` takes Better Auth's own options, exactly as `betterAuth({...})` takes them, and passes them through
|
|
400
|
+
unchanged; Access installs Better Auth's `jwt()` plugin and its own. The instance mounts nothing: mount
|
|
401
|
+
`access.handler` where you would mount Better Auth's handler. `access.api` is Better Auth's server API and
|
|
402
|
+
`access.options` the options Better Auth runs with (what `npx auth generate` reads).
|
|
403
|
+
- `policies.claims` declares the token's claims by type (`{ role: "string" }`). A token carries `sub` and the declared
|
|
404
|
+
claims only, read from the user's fields of the same name or from `policies.claimsOf`. A claim the rules read and
|
|
405
|
+
the declaration lacks is refused at construction, and is a type error when the rules are typed.
|
|
406
|
+
- The instance fits `@aventara/core`'s `AvAccessProvider` by shape: `createFramework({ ...config, access })` takes it
|
|
407
|
+
with no `policy` and no `state`. Its `state` verifies a bearer token against Better Auth's key set (read again every
|
|
408
|
+
5 minutes), or reads the session cookie from Better Auth's store, and gives `me`: the identity `{ sub, claims }`, or
|
|
409
|
+
what `principal` maps it to. A token with an undeclared claim is refused.
|
|
410
|
+
- `profile: { kind: "service" }` hands no caller a new session, and reads `me` from bearer tokens only.
|
|
411
|
+
`{ kind: "web" }` is the default.
|
|
412
|
+
- `better-auth` is an optional peer, pinned to one exact version, and loaded only when `createAccess` runs: the engine
|
|
413
|
+
works without it. Another version is refused at construction (`ACCESS_BETTER_AUTH_VERSION`).
|
|
414
|
+
- Refusals are `AccessError`s with a `code`: `ACCESS_BETTER_AUTH_VERSION`, `ACCESS_CLAIMS_UNDECLARED`,
|
|
415
|
+
`ACCESS_PLUGIN_DUPLICATE` (a plugin `createAccess` installs itself, also a type error at that plugin),
|
|
416
|
+
`ACCESS_OPTIONS_INVALID`, and the registry's (below).
|
|
417
|
+
- Depends on `jose` for token verification.
|
|
418
|
+
- Access's routes mount the way Better Auth's do, with Better Auth's own helpers: in Next.js, the route file
|
|
419
|
+
`app/api/auth/[...all]/route.ts` holds `export const { GET, POST } = toNextJsHandler(access)`
|
|
420
|
+
(`better-auth/next-js`); in Node.js, Express or NestJS, `toNodeHandler(access)` (`better-auth/node`) is mounted
|
|
421
|
+
before the body parser. In NestJS that is `app.use()` between `NestFactory.create()` and `listen()`, with a path
|
|
422
|
+
test, `request.url?.startsWith("/api/auth/") ? handler(request, response) : next()`, which works on Express and
|
|
423
|
+
Fastify alike (on Fastify, `app.use("/api/auth", …)` hands Better Auth the URL without its base path). The
|
|
424
|
+
framework's hosts mount no Access route.
|
|
425
|
+
- `@aventara/access/standalone`: `serve(access, { port, hostname, maxBodyBytes })` runs Access as its own HTTP server
|
|
426
|
+
around the same handler, with `GET /health` answering `200 {"status":"ok"}`. `port` defaults to `3000`, and `0`
|
|
427
|
+
picks a free one; `hostname` defaults to `127.0.0.1`. It resolves to `{ port, url, close() }` once the server
|
|
428
|
+
listens.
|
|
429
|
+
- `createAccess` loads `better-auth` through the package's own `#better-auth.require` import: Node.js's `require` under
|
|
430
|
+
the `node` condition, which a bundler building for Node.js also reads, so a Next.js server bundle (Turbopack or
|
|
431
|
+
webpack) and its standalone output carry Better Auth. Under any other condition (a browser, an edge runtime) it names
|
|
432
|
+
no Node.js API, so the engine bundles there too.
|
|
433
|
+
- Requires Node.js `^22.23.4 || >=24.22.1`, the Node.js security releases of 2026-10-20 (CVE-2026-105481,
|
|
434
|
+
CVE-2026-105482, CVE-2026-105483, CVE-2026-106069, CVE-2026-106072, CVE-2026-106073, CVE-2026-106074,
|
|
435
|
+
CVE-2026-111497).
|
|
436
|
+
- The policy registry: `registry: { enabled: true }`. Its tables (`accessNamespace`, `accessKey`,
|
|
437
|
+
`accessPolicyRevision`, `accessGuardrailRevision`, `accessHead`, `accessPushLog`, `accessResourceOwner`) are Better
|
|
438
|
+
Auth plugin tables:
|
|
439
|
+
Better Auth's own migration, or `npx auth generate` for an ORM's schema, creates them beside Better Auth's, and they
|
|
440
|
+
are stored through whatever database adapter `betterAuth.database` names. `access.handler` serves its routes under
|
|
441
|
+
Better Auth's base path, so every mount carries them: `GET /access/v1/namespaces/:ns`, `…/revisions`,
|
|
442
|
+
`…/revisions/:hash`, `…/head`, `…/guardrails`, and `POST …/revisions` to push. One org per server.
|
|
443
|
+
- `access.registry` is the operator's API: `createNamespace(name)` issues the namespace's push key and its read-only
|
|
444
|
+
pull key (shown in that answer only; the registry keeps their SHA-256 hashes), `rotateKey(namespace, kind)`,
|
|
445
|
+
`revokeKey(namespace, kind)` and `pushGuardrails(file)`; a name that is not a namespace name, or one the registry
|
|
446
|
+
has already, is refused (`ACCESS_NAMESPACE`). A service sends its key in the `x-access-key` header. A
|
|
447
|
+
wrong, revoked or rotated-out key, a pull key that pushes, or another namespace's key is refused
|
|
448
|
+
(`ACCESS_KEY_REFUSED`).
|
|
449
|
+
- A pushed file is stored once, in its canonical form, under its `policiesHash`, and is never updated: pushing a file
|
|
450
|
+
the registry has moves the namespace's head back to it. A stored revision that no longer hashes to its key is
|
|
451
|
+
never served (`ACCESS_REVISION_CHANGED`).
|
|
452
|
+
- Ownership at push (`ACCESS_OWNERSHIP`): a namespace's file names as its own only Resources no other namespace owns,
|
|
453
|
+
and its grants, denies and governed fields name only those. The org's guardrails file holds no grant. A file the
|
|
454
|
+
engine would refuse is refused too (`ACCESS_POLICIES_INVALID`).
|
|
455
|
+
- `registry: { enabled: false }` stays the default: no registry table, and `/access/v1/*` answers `404`.
|
|
456
|
+
- The org's guardrails: `defineGuardrails({ claims })` writes deny-only rules that central Access adds on top of
|
|
457
|
+
every namespace's own. The builder has `.denies()` and no `.grants()`, its callback hands out `deny` alone, and a
|
|
458
|
+
guardrail's options are a `name` and `if: { claims, origin }`: a `grant` in it, or a row condition (`where`,
|
|
459
|
+
`after`), is a type error. `deny(actions, resources, options?)` names whole Resources: `"*"`, or by namespace,
|
|
460
|
+
`{ campaign: ["Note"] }` or `{ chat: "*" }`. `.contract()` compiles them to an `aventara-access/guardrails@1` file
|
|
461
|
+
(`format`, the `claims` they compare, `denies`), passive JSON hashed like a policies file: `guardrailsHash(file)`
|
|
462
|
+
is the same for the same guardrails in any order. A member the format does not define is refused with a sentence
|
|
463
|
+
(`grants` and a guardrail's `where` by name), and so is a file of another format.
|
|
464
|
+
- An engine applies pulled guardrails and only narrows: `rules.engine({ guardrails })`,
|
|
465
|
+
`createAccessEngine(file, { guardrails })`, and in `createAccess`, `policies.guardrails`. Each guardrail becomes a
|
|
466
|
+
deny on the Resources it reaches: every one for `"*"`, and the file's own namespace's list; another namespace's
|
|
467
|
+
list is left alone. A Resource in the file's own list that the file lacks, or reads as another namespace's, is
|
|
468
|
+
refused, and so is a namespaced list when the file names no namespace. The governed fields, the paths and the
|
|
469
|
+
engine's type are the file's own. `createAccess` refuses guardrails that compare a claim `policies.claims` does not
|
|
470
|
+
declare (`ACCESS_CLAIMS_UNDECLARED`), and `policies` refuses a member it does not take.
|
|
471
|
+
- `policies.tokenTtl`: how long a token lives, in whole seconds from 300 to 900 (5 to 15 minutes); 900 by default.
|
|
472
|
+
A claim that must take effect soon (`banned`, `suspended`) reaches every service with the user's next token.
|
|
473
|
+
- The registry's `pushGuardrails(file)` takes the `guardrails@1` file. A `policies@1` file is refused by its format
|
|
474
|
+
(`ACCESS_POLICIES_INVALID`), a `grants` member or a guardrail with `subjects` by ownership (`ACCESS_OWNERSHIP`),
|
|
475
|
+
and a guardrail naming a namespace the registry does not have by `ACCESS_NAMESPACE`. `…/guardrails` serves the
|
|
476
|
+
file with its `guardrailsHash`. Guardrails are pushed through `access.registry` only: no namespace key can push
|
|
477
|
+
them.
|
|
478
|
+
- A deny or a governed field on a `foreign` Resource is refused, by the types, the compiler and the registry: only
|
|
479
|
+
the owner narrows its Resources; another namespace's are narrowed by the org's guardrails.
|
|
480
|
+
- The `avaccess` bin, the registry from a project. It keeps nothing of its own: every answer is the registry's, over
|
|
481
|
+
its HTTP routes. The registry is `--registry <url>` or `AVACCESS_REGISTRY_URL` (the Access server's URL; with no
|
|
482
|
+
path, Better Auth's `/api/auth` is added, as Better Auth's client does), and the key is `AVACCESS_KEY`, never a
|
|
483
|
+
flag. Exit codes: `0` done, `1` the check failed or the registry refused, `2` the command could not run.
|
|
484
|
+
- `avaccess push <rules>` pushes the namespace's policies file: a `.json` file, or the module that holds the rules
|
|
485
|
+
(`definePolicies(model, { namespace })…` or its `.contract()`, as the default export or `rules`), loaded with
|
|
486
|
+
jiti as written, TypeScript included. The namespace is the file's own.
|
|
487
|
+
- `avaccess pull [--namespace <ns>] [--out <dir>]` writes the namespace's current file and the org's guardrails
|
|
488
|
+
into the project (`access/<ns>.policies.json`, `access/guardrails.json`) and pins both hashes in
|
|
489
|
+
`avAccess.lock.json`: `{ format: "aventara-access/lock@1", namespace, policies: { path, hash }, guardrails:
|
|
490
|
+
{ path, hash } | null }`. Commit all three. The lock pins content, not a registry. A file the registry serves is
|
|
491
|
+
hashed again before it is written.
|
|
492
|
+
- `avaccess verify` holds the pulled files to the lock, offline: no registry, no network. A file edited by hand
|
|
493
|
+
fails it with exit 1 and `ACCESS_LOCK_MISMATCH`, naming the file and both hashes. Put it first in the project's
|
|
494
|
+
`aventara:prepare` script, so the build stops before anything is generated from a changed file:
|
|
495
|
+
`"aventara:prepare": "avaccess verify && aventara-prisma7-generate … && aventara-build"` (in a Next.js app, before
|
|
496
|
+
`aventara-build` the same way).
|
|
497
|
+
- `avaccess status` says, for the namespace's file and the org's guardrails, whether the lock holds the registry's
|
|
498
|
+
current revision, and exits 1 when either is behind: run it in CI.
|
|
499
|
+
- `avaccess diff [<from> [<to>]]` names the rules that changed between two revisions (each grant and deny by its id,
|
|
500
|
+
roles, governed fields, schema entries and claims). A revision is `lock`, `head`, a hash or its first seven or
|
|
501
|
+
more hex digits; the default, `lock` to `head`, is what a pull would bring.
|
|
502
|
+
- `avaccess keys issue <ns>`, `keys rotate <ns> <push|pull>` and `keys revoke <ns> <push|pull>`, with
|
|
503
|
+
`--server <module>`: the operator's API, run in the operator's own process from the Access server's module (its
|
|
504
|
+
`createAccess({ …, registry: { enabled: true } })` instance, as the default export or `access`), never over HTTP.
|
|
505
|
+
A key is printed once, by the command that issues it, and by no other command.
|
|
506
|
+
- `avaccess generate [--orm <orm>] [--out <file>] [--tsconfig <file>]` writes the generated client of a service that does not run on
|
|
507
|
+
Aventara: two TypeScript files (default `src/generated/avAccess.ts`, and `avAccess.internal.ts` beside it; a
|
|
508
|
+
custom `--out <name>.ts` writes `<name>.internal.ts`) from the pulled files the lock pins,
|
|
509
|
+
after the same check `avaccess verify` runs (a file edited by hand is refused, exit 1, and nothing is written).
|
|
510
|
+
It holds the pulled policies file as a constant and the denies the org's guardrails add to it, a copy of Access's
|
|
511
|
+
decisions, the namespace's Resource types (`ResourceName`, `ResourceRows`, `ResourceFields`), and `access`:
|
|
512
|
+
`can`, `scopeOf` and `fieldsOf`, which answer as the engine does, in Aventara's `where`. `--orm` is optional and
|
|
513
|
+
defaults to `none` (that neutral client, naming no ORM; its banner spells `--orm none` out); `--orm prisma7`
|
|
514
|
+
adds the Prisma 7 translator's `prismaWhere(resource, scope)` (a scope as a Prisma `where`; `false` matches no row),
|
|
515
|
+
`readable(question)` (the fields a read shows on every row, as a Prisma `select`; a governed field opened on some
|
|
516
|
+
rows only is left out, and no relation is selected) and `prismaExists(prisma, resource, where)` (`can`'s `exists`
|
|
517
|
+
through the service's Prisma client). `avAccess.ts` exports `access` and the client's types only (`ResourceName`,
|
|
518
|
+
`ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `ScopeQuestionOf`, `FieldsQuestionOf`,
|
|
519
|
+
`PrismaClientOf`, `PrismaSelectOf`, `PrismaWhere`, the last three with `--orm prisma7` only); the copied
|
|
520
|
+
decisions stay in `avAccess.internal.ts`, which it imports with the extension the project's `tsconfig.json`
|
|
521
|
+
asks for, by Prisma 7's `prisma-client` rules (`.js`, none, or `.ts`). The tsconfig is the nearest one above the
|
|
522
|
+
generated file (a solution-style root's referenced project that includes it), or the one `--tsconfig` names; a
|
|
523
|
+
project with none, or one that cannot be read, is refused (exit 2) and nothing is written, as the framework's
|
|
524
|
+
`avclient` refuses a JavaScript project. Neither imports
|
|
525
|
+
anything else: no `@aventara` package and no Prisma module, so the service needs no Access version at runtime. The same pulled files, translator and Access version give the
|
|
526
|
+
same bytes. Regenerate after each pull, for example in the project's `postinstall`:
|
|
527
|
+
`"avaccess verify && avaccess generate --orm prisma7"`. The scope covers only the queries it is passed to; a
|
|
528
|
+
Prisma `include` is not scoped.
|
|
529
|
+
- `avaccess schema --orm prisma7 [--schema <path>] [--out <file>]` reads the service's Prisma schema (default
|
|
530
|
+
`prisma/schema.prisma`; a folder of `.prisma` files reads as one) into the `AccessSchema` its rules are authored
|
|
531
|
+
against, written as a module (default `src/generated/avAccess.schema.ts`) exporting `model`:
|
|
532
|
+
`definePolicies(model, { namespace })`, as an Aventara app does with its discovered model. It reads the same
|
|
533
|
+
facts the framework's Prisma 7 discovery reads (fields, enums, relations with their reference pairs and inverse,
|
|
534
|
+
identifiers in order) and leaves out, and lists, what it cannot model: an `Unsupported(...)` column, a composite
|
|
535
|
+
`type`, a `view`, an `@ignore`d field or model.
|
|
536
|
+
- `--orm` names the translator, `<orm><major>`: required by `schema` (`prisma7` is the first), optional for
|
|
537
|
+
`generate` (default `none`). Drizzle follows once Drizzle ORM v1 is stable.
|
|
538
|
+
- `avaccess serve [--config <file>] [--port <port>] [--hostname <host>] [--max-body-bytes <bytes>] [--migrate]` runs
|
|
539
|
+
the standalone host, on `127.0.0.1` unless `--hostname` says otherwise, from
|
|
540
|
+
a configuration file: `access.config.ts` in the working directory (or `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`), or
|
|
541
|
+
the file `--config` names, loaded with jiti the way `prisma.config.ts` is, so it needs no build step. Its default
|
|
542
|
+
export (or its `access` export) is the `createAccess({...})` instance; `serve()` serves it, with `GET /health`,
|
|
543
|
+
until SIGTERM or SIGINT, when it closes the server and exits 0. The port is `--port`, else `PORT`, else `3000`.
|
|
544
|
+
Better Auth's migration (the one `npx auth migrate` runs) runs on start only with `--migrate` or
|
|
545
|
+
`AVACCESS_MIGRATE=true` (`1` also; `false`, `0` or unset do not). Without it, a database Better Auth migrates
|
|
546
|
+
itself (a `pg` Pool, a MySQL pool, SQLite) is checked before the server listens: one that lacks a table, column
|
|
547
|
+
or index the options need is refused with exit 1 and the list of what it lacks. A database behind an ORM's
|
|
548
|
+
adapter (Prisma, Drizzle) is the ORM's to migrate (`npx auth generate`, then its migration): it is served as it
|
|
549
|
+
is, and `--migrate` refuses it with exit 2.
|
|
550
|
+
- The container image of the standalone host, `aventara/access-server:<version>`, built from the package's tarball
|
|
551
|
+
(`docker/access-server/Dockerfile`; the build context holds the Dockerfile, its two scripts, its `package.json` and
|
|
552
|
+
`package-lock.json`, and the tarball, nothing else). It holds `@aventara/access` and the Better Auth version the
|
|
553
|
+
package pins, under `/srv/node_modules`, every package installed by `npm ci` from the lockfile with no install
|
|
554
|
+
script run, on Node.js 24, and runs `avaccess serve --hostname 0.0.0.0` as user `node` in `/srv/access`; arguments
|
|
555
|
+
after the image name go to `avaccess serve`. The operator mounts `access.config.ts` into `/srv/access`, read-only:
|
|
556
|
+
its default export is the `createAccess({...})` instance, and it reads its secrets from the environment
|
|
557
|
+
(`process.env.DATABASE_URL` and the like), passed with `docker run -e`. What the configuration imports beyond Access
|
|
558
|
+
and Better Auth (the database driver, a Better Auth plugin in its own package) is installed at build time, in an
|
|
559
|
+
image built `FROM` this one that copies the operator's `package.json` and `package-lock.json` into `/srv/access` and
|
|
560
|
+
runs `/srv/bin/access-deps` (`npm ci`, no install script, no peers: a plugin's `better-auth` peer is the image's).
|
|
561
|
+
A file that names `@aventara/access` or `better-auth` is refused, since the image provides them, and so is one
|
|
562
|
+
without its lockfile. Nothing is installed at start.
|
|
563
|
+
|
|
564
|
+
```bash
|
|
565
|
+
docker run -p 3000:3000 \
|
|
566
|
+
-v ./access.config.ts:/srv/access/access.config.ts:ro \
|
|
567
|
+
-e DATABASE_URL=postgresql://... -e BETTER_AUTH_SECRET=... \
|
|
568
|
+
aventara/access-server:<version> --migrate
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`PORT` (default `3000`) sets the port, and the image's health check calls `GET /health` on it. `docker stop` drains
|
|
572
|
+
and ends it cleanly. Pass `--migrate` (or `-e AVACCESS_MIGRATE=true`) on the start that should create or update the
|
|
573
|
+
tables; without it, an empty database stops the container with exit 1 and the list of what is missing. Nothing
|
|
574
|
+
pushes the image to a registry yet.
|
|
575
|
+
- `avAccess.lock.json` at start: `createAccessEngine(file, { guardrails, lock })`, `rules.engine({ guardrails, lock })`
|
|
576
|
+
and `createAccess({ policies: { …, lock } })` refuse to start (`ACCESS_LOCK_MISMATCH`) unless the guardrails hash to
|
|
577
|
+
the lock's pin and are given exactly when it pins some, and a policies file handed over (not the typed rules, which
|
|
578
|
+
are the project's own source) hashes to its own; the rules' namespace must be the lock's. Read the lock and the
|
|
579
|
+
pulled files as JSON; `AccessLock` (and `AccessLockPin`) types the lock with `format` as a string, so an imported
|
|
580
|
+
JSON file fits it.
|
|
581
|
+
- Depends on `jiti`, which the bin loads only to read a project's modules, and on `get-tsconfig` (pinned exactly,
|
|
582
|
+
the version Prisma 7 and the framework's `avclient` read tsconfigs with), which `avaccess generate` loads only to
|
|
583
|
+
read the project's tsconfig.
|
|
584
|
+
- The audit log: `audit: true` or `audit: { sink }` (off by default). Each event is an `AccessAuditEvent`:
|
|
585
|
+
`{ at, kind: "sign-in", userId, origin, impersonatedBy? }`,
|
|
586
|
+
`{ at, kind: "sign-in-failed", userId: null, route, reason, target?, origin, suppressed?, windowStart?, windowEnd? }`,
|
|
587
|
+
`{ at, kind: "sign-out", userId, origin, impersonatedBy? }`, `{ at, kind: "token", userId, origin, impersonatedBy? }`
|
|
588
|
+
or `{ at, kind: "decision", userId, resource, action, allowed, reason, origin, suppressed?, windowStart?,
|
|
589
|
+
windowEnd? }`.
|
|
590
|
+
- `audit: true` writes each event to the `accessAudit` table, a Better Auth plugin table: `npx auth generate` writes
|
|
591
|
+
it into an ORM's schema (Prisma: `model AccessAudit`) and `npx auth migrate` creates it, beside Better Auth's
|
|
592
|
+
tables, through the app's own Better Auth adapter. Better Auth refuses to start while the table is missing.
|
|
593
|
+
- `audit: { sink }` hands each event to the app's function instead, and the table is not declared at all.
|
|
594
|
+
- A sign-in is a session Better Auth created: a sign-in, or a sign-up that signs the user in. Its `origin` is
|
|
595
|
+
`client` through Access's routes and `application` when the server calls Better Auth's API itself.
|
|
596
|
+
- A decision is a `can` answer: a request's admission (may this caller ever do this action on this Resource?) is
|
|
597
|
+
one event, allowed or refused, however many rows the request reads or writes; a row question (the row a create
|
|
598
|
+
inserts, or an update leaves) adds an event only when the rules refuse that row. `scopeOf`, `fieldsOf` and
|
|
599
|
+
`explain` log nothing. `reason` names the deciding rule: `grant:<id>`, `deny:<id>`, or `no grant`. `userId` is
|
|
600
|
+
the `sub` of the token or session the principal came from, `null` for an anonymous caller.
|
|
601
|
+
- An event holds no token, password, cookie, key or header.
|
|
602
|
+
- The request waits for the write or the sink. A sink that throws, or a write that fails, does not fail the
|
|
603
|
+
request: Better Auth's logger warns and the request goes on.
|
|
604
|
+
- Better Auth's tables as Resources of the app's rules: `resources: { users?: boolean, members?: boolean }`
|
|
605
|
+
(type `AccessResourcesOptions`). The engine runs built-in rules over every Better Auth table in the rules' schema
|
|
606
|
+
(its plugins' and Access's own included, matched by the model name Better Auth's generator writes, the app's
|
|
607
|
+
`modelName` and `fieldName` honoured). They only narrow:
|
|
608
|
+
- `users: true` opens `user`, `session` and `account`, `members: true` the organization plugin's `member` and its
|
|
609
|
+
`organization` (read only). An opened table is read as the rules grant. Every other table of Better Auth's
|
|
610
|
+
(`verification`, `jwks`, the organization plugin's `invitation`, `twoFactor`, `deviceCode`, Access's own) stays
|
|
611
|
+
closed: every action is denied, and a key that is off leaves its tables closed too.
|
|
612
|
+
- A write through Aventara skips Better Auth's hooks, its cookie cache and its secondary storage, so it is limited
|
|
613
|
+
to what those do not guard: on `user`, an update of `role`; on `member`, a create (the membership's fields), an
|
|
614
|
+
update of `role`, and a delete. Nothing else is written.
|
|
615
|
+
- Secret columns are never in an answer, whatever a grant says, so a request that selects, filters or sorts by one
|
|
616
|
+
is refused: `account.password`, `accessToken`, `refreshToken`, `idToken`; `session.token`; `verification.identifier`
|
|
617
|
+
and `value`; `jwks.privateKey`; `twoFactor.secret` and `backupCodes`; `deviceCode.deviceCode` and `userCode`;
|
|
618
|
+
`accessKey.keyHash`. Exempt them from your Contracts too, or govern them, so a read that names no field leaves
|
|
619
|
+
them out instead of being refused.
|
|
620
|
+
- Checked at startup, the first read of `access.policy` (the framework's construction): a key whose tables Better
|
|
621
|
+
Auth lacks (`members` without `organization()`), or that the rules' schema lacks (run `npx auth generate`, then
|
|
622
|
+
regenerate the model), is refused (`ACCESS_RESOURCE_MISSING`); so is a grant on a closed table, or a grant of a
|
|
623
|
+
write the table never takes (`ACCESS_OPTIONS_INVALID`). The instance itself still builds, so `npx auth generate`
|
|
624
|
+
loads the configuration before the schema has the tables.
|
|
625
|
+
- `explain` and the audit log name a built-in deny `better-auth:<Resource>` (a closed table) or
|
|
626
|
+
`better-auth:<Resource>/<actions>` (the writes an opened one never takes).
|
|
627
|
+
- `access.policy` is built on its first read and, like `access.api`, is not enumerable.
|
|
628
|
+
- Type the rules with the principal `createAccess` hands them: `definePolicies(model).principal<AccessIdentity<typeof
|
|
629
|
+
claims>>()`, where `claims` is the object you pass as `policies.claims` (declared `as const`). Without
|
|
630
|
+
`.principal<…>()` the rules have no principal type, so `me.sub` in a `where` and `claims` in a role are type errors.
|
|
631
|
+
With it, `createAccess` checks that every claim the rules read is declared in `policies.claims`, and `principal` is
|
|
632
|
+
optional, since `me` is then the verified identity `{ sub, claims }` itself. Rules typed with your own profile type
|
|
633
|
+
instead (`.principal<Profile>()`) make `principal` required: it maps the identity to that profile.
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
// src/access/rules.ts
|
|
637
|
+
import { type AccessIdentity, definePolicies } from "@aventara/access";
|
|
638
|
+
import { model } from "../aventara/generated/discovery.artifact";
|
|
639
|
+
|
|
640
|
+
export const claims = { role: "string", suspended: "boolean" } as const;
|
|
641
|
+
|
|
642
|
+
export const rules = definePolicies(model, { namespace: "blog" })
|
|
643
|
+
.principal<AccessIdentity<typeof claims>>()
|
|
644
|
+
.grants(({ grant, me }) => [
|
|
645
|
+
grant("signedIn", "create", "Post", { where: { authorId: me.sub } }),
|
|
646
|
+
]);
|
|
647
|
+
```
|
|
648
|
+
- Install Better Auth at the exact version Access is built against, so a later `npm install` cannot move it:
|
|
649
|
+
`npm i better-auth@1.7.12 --save-exact`. Another version is refused at construction (`ACCESS_BETTER_AUTH_VERSION`).
|
|
650
|
+
- Hand the instance to the framework where your host takes its configuration:
|
|
651
|
+
- Next.js (`@aventara/next`): add `access` to the configuration in `src/aventara/config.ts`, which `server.ts`
|
|
652
|
+
spreads into `Aventara({ ...aventaraConfig, adapter, prebuilt })`. In `config.ts` it is what `aventara-build`
|
|
653
|
+
compiles the prebuilt Contracts with; given only in `server.ts`, the prebuilt Contracts no longer match, and the
|
|
654
|
+
framework warns `AVENTARA_PREBUILT_STALE` and compiles them at startup. `aventara-build` runs `config.ts` at build
|
|
655
|
+
time, so the module that calls `createAccess` must load there: with Prisma, the build's environment needs the
|
|
656
|
+
`DATABASE_URL` your database client reads (`aventara-build` does not read `.env`), or build the client lazily.
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
// src/aventara/config.ts
|
|
660
|
+
import { access } from "../access/access";
|
|
661
|
+
|
|
662
|
+
export const aventaraConfig = {
|
|
663
|
+
api: "/api",
|
|
664
|
+
access,
|
|
665
|
+
// exposed, resources, diagnostics … as the scaffold wrote them
|
|
666
|
+
} as const;
|
|
667
|
+
```
|
|
668
|
+
- NestJS (`@aventara/nest`): add `access` to the `config` your `AventaraModule.forRootAsync` factory returns, and
|
|
669
|
+
mount `access.handler` in `main.ts` with `toNodeHandler(access)`, as above.
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
AventaraModule.forRootAsync({
|
|
673
|
+
imports: [PrismaModule],
|
|
674
|
+
inject: [PrismaService],
|
|
675
|
+
useFactory: async (prisma: PrismaService) => ({
|
|
676
|
+
config: {
|
|
677
|
+
...aventaraConfig,
|
|
678
|
+
access,
|
|
679
|
+
adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
|
|
680
|
+
},
|
|
681
|
+
}),
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
- Anywhere else: `createFramework({ ...config, access })`.
|
|
685
|
+
- The audit log's decision `userId` is set under Next.js too. Next evaluates the app's `access.ts` once per bundle, so
|
|
686
|
+
one process holds several Access instances, and `@aventara/next` shares one framework (with one bundle's
|
|
687
|
+
`access.policy`) across them, while a route's `state` comes from its own bundle's instance. The `sub` now travels on
|
|
688
|
+
the principal `state` returns (under a non-enumerable `Symbol.for` key: a spread, `JSON.stringify` or `Object.keys`
|
|
689
|
+
of `me` does not show it), so any instance's engine reads it. Each instance also keeps its own copy of Better Auth's
|
|
690
|
+
key set, read once from your database on its first token; that is expected under Next.js and costs one read per
|
|
691
|
+
bundle.
|
|
692
|
+
- A pulled file imported as JSON fits as it is: `import guardrails from "../access/guardrails.json"` and a pulled
|
|
693
|
+
policies file go straight into `policies.guardrails` and `policies.rules` (and `createAccessEngine(file, {
|
|
694
|
+
guardrails })`), with no `as unknown as`. TypeScript widens a JSON import's literals, so these options take the
|
|
695
|
+
file's outline (`AccessGuardrailsJson`, `AccessPoliciesJson`) and the engine checks the rest when it is built, as
|
|
696
|
+
it checks a typed file. A policies file passed as guardrails is still a type error. `defineGuardrails(…).contract()`
|
|
697
|
+
and `.contract()` still return the strict `AccessGuardrails` and `AccessPolicies`.
|
|
698
|
+
- A registry-only server leaves `policies` out: `createAccess({ betterAuth, registry: { enabled: true } })`. It has
|
|
699
|
+
no rules of its own, so its engine denies every question and its tokens carry `sub` alone. Every other instance
|
|
700
|
+
needs `policies`; leaving it out is a type error and is refused with a sentence that says what to pass.
|
|
701
|
+
- `grant(…, { fields: ["Note.body"] })` on a Resource with no governed field is a type error that says what to do:
|
|
702
|
+
`no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it`.
|
|
703
|
+
- `ACCESS_PLUGIN_DUPLICATE` and its type error name the Access plugin by what it is (plugin id `aventara-access`), not
|
|
704
|
+
by a factory this release does not export.
|
|
705
|
+
- `createAccess` without `betterAuth` says the option is missing.
|
|
706
|
+
- `avaccess pull` with no lock and no `--namespace` pulls the namespace the key belongs to. The registry names it at
|
|
707
|
+
`GET /access/v1/key` (with the key in `x-access-key`: `{ namespace, kind }`).
|
|
708
|
+
- `fieldsOf` refuses a question it cannot answer as asked, with `ACCESS_QUESTION_INVALID`: one without
|
|
709
|
+
`access: "read"` or `access: "write"`, a read with an `action`, or a write whose `action` is not `create` or
|
|
710
|
+
`update`. Such a question was answered as a read, or with no field.
|
|
711
|
+
- The README starts from `npx @aventara/cli@1.0.0-rc.9 new <app> --orm prisma7 --db postgresql`, and pins Better
|
|
712
|
+
Auth's CLI as `npx auth@1.7.12`. It shows the scaffold's `Int` user id kept with Better Auth's own
|
|
713
|
+
`advanced.database.generateId: "serial"` (and the principal's `id: Number(sub)`), sign-up and sign-in with `curl`
|
|
714
|
+
and the `Origin` header Better Auth requires, reading the audit log through the app's ORM, the `fieldsOf` question's
|
|
715
|
+
shape, that `avaccess pull` writes `access/guardrails.json` only when the registry has guardrails, and the
|
|
716
|
+
environment `avaccess keys` needs (`BETTER_AUTH_URL` with the rest).
|
|
717
|
+
- Identity, tokens and sessions, hardened after the identity audit:
|
|
718
|
+
- Better Auth's tables are recognized under `usePlural` names (`Users`, `Accounts`, `Jwkss`) and hand-written ones,
|
|
719
|
+
where they were left open to every grant (see below for how).
|
|
720
|
+
- A declared claim read by the default mapping from a user field without `input: false` (one the user sets at
|
|
721
|
+
sign-up or with `/update-user`, a plugin's field included) is refused at construction (`ACCESS_OPTIONS_INVALID`).
|
|
722
|
+
A user could make themselves `role: "admin"`.
|
|
723
|
+
- The service profile matches the routes that start a session by Better Auth's endpoint path, whatever the URL's
|
|
724
|
+
spelling, `basePath` or `baseURL` (a `baseURL` with a path, `basePath: "/"`, a trailing slash let sign-up
|
|
725
|
+
through), covers the plugins' sign-in routes (`/phone-number/verify` signed a caller in), and answers `404` to any
|
|
726
|
+
request that creates a session anyway, revoking it before a cookie or token leaves.
|
|
727
|
+
- `state` reads the session cookie from Better Auth's store, never its cookie cache, so a revoked session is gone at
|
|
728
|
+
once; with `session.cookieCache` on, a token is issued only for a session the store still has.
|
|
729
|
+
- The key set is read again every 5 minutes (the last good set kept when a read fails), so a key removed from it
|
|
730
|
+
stops verifying within 5 minutes, not only when a token with an unknown key arrives.
|
|
731
|
+
- Tokens carry, and are verified against, one fixed issuer and audience: the origin of the base URL (`baseURL`,
|
|
732
|
+
`BETTER_AUTH_URL`, or a dynamic `baseURL`'s `fallback`). With none, `createAccess` refuses; an unset base URL left
|
|
733
|
+
tokens unchecked for issuer and audience. An audience list that merely includes it is refused.
|
|
734
|
+
- A token must carry `iat`, be no older than `tokenTtl` and not issued in the future (5 seconds of skew allowed).
|
|
735
|
+
- The audit log records refused sign-ins (route and reason, nothing the caller sent), sign-outs, issued tokens and
|
|
736
|
+
the admin behind an impersonated session (`impersonatedBy`). Anonymous refusals are logged once a minute per origin
|
|
737
|
+
and reason, with the repeats counted in `suppressed`. The `accessAudit` table gains `route`, `impersonatedBy` and
|
|
738
|
+
`suppressed`: run `npx auth@1.7.12 generate` (or `migrate`) again.
|
|
739
|
+
- A claim refused for its type names the type it got (`an object`), never its value.
|
|
740
|
+
- `ACCESS_PLUGIN_DUPLICATE` also refuses `jwt()` or the Access plugin under another case or spacing of its id, and a
|
|
741
|
+
plugin that defines one of `jwt()`'s endpoints (`getJwks`, `getToken`, `signJWT`, `verifyJWT`, `/jwks`, `/token`).
|
|
742
|
+
- Any `Authorization` header means `state` reads no session cookie: a header that is not a valid bearer token gives
|
|
743
|
+
`me: null` instead of the cookie's user.
|
|
744
|
+
- A `principal` that returns one object for two users is refused (`ACCESS_OPTIONS_INVALID`), so the audit cannot name
|
|
745
|
+
the wrong user.
|
|
746
|
+
- A session cookie set to `sameSite: "none"` is refused unless `trustedOrigins` is set; with it, `state` reads the
|
|
747
|
+
cookie only from a trusted `Origin` (or a request the browser does not mark cross-site).
|
|
748
|
+
- The README states what is not revoked: a bearer token outlives a sign-out or a deleted user for at most
|
|
749
|
+
`tokenTtl`. It also states the cookie's `SameSite=Lax` stance.
|
|
750
|
+
- Hardened again after the re-audit of the identity fixes:
|
|
751
|
+
- Which Resource is which of Better Auth's tables is known, not guessed. On Better Auth's Prisma adapter, it is the
|
|
752
|
+
model the adapter reads (exact: a lowercase or plural model is found too). On any other adapter, the app names each
|
|
753
|
+
table in `resources.authTables` (a Resource, or `null`), required when the rules have a schema; without it,
|
|
754
|
+
`createAccess` refuses (`ACCESS_OPTIONS_INVALID`). Better Auth's tables under `@@map`'d names, and secret columns
|
|
755
|
+
under `@map`'d names, were left open to every grant. The map is held to the schema: a table's Resource must have
|
|
756
|
+
every column of it under Better Auth's `fieldName`, and a table mapped to `null` (not in this schema) must not
|
|
757
|
+
match an unnamed Resource column for column, nor have its secret column there beside another of its columns. On Prisma's adapter, a schema that differs from the adapter only in
|
|
758
|
+
case or plural is refused. An opened table whose secret column is not found under Better Auth's field name is
|
|
759
|
+
refused. The guesses by name (singular and plural, case, look-alike columns) are gone.
|
|
760
|
+
- The audit log's repeat window hides nothing: the count of an anonymous refusal's repeats is logged as a summary
|
|
761
|
+
(`suppressed`, `windowStart`, `windowEnd`) when the minute closes, on the next event of any kind or by a timer,
|
|
762
|
+
not on the next repeat. A refused sign-in carries `target`, an HMAC of the identifier keyed by Better Auth's
|
|
763
|
+
secret, of the identifier the route reads (the email, username or phone number its sign-in route looks up,
|
|
764
|
+
normalized as it is), and is counted per target; a sign-in logs its target's count first, so a guessed password
|
|
765
|
+
follows its failures. At most 10,000 windows stay open; past that, the oldest closes early, so every target keeps
|
|
766
|
+
its own rows.
|
|
767
|
+
The `accessAudit` table gains `target`, `windowStart` and `windowEnd`.
|
|
768
|
+
- `token` events are logged for `GET /token` only, not for `/get-session`'s `set-auth-jwt` header.
|
|
769
|
+
- The service profile refuses the session row itself (Better Auth's `session.create` before-hook) on a request
|
|
770
|
+
through the handler, so no session exists even while a route runs; answers `404` to a response that sets the
|
|
771
|
+
session cookie of an existing session; and `access.handler` strips any session cookie (a `multiSession()` one
|
|
772
|
+
too) from what Better Auth answers, after every plugin's `onResponse`, a route's own `Response` included.
|
|
773
|
+
- On a request that may change something, a session cookie counts only from a trusted `Origin`, or a
|
|
774
|
+
`Sec-Fetch-Site` of `same-origin` or `none`, whatever `sameSite` is (the method the client sent, Express's
|
|
775
|
+
`originalMethod` before a method override). A request with neither header is still read.
|
|
776
|
+
- The README warns that `claimsOf` is not checked for fields a user can set, and that apps sharing an origin and a
|
|
777
|
+
database accept each other's tokens.
|
|
778
|
+
|
|
779
|
+
### Engine security fixes (audit of 2026-10-23)
|
|
780
|
+
|
|
781
|
+
- An update is checked on the row it leaves by default. That row must still meet the `where` and the `after` of one
|
|
782
|
+
update grant, and no update deny may match it, whether or not a grant has an `after`. A grant
|
|
783
|
+
`where: { authorId: me.id }` no longer lets the author hand the row to someone else. `afterChecks` now lists every
|
|
784
|
+
Resource whose update grants read the row (a `where`, an `after`, a role held on a row) or whose update denies have
|
|
785
|
+
a `where`, so Aventara asks `can` about each changed row there, and refuses those updates where it has no
|
|
786
|
+
interactive transaction. `can({ action: "update", row })` (a generated client's too) evaluates the update grants
|
|
787
|
+
and denies on the row; it no longer answers `true` on a Resource without an `after` grant.
|
|
788
|
+
- A deny that reads a `me` value the principal lacks matches, whatever `not` or `NOT` surrounds it. A deny's `if` that
|
|
789
|
+
compares a claim the principal lacks holds, except a comparison with `true`: `banned: true` leaves alone a token with
|
|
790
|
+
no `banned`, and `verified: false` denies a token with no `verified`. Grants are unchanged: a missing value or claim
|
|
791
|
+
matches no grant.
|
|
792
|
+
- `can` compares by the field's type: a decimal by value (a decimal object, a string and a number alike), a datetime
|
|
793
|
+
by instant, a bigint exactly. A row value of another type is unknown: no grant matches it, and a deny matches. A
|
|
794
|
+
`policies@1` schema entry gains an optional member, `types`, which `.contract()` writes for its `bigint`,
|
|
795
|
+
`datetime` and `decimal` fields; a rule's literal is written in its field's canonical form (`toISOString()`, a
|
|
796
|
+
decimal without exponent or extra zeros, a bigint past a safe integer as its digits).
|
|
797
|
+
- Only a plain object is an operator object: a `Date` literal compiles to its instant (it compiled to `true`), and
|
|
798
|
+
another object, or `{}`, is refused.
|
|
799
|
+
- The engine checks every member of a policies file it is handed, as strictly as the guardrails checker: the members
|
|
800
|
+
of each rule and condition, the comparison (`eq`, `ne`, `in`, `notIn`), a list for `in` and `notIn` only (a string
|
|
801
|
+
is refused), finite literals in their field's form, a claim path of one declared claim, and a non-empty `and`.
|
|
802
|
+
`NaN` and `Infinity` are refused in policies and guardrails files, and the canonical writer throws on them, so no
|
|
803
|
+
two files share a hash.
|
|
804
|
+
- A global role with no claim (`{ global: { claims: {} } }`), which every principal held, is refused.
|
|
805
|
+
- `write.except` naming a relation locks its reference fields, as naming a reference field already locked its
|
|
806
|
+
relation. `.govern()` naming a relation without its reference fields, or the reverse, is refused.
|
|
807
|
+
- A grant on `"*"` reaches only the file's own Resources: never one its schema does not list.
|
|
808
|
+
- The engine runs from its own frozen copy of the file, taken before the lock check.
|
|
809
|
+
- `requireLock: true` (`policies.requireLock`, or the engine's option) refuses to build without a lock
|
|
810
|
+
(`ACCESS_LOCK_MISMATCH`). The lock stays optional; set this in production.
|
|
811
|
+
- The principal is any object, an instance of a class included; an array, a function or a primitive is anonymous.
|
|
812
|
+
An empty-string `me` value counts as missing.
|
|
813
|
+
- `can` never reads a relation carried on the row (`author: { … }`, `tags: [ … ]`): a relation condition is always
|
|
814
|
+
asked through the row's identifier or reference fields and `exists`. A forged relation object no longer satisfies a
|
|
815
|
+
create, an update or a read.
|
|
816
|
+
- A datetime literal must read back as written: `2020-02-30`, hour 24 and digits past the millisecond are refused,
|
|
817
|
+
and such a row value is unknown.
|
|
818
|
+
- The checker refuses a Resource or field name that is no identifier (or is `__proto__`), an identifier, reference
|
|
819
|
+
field or relation target the schema does not list, a decimal over 1,000 characters, and a deny id that starts with
|
|
820
|
+
`guardrail:` or `better-auth:`. A translated `where` writes each key as an own member.
|
|
821
|
+
- On an updated row, a to-one step whose reference fields the row carries is asked on the row those values name,
|
|
822
|
+
as on a created row: a generated client asking before the write no longer reads the database's old reference
|
|
823
|
+
(moving a row into another tenant was allowed).
|
|
824
|
+
- A generated client compiles when a relation leads to a Resource the file does not list: that relation is typed
|
|
825
|
+
`unknown`.
|
|
826
|
+
- With `requireLock`, typed rules are held to the lock's policies hash. The engine's `afterChecks` and `paths` are
|
|
827
|
+
frozen.
|
|
828
|
+
- Through Aventara, a rule comparing a `datetime`, `decimal` or `bigint` field still answers `A3000` (the framework
|
|
829
|
+
validates a policy's `where` against typed values); it fails closed until the framework's seam changes.
|
|
830
|
+
- Every lookup of a name from a file, a row or a principal reads own members only: `constructor` names no Resource,
|
|
831
|
+
and an inherited claim is no claim.
|
|
832
|
+
- A generated client's `fieldsOf` refuses a malformed question, as the engine does.
|
|
833
|
+
- The README states that string comparisons in `can` are exact, so a case- or accent-insensitive collation can make
|
|
834
|
+
`scopeOf` and `can` disagree, and that a `where` matching no row is `{ OR: [] }`.
|
|
835
|
+
|
|
836
|
+
### Registry, CLI and host security fixes (audit of 2026-10-23)
|
|
837
|
+
|
|
838
|
+
- The CLI follows no redirect (`redirect: "manual"`): a `3xx` from the registry stops it with exit 2, and the key and a
|
|
839
|
+
pushed file are sent to no other address.
|
|
840
|
+
- The registry's URL is `https:`; `http:` is refused except on a loopback address (`localhost`, `127.0.0.0/8`,
|
|
841
|
+
`[::1]`), and so is a URL with a user name or password.
|
|
842
|
+
- `avaccess pull` refuses, writing nothing, a pull that would drop the guardrails the lock pins (the registry serves
|
|
843
|
+
none), and a file of another namespace than the one asked for. A hash the registry names is checked to be one. A
|
|
844
|
+
file is held to its hash, not to who wrote it: signed revisions are a follow-up.
|
|
845
|
+
- Ownership is checked and claimed in the push's own transaction, against a new table, `accessResourceOwner`, whose
|
|
846
|
+
unique Resource name refuses a second claim made at the same moment: two namespaces pushing the same new Resource
|
|
847
|
+
at once were both accepted.
|
|
848
|
+
- `registry.ownership`: `"first-push"` (the default) or `"assigned"`. The operator's `access.registry.assignResource(
|
|
849
|
+
resource, namespace)` gives a Resource to a namespace, whoever named it first, and `releaseResource(resource)` takes
|
|
850
|
+
it back. With `"assigned"`, a push that names an unassigned Resource as its own is refused.
|
|
851
|
+
- `serve()` caps a request body at `maxBodyBytes` (1 MiB by default; `avaccess serve --max-body-bytes`) before any of
|
|
852
|
+
it is handed on, with `413`, and checks a registry write's key before it reads the body. The registry refuses a
|
|
853
|
+
policies file over 1 MiB (`ACCESS_POLICIES_INVALID`, `413`) on every mount, and a body it cannot measure (`400`).
|
|
854
|
+
- `serve()` reads the client's address from the socket. A forwarded-for header is kept only from a proxy Better
|
|
855
|
+
Auth's `advanced.ipAddress.trustedProxies` names, and replaced with the socket's address otherwise, so Better Auth's
|
|
856
|
+
rate limit counts each client on its own: a spoofed `X-Forwarded-For` bought a fresh bucket, and every client
|
|
857
|
+
shared one without it. `avaccess serve` turns Better Auth's rate limit on whatever `NODE_ENV` says, unless the
|
|
858
|
+
configuration sets `betterAuth.rateLimit.enabled`.
|
|
859
|
+
- The push log writes a row only for a push made with a key the registry has; pushes with no key, or one that is not
|
|
860
|
+
the registry's, add up in one row per namespace per minute (`accessPushLog.count`).
|
|
861
|
+
- A namespace has one live key of each kind, held by a unique slot (`accessKey.slot`): two rotations at the same
|
|
862
|
+
moment left two live keys.
|
|
863
|
+
- Another namespace's key is refused as a namespace the registry does not have, with the same `401` and message, and
|
|
864
|
+
an ownership refusal never names the namespace that owns the Resource.
|
|
865
|
+
- The CLI prints no control character a registry sends.
|
|
866
|
+
- A revision's `:hash` that is not `sha256:` and 64 hex digits answers `404` (a NUL byte answered `500` on
|
|
867
|
+
PostgreSQL).
|
|
868
|
+
- `close()` and `docker stop` stop accepting, let requests in flight finish for up to 10 seconds, then end the rest.
|
|
869
|
+
- `serve()` and `avaccess serve` listen on `127.0.0.1` by default; the image passes `--hostname 0.0.0.0`. The README
|
|
870
|
+
says to terminate TLS in front of it.
|
|
871
|
+
- A lock's paths refuse a drive letter, a backslash and a control character. `pull` and `generate` refuse a path whose
|
|
872
|
+
directory leads out of the project through a symbolic link, and create their temporary files new (`wx`). Paths and
|
|
873
|
+
the command line written into the generated client's comments cannot end a comment.
|
|
874
|
+
- The image installs from a lockfile with `npm ci --ignore-scripts`; `access-deps` runs at build time, from the
|
|
875
|
+
operator's lockfile, and a container started with a `package.json` its image did not install refuses to start.
|
|
876
|
+
- A registry list longer than 10,000 rows is refused, not cut short.
|
|
877
|
+
- The README's registry server no longer turns on open sign-up, and says what `emailAndPassword` opens.
|