@aventara/access 0.0.0-stage → 0.1.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +831 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2439 -2
- package/dist/access/access-options.d.ts +66 -0
- package/dist/access/access-options.js +5 -0
- package/dist/access/access.error.d.ts +35 -0
- package/dist/access/access.error.js +11 -0
- package/dist/access/access.factory.d.ts +273 -0
- package/dist/access/access.factory.js +440 -0
- package/dist/access/better-auth.hardening.d.ts +120 -0
- package/dist/access/better-auth.hardening.js +420 -0
- package/dist/access/better-auth.loader.d.ts +67 -0
- package/dist/access/better-auth.loader.js +65 -0
- package/dist/access/better-auth.range.d.ts +9 -0
- package/dist/access/better-auth.range.js +28 -0
- package/dist/access/better-auth.require.d.ts +90 -0
- package/dist/access/better-auth.require.js +6 -0
- package/dist/access/better-auth.require.node.d.ts +7 -0
- package/dist/access/better-auth.require.node.js +52 -0
- package/dist/access/claims.mapper.d.ts +79 -0
- package/dist/access/claims.mapper.js +152 -0
- package/dist/access/key-set.cache.d.ts +10 -0
- package/dist/access/key-set.cache.js +63 -0
- package/dist/access/policies-block.checker.d.ts +64 -0
- package/dist/access/policies-block.checker.js +138 -0
- package/dist/access/principal.subject.d.ts +12 -0
- package/dist/access/principal.subject.js +60 -0
- package/dist/access/profile.policy.d.ts +74 -0
- package/dist/access/profile.policy.js +110 -0
- package/dist/access/request.state.d.ts +89 -0
- package/dist/access/request.state.js +196 -0
- package/dist/access/session.hooks.d.ts +114 -0
- package/dist/access/session.hooks.js +258 -0
- package/dist/access/token.verifier.d.ts +35 -0
- package/dist/access/token.verifier.js +69 -0
- package/dist/access-schema.d.ts +48 -0
- package/dist/access-schema.js +5 -0
- package/dist/access-warning.emitter.d.ts +2 -0
- package/dist/access-warning.emitter.js +20 -0
- package/dist/access.builder.d.ts +299 -0
- package/dist/access.builder.js +66 -0
- package/dist/access.engine.d.ts +142 -0
- package/dist/access.engine.js +470 -0
- package/dist/audit/audit-event.d.ts +135 -0
- package/dist/audit/audit-event.js +8 -0
- package/dist/audit/audit.log.d.ts +122 -0
- package/dist/audit/audit.log.js +345 -0
- package/dist/audit/audit.schema.d.ts +143 -0
- package/dist/audit/audit.schema.js +50 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +2 -0
- package/dist/aventara-where.translator.d.ts +19 -0
- package/dist/aventara-where.translator.js +87 -0
- package/dist/beacon/beacon.check.d.ts +59 -0
- package/dist/beacon/beacon.check.js +128 -0
- package/dist/beacon/beacon.schema.d.ts +41 -0
- package/dist/beacon/beacon.schema.js +48 -0
- package/dist/beacon/beacon.store.d.ts +29 -0
- package/dist/beacon/beacon.store.js +78 -0
- package/dist/better-auth/access.client-plugin.d.ts +75 -0
- package/dist/better-auth/access.client-plugin.js +133 -0
- package/dist/better-auth/access.plugin.d.ts +164 -0
- package/dist/better-auth/access.plugin.js +348 -0
- package/dist/better-auth/access.provider.d.ts +78 -0
- package/dist/better-auth/access.provider.js +100 -0
- package/dist/better-auth/index.d.ts +7 -0
- package/dist/better-auth/index.js +4 -0
- package/dist/better-auth/organization.bridge.d.ts +66 -0
- package/dist/better-auth/organization.bridge.js +144 -0
- package/dist/canonical-json.writer.d.ts +12 -0
- package/dist/canonical-json.writer.js +30 -0
- package/dist/cli/avaccess.bin.d.ts +2 -0
- package/dist/cli/avaccess.bin.js +26 -0
- package/dist/cli/avaccess.program.d.ts +15 -0
- package/dist/cli/avaccess.program.js +166 -0
- package/dist/cli/beacon-pins.file.d.ts +14 -0
- package/dist/cli/beacon-pins.file.js +62 -0
- package/dist/cli/beacon.loader.d.ts +20 -0
- package/dist/cli/beacon.loader.js +71 -0
- package/dist/cli/cli.context.d.ts +49 -0
- package/dist/cli/cli.context.js +66 -0
- package/dist/cli/diff.command.d.ts +2 -0
- package/dist/cli/diff.command.js +66 -0
- package/dist/cli/generate.command.d.ts +2 -0
- package/dist/cli/generate.command.js +82 -0
- package/dist/cli/keys.command.d.ts +2 -0
- package/dist/cli/keys.command.js +96 -0
- package/dist/cli/lock.file.d.ts +31 -0
- package/dist/cli/lock.file.js +181 -0
- package/dist/cli/module.loader.d.ts +13 -0
- package/dist/cli/module.loader.js +63 -0
- package/dist/cli/policies.diff.d.ts +16 -0
- package/dist/cli/policies.diff.js +83 -0
- package/dist/cli/pull.command.d.ts +2 -0
- package/dist/cli/pull.command.js +234 -0
- package/dist/cli/push.command.d.ts +2 -0
- package/dist/cli/push.command.js +59 -0
- package/dist/cli/registry.client.d.ts +63 -0
- package/dist/cli/registry.client.js +287 -0
- package/dist/cli/schema.command.d.ts +2 -0
- package/dist/cli/schema.command.js +60 -0
- package/dist/cli/serve.command.d.ts +6 -0
- package/dist/cli/serve.command.js +200 -0
- package/dist/cli/status.command.d.ts +2 -0
- package/dist/cli/status.command.js +31 -0
- package/dist/cli/translator.flag.d.ts +6 -0
- package/dist/cli/translator.flag.js +24 -0
- package/dist/cli/verify.command.d.ts +2 -0
- package/dist/cli/verify.command.js +40 -0
- package/dist/client/access-client.factory.d.ts +92 -0
- package/dist/client/access-client.factory.js +269 -0
- package/dist/client/access-verbs.schema.d.ts +66 -0
- package/dist/client/access-verbs.schema.js +159 -0
- package/dist/client/verb-rate.limiter.d.ts +116 -0
- package/dist/client/verb-rate.limiter.js +358 -0
- package/dist/foreign/foreign-facts.d.ts +30 -0
- package/dist/foreign/foreign-facts.js +75 -0
- package/dist/foreign/foreign.merger.d.ts +10 -0
- package/dist/foreign/foreign.merger.js +52 -0
- package/dist/foreign-references.check.d.ts +12 -0
- package/dist/foreign-references.check.js +134 -0
- package/dist/generator/client.emitter.d.ts +28 -0
- package/dist/generator/client.emitter.js +411 -0
- package/dist/generator/copied-modules.reader.d.ts +14 -0
- package/dist/generator/copied-modules.reader.js +80 -0
- package/dist/generator/namespace.facts.d.ts +28 -0
- package/dist/generator/namespace.facts.js +32 -0
- package/dist/generator/orm.translator.d.ts +48 -0
- package/dist/generator/orm.translator.js +29 -0
- package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
- package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
- package/dist/generator/prisma7/prisma-where.translator.d.ts +72 -0
- package/dist/generator/prisma7/prisma-where.translator.js +136 -0
- package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
- package/dist/generator/prisma7/prisma7.translator.js +87 -0
- package/dist/generator/schema.emitter.d.ts +14 -0
- package/dist/generator/schema.emitter.js +23 -0
- package/dist/generator/tsconfig.reader.d.ts +44 -0
- package/dist/generator/tsconfig.reader.js +110 -0
- package/dist/generator/typescript-literal.writer.d.ts +5 -0
- package/dist/generator/typescript-literal.writer.js +42 -0
- package/dist/guardrails/guardrails-contract.d.ts +45 -0
- package/dist/guardrails/guardrails-contract.js +12 -0
- package/dist/guardrails/guardrails.builder.d.ts +48 -0
- package/dist/guardrails/guardrails.builder.js +32 -0
- package/dist/guardrails/guardrails.compiler.d.ts +10 -0
- package/dist/guardrails/guardrails.compiler.js +254 -0
- package/dist/guardrails/guardrails.composer.d.ts +7 -0
- package/dist/guardrails/guardrails.composer.js +95 -0
- package/dist/guardrails/guardrails.hash.d.ts +10 -0
- package/dist/guardrails/guardrails.hash.js +38 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
- package/dist/kiosk/kiosk-verbs.schema.js +61 -0
- package/dist/kiosk/kiosk.plugin.d.ts +217 -0
- package/dist/kiosk/kiosk.plugin.js +877 -0
- package/dist/kiosk/kiosk.schema.d.ts +176 -0
- package/dist/kiosk/kiosk.schema.js +101 -0
- package/dist/kiosk/kiosk.store.d.ts +171 -0
- package/dist/kiosk/kiosk.store.js +478 -0
- package/dist/kiosk/lan-range.policy.d.ts +31 -0
- package/dist/kiosk/lan-range.policy.js +113 -0
- package/dist/kiosk/pin.policy.d.ts +9 -0
- package/dist/kiosk/pin.policy.js +143 -0
- package/dist/kiosk/session-use.marker.d.ts +4 -0
- package/dist/kiosk/session-use.marker.js +16 -0
- package/dist/lock/lock-contract.d.ts +38 -0
- package/dist/lock/lock-contract.js +67 -0
- package/dist/lock/lock.verifier.d.ts +27 -0
- package/dist/lock/lock.verifier.js +62 -0
- package/dist/mcp/agent-scope.gate.d.ts +11 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +36 -0
- package/dist/mcp/agent.grant.js +92 -0
- package/dist/mcp/mcp-options.d.ts +60 -0
- package/dist/mcp/mcp-options.js +72 -0
- package/dist/oauth/access-token.verifier.d.ts +66 -0
- package/dist/oauth/access-token.verifier.js +201 -0
- package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
- package/dist/oauth/oauth-provider.hardening.js +261 -0
- package/dist/passwords/argon2id.hasher.d.ts +97 -0
- package/dist/passwords/argon2id.hasher.js +256 -0
- package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
- package/dist/passwords/node-rs-argon2.adapter.js +42 -0
- package/dist/passwords/passwords.options.d.ts +79 -0
- package/dist/passwords/passwords.options.js +212 -0
- package/dist/policies-contract.d.ts +192 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +36 -0
- package/dist/policies.compiler.js +1358 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +382 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +53 -0
- package/dist/predicate.evaluator.d.ts +62 -0
- package/dist/predicate.evaluator.js +378 -0
- package/dist/random-secret.generator.d.ts +2 -0
- package/dist/random-secret.generator.js +15 -0
- package/dist/reference-reads.check.d.ts +12 -0
- package/dist/reference-reads.check.js +148 -0
- package/dist/registry/access.registry.d.ts +173 -0
- package/dist/registry/access.registry.js +865 -0
- package/dist/registry/beacon.ledger.d.ts +30 -0
- package/dist/registry/beacon.ledger.js +93 -0
- package/dist/registry/ownership.check.d.ts +54 -0
- package/dist/registry/ownership.check.js +215 -0
- package/dist/registry/registry.endpoints.d.ts +50 -0
- package/dist/registry/registry.endpoints.js +193 -0
- package/dist/registry/registry.key.d.ts +7 -0
- package/dist/registry/registry.key.js +13 -0
- package/dist/registry/registry.options.d.ts +13 -0
- package/dist/registry/registry.options.js +28 -0
- package/dist/registry/registry.refusal.d.ts +6 -0
- package/dist/registry/registry.refusal.js +11 -0
- package/dist/registry/registry.schema.d.ts +238 -0
- package/dist/registry/registry.schema.js +127 -0
- package/dist/resources/auth-tables.catalog.d.ts +91 -0
- package/dist/resources/auth-tables.catalog.js +102 -0
- package/dist/resources/auth-tables.guard.d.ts +95 -0
- package/dist/resources/auth-tables.guard.js +405 -0
- package/dist/scope.resolver.d.ts +138 -0
- package/dist/scope.resolver.js +512 -0
- package/dist/sha256.digest.d.ts +2 -0
- package/dist/sha256.digest.js +81 -0
- package/dist/standalone/access.server.d.ts +41 -0
- package/dist/standalone/access.server.js +301 -0
- package/dist/standalone/index.d.ts +1 -0
- package/dist/standalone/index.js +1 -0
- package/package.json +101 -3
- package/src/aventara-where.translator.ts +116 -0
- package/src/beacon/beacon.check.ts +204 -0
- package/src/canonical-json.writer.ts +51 -0
- package/src/generator/prisma7/prisma-where.translator.ts +241 -0
- package/src/policies-contract.ts +184 -0
- package/src/policies.decider.ts +650 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +791 -0
- package/src/sha256.digest.ts +85 -0
package/README.md
CHANGED
|
@@ -1,3 +1,2440 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aventara/access
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Access extends [Better Auth](https://www.better-auth.com) with policies for
|
|
4
|
+
[Aventara](https://aventara-framework.vercel.app) apps and the services beside them. Better Auth signs users in and
|
|
5
|
+
issues their tokens. Access decides what each user may do with each row and each field.
|
|
6
|
+
|
|
7
|
+
You write role-first rules, typed from your app's schema. Access compiles them to a policies file: plain JSON in the
|
|
8
|
+
`aventara-access/policies@1` format. Its engine answers the framework's policy questions from that file: who may read,
|
|
9
|
+
create, update or delete which rows, and which fields they may see.
|
|
10
|
+
|
|
11
|
+
This is a release candidate. It holds:
|
|
12
|
+
|
|
13
|
+
| Part | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `@aventara/access`: `definePolicies`, `createAccessEngine` | The rules and the engine. They need no Better Auth and no server. |
|
|
16
|
+
| `@aventara/access/auth`: `createAccess` | Better Auth with Access beside it: tokens with claims, the engine, the request's principal. |
|
|
17
|
+
| `@aventara/access/better-auth`: `aventaraAccess`, `accessOf`, `aventaraAccessClient` | Access as a plugin of the Better Auth you build yourself, and its client plugin. |
|
|
18
|
+
| `@aventara/access/standalone` | `serve(access)`: Access as its own HTTP server. |
|
|
19
|
+
| The registry | Namespaces, keys and the org's guardrails, stored in Better Auth's database. |
|
|
20
|
+
| `avaccess` | The command line: push, pull, verify, status, diff, keys, generate, schema, serve. |
|
|
21
|
+
| The generated client | Two TypeScript files (`avAccess.ts` and `avAccess.internal.ts`) that import nothing else and decide for a service that does not run on Aventara. |
|
|
22
|
+
|
|
23
|
+
Contents: [Install](#install) · [Write the rules](#write-the-rules) · [createAccess](#createaccess) ·
|
|
24
|
+
[Mount the routes](#mount-the-routes) · [Wire it into Aventara](#wire-it-into-aventara) ·
|
|
25
|
+
[The Better Auth plugin](#the-better-auth-plugin) · [The engine alone](#the-engine-alone) · [The audit log](#the-audit-log) ·
|
|
26
|
+
[Users and roles as Resources](#users-and-roles-as-resources) · [The registry and avaccess](#the-registry-and-avaccess)
|
|
27
|
+
· [Guardrails](#guardrails) · [Services not on Aventara](#services-not-on-aventara) ·
|
|
28
|
+
[Running `avaccess serve`](#running-avaccess-serve) · [File formats and hashes](#file-formats-and-hashes) ·
|
|
29
|
+
[Error codes](#error-codes)
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Access goes into an Aventara app. To start one, create it with the Aventara CLI (here NestJS, Prisma 7 and
|
|
34
|
+
PostgreSQL), then install Access in it:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx @aventara/cli@1.0.0-rc.10 new <app> --orm prisma7 --db postgresql
|
|
38
|
+
cd <app>
|
|
39
|
+
npm install --save-exact @aventara/access@rc better-auth@~1.7.12
|
|
40
|
+
# or, with pnpm:
|
|
41
|
+
pnpm add --save-exact @aventara/access@rc better-auth@~1.7.12
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On a terminal, `aventara new` asks for the package manager; anywhere else, add `--package-manager npm` (or `pnpm`)
|
|
45
|
+
or `--yes`. Then set `DATABASE_URL` in `.env`, as the CLI says.
|
|
46
|
+
|
|
47
|
+
Access runs on Better Auth `~1.7.12`: 1.7.12 or any later 1.7 patch. npm installs the newest patch, and
|
|
48
|
+
`--save-exact` records it. Another version (an older one, 1.8, a prerelease) is refused when `createAccess` runs
|
|
49
|
+
(`ACCESS_BETTER_AUTH_VERSION`).
|
|
50
|
+
|
|
51
|
+
`better-auth` is an optional peer. The engine alone (`definePolicies`, `createAccessEngine`, from `@aventara/access`)
|
|
52
|
+
works without it, and type-checks without it with `skipLibCheck: false`. Only `createAccess` loads it, and it is
|
|
53
|
+
imported from `@aventara/access/auth`.
|
|
54
|
+
|
|
55
|
+
Access works with `@aventara/core` `1.0.0-rc.10` or later. It has no dependency on the framework: its engine and its
|
|
56
|
+
instance fit the framework's types by shape. Node.js `^22.23.4 || >=24.22.1`. On Node.js 22, `createAccess` needs
|
|
57
|
+
`@node-rs/argon2` to hash passwords ([Passwords](#passwords)).
|
|
58
|
+
|
|
59
|
+
## Write the rules
|
|
60
|
+
|
|
61
|
+
`definePolicies` takes your schema. In an Aventara app that is the discovery artifact's `model`, which
|
|
62
|
+
`aventara:prepare` writes. Every name in a rule is checked against it. A Resource, field, relation or enum value that
|
|
63
|
+
does not exist is a type error at the key that names it.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// src/access.policies.ts
|
|
67
|
+
import { definePolicies } from "@aventara/access";
|
|
68
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
69
|
+
|
|
70
|
+
/** What your app derives for a request: the signed-in user, or null. */
|
|
71
|
+
export type Principal = {
|
|
72
|
+
id: string;
|
|
73
|
+
claims: { role?: "admin"; banned?: boolean };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const policies = definePolicies(model)
|
|
77
|
+
.principal<Principal>()
|
|
78
|
+
|
|
79
|
+
.roles(({ me }) => ({
|
|
80
|
+
// A global role, read from the principal's claims.
|
|
81
|
+
admin: { global: { claims: { role: "admin" } } },
|
|
82
|
+
// Roles held on a row: a member of a campaign, as DM or player.
|
|
83
|
+
dm: {
|
|
84
|
+
on: "Campaign",
|
|
85
|
+
where: { members: { some: { userId: me.id, role: "DM" } } },
|
|
86
|
+
},
|
|
87
|
+
player: {
|
|
88
|
+
on: "Campaign",
|
|
89
|
+
where: { members: { some: { userId: me.id, role: "PLAYER" } } },
|
|
90
|
+
},
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
// Governed fields: out of every default projection; only a grant that names one
|
|
94
|
+
// opens it, and only when selected.
|
|
95
|
+
.govern("User", ["email"])
|
|
96
|
+
|
|
97
|
+
.grants(({ grant, me }) => [
|
|
98
|
+
grant("signedIn", "read", "User"),
|
|
99
|
+
grant("signedIn", "read", "User", {
|
|
100
|
+
where: { id: me.id },
|
|
101
|
+
fields: ["User.email"],
|
|
102
|
+
}),
|
|
103
|
+
grant("admin", "read", "*", { fields: ["User.email"] }),
|
|
104
|
+
grant("signedIn", "create", "Campaign"),
|
|
105
|
+
grant(["dm", "player"], "read", "Campaign"),
|
|
106
|
+
grant("player", "read", "Note", {
|
|
107
|
+
in: "campaign",
|
|
108
|
+
where: { visibility: "PARTY" },
|
|
109
|
+
}),
|
|
110
|
+
grant("dm", ["read", "create", "update", "delete"], "Note", {
|
|
111
|
+
in: "campaign",
|
|
112
|
+
}),
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
.denies(({ deny }) => [
|
|
116
|
+
deny("*", "*", { if: { claims: { banned: true } } }),
|
|
117
|
+
deny("delete", "*", { if: { origin: "agent" } }),
|
|
118
|
+
]);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In Next.js, `aventara:prepare` writes the artifact to `src/aventara/generated/discovery.artifact.ts`. Import the model
|
|
122
|
+
there as:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// src/access.policies.ts (Next.js)
|
|
126
|
+
import { model } from "@/aventara/generated/discovery.artifact";
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
How the rules combine:
|
|
130
|
+
|
|
131
|
+
- Nothing is allowed until a grant allows it.
|
|
132
|
+
- Any grant that matches allows. Any deny that matches wins over every grant. The order of rules never matters.
|
|
133
|
+
- `signedIn` is any non-null principal. `anyone` also includes the anonymous caller (`null`). The principal is an
|
|
134
|
+
object, an instance of a class (an entity) included; an array, a function or a primitive is anonymous. Its values
|
|
135
|
+
are read from its own properties, never from its prototype.
|
|
136
|
+
- `in: "campaign"` means the subject holds its role on the row's `campaign`.
|
|
137
|
+
- A rule reads `me` values the principal may lack: an anonymous principal lacks them all, and a missing or `null`
|
|
138
|
+
property, an empty string or a number JSON cannot carry counts as missing. A grant that reads a missing value
|
|
139
|
+
never matches. A deny that reads one in its `where` matches, whatever `not` or `NOT` surrounds it: it fails
|
|
140
|
+
closed.
|
|
141
|
+
- A claim the principal lacks fails closed the same way: a global role that compares it is not held, and a deny's
|
|
142
|
+
`if` that compares it holds, whatever value it compares with. A token Access issues carries every declared
|
|
143
|
+
boolean claim, `false` when the user's is unset, so `deny("*", "*", { if: { claims: { banned: true } } })` leaves
|
|
144
|
+
alone a user whose ban is unset and denies a token that lacks `banned` (one from another issuer, or from before
|
|
145
|
+
the claim was declared). The anonymous caller has no claims and no token: a comparison with `true` never holds for
|
|
146
|
+
it, so a ban does not close an `anyone` grant, and any other comparison holds for a deny.
|
|
147
|
+
- `if: { origin: "agent" }` matches an AI agent's request, through the framework's MCP endpoint. The other origins are
|
|
148
|
+
`client` (a user's request) and `application` (your server's own).
|
|
149
|
+
- A rule compares a value by its field's type: a `decimal` by value (`1.5`, `"1.50"` and a decimal object are equal),
|
|
150
|
+
a `datetime` by instant (a `Date`, or an ISO-8601 date or instant with its offset that reads back as written: no
|
|
151
|
+
February 30th, no hour 24, nothing past the millisecond; the compiled file holds `toISOString()`'s form), and a
|
|
152
|
+
`bigint` exactly. A row value of another type than its field's, or than the rule's `me` value, is unknown: no grant
|
|
153
|
+
matches it, and a deny that compares it matches.
|
|
154
|
+
- Through Aventara, a rule that compares a `datetime`, `decimal` or `bigint` field cannot be used yet: the framework
|
|
155
|
+
validates a policy's `where` with a `Date`, a decimal object or a `bigint` where the file holds text, so a scope or
|
|
156
|
+
an `exists` question on such a field answers `A3000` (the request is refused). It fails closed; the framework's
|
|
157
|
+
seam is being changed. `can` on a row, and a generated client, compare these fields as above.
|
|
158
|
+
- Writing a reference needs read on its target. When a create or an update sends a reference (a `Member`'s
|
|
159
|
+
`profileId`) to a Resource the app serves, Aventara holds it to the rows the caller may read there, and refuses the
|
|
160
|
+
write (`A4002`) otherwise. When Aventara hosts the engine (`@aventara/core` `1.0.0-rc.10` or later) it tells the
|
|
161
|
+
engine which Resources it holds references to (`engine.hosted({ held })`), and the engine warns
|
|
162
|
+
(`ACCESS_REFERENCE_UNREADABLE`, a Node.js warning) about a role that may write a reference to one of them no read
|
|
163
|
+
grant reaches for it. Compiling the rules warns about nothing, and the engine decides as before. A reference to a
|
|
164
|
+
foreign Resource is the exception: no caller reads one, so the engine lists the rules' foreign Resources in
|
|
165
|
+
`unheld` and Aventara does not hold a reference to them ([Namespaces and foreign
|
|
166
|
+
Resources](#namespaces-and-foreign-resources)).
|
|
167
|
+
- Strings compare exactly in `can`, code unit by code unit. A database column with a case- or accent-insensitive
|
|
168
|
+
collation (MySQL's default, PostgreSQL's `citext` or a nondeterministic collation) can find rows `can` would not
|
|
169
|
+
keep, so `scopeOf` and `can` disagree on them. Give the fields your rules compare a binary or deterministic
|
|
170
|
+
collation.
|
|
171
|
+
|
|
172
|
+
### Updates
|
|
173
|
+
|
|
174
|
+
An update is checked twice. Its scope (`scopeOf(update)`) picks the rows a grant's `where` covers and no update deny
|
|
175
|
+
matches. Then the row each update leaves must still be one an update grant covers, by that grant's `where` and its
|
|
176
|
+
`after` together, and no update deny may match it. A grant's `where` is checked on the new row by default: a grant
|
|
177
|
+
`where: { authorId: me.id }` does not let the author hand the row to someone else. `after` adds what the new row must
|
|
178
|
+
also meet:
|
|
179
|
+
|
|
180
|
+
- `grant("signedIn", "update", "Character", { where: { ownerId: me.id }, after: { campaign: { $role: "player" } } })`
|
|
181
|
+
lets a player move a character only to a campaign they play in.
|
|
182
|
+
|
|
183
|
+
The grant that covers the row after must also be the one that covered the row before. `can` then gets both rows
|
|
184
|
+
(`row`, and `before`, the row as it was before the update), and one update grant must cover both: its `where` the row
|
|
185
|
+
before, its `where` and `after` the row after. No update moves a row from the rows one grant reaches to another's.
|
|
186
|
+
When two update grants with different conditions cover a Resource, a row that only one of them covers before the
|
|
187
|
+
update and only the other covers after it is refused. Update denies are asked about the row after only.
|
|
188
|
+
|
|
189
|
+
The engine lists in `afterChecks` every Resource whose update grants read the row (a `where`, an `after`, a role held
|
|
190
|
+
on a row) or whose update denies have a `where`. Aventara asks `can` about each row such an update changed, with the
|
|
191
|
+
row before it, inside the update's transaction, and rolls the update back when one fails; where it has no interactive
|
|
192
|
+
transaction, it refuses those updates. The row before needs `@aventara/core` `1.0.0-rc.10` or later. An older core
|
|
193
|
+
hands `can` the row after only, and `can` then lets an update in when any update grant covers that row.
|
|
194
|
+
|
|
195
|
+
Which grants covered the row before is decided before the write. Aventara asks the engine's `grantsOf` about each row
|
|
196
|
+
such an update will change, after it reads the row and before any of your stages or the write itself runs, and hands
|
|
197
|
+
the answer back to `can` as `beforeGrants`. The grant that covers the row after must be one of those. A grant whose
|
|
198
|
+
`where` reads a relation (`tags: { some: { name: "shared" } }`, a role held through a membership) is therefore judged
|
|
199
|
+
on what the database held before the update: tagging a row "shared" in the same update that hands it to someone else
|
|
200
|
+
does not let a grant on shared rows cover the row before. This also needs `@aventara/core` `1.0.0-rc.10` or later.
|
|
201
|
+
Without `beforeGrants`, `can` asks a relation condition on the row before through `exists` at the after check, when
|
|
202
|
+
the update has already written, so a nested write in the same request can make it hold. With `before` too, `can`
|
|
203
|
+
still checks each listed grant's `where` on the row before in memory, so a key list replayed from another row or
|
|
204
|
+
another principal fails on a scalar condition; only a relation condition is left to `grantsOf`'s answer. The keys
|
|
205
|
+
`grantsOf` answers are opaque: a short hash of the policies file's grants, then a grant's place in it, never its
|
|
206
|
+
conditions, so a list from another policies file covers nothing. `grantsOf` answers no key on a row an update deny
|
|
207
|
+
already refuses in memory. Its answer is what the database held when it was asked: a relation fact your own stages,
|
|
208
|
+
or another transaction committing in between, change before the write is not in it. Aventara may ask it about several
|
|
209
|
+
rows at once, so your `exists` can be called concurrently in one transaction. It decides nothing, so it is neither
|
|
210
|
+
reported to the decision observer nor explained; the `can` after it is.
|
|
211
|
+
|
|
212
|
+
A generated client's `can({ action: "update", row, before })` answers the same question about the rows you pass.
|
|
213
|
+
Without `before` it checks only the row the update leaves. Ask `access.grantsOf({ action: "update", row })` about the
|
|
214
|
+
row before you write it, in the update's transaction and with the same `exists`, and pass its answer to `can` as
|
|
215
|
+
`beforeGrants` with the row the update leaves; without it, `can` asks the row before's relation conditions when you
|
|
216
|
+
ask `can`. The caller must also limit the update itself to the rows `scopeOf({ action: "update" })` returns, or an
|
|
217
|
+
update could start from a row no grant covers.
|
|
218
|
+
|
|
219
|
+
A row handed to `can` is a Resource's own scalar fields. A relation on it (`author: { … }`, `tags: [ … ]`) is never
|
|
220
|
+
read: a relation condition is always asked through `exists`, on what the database holds. Still, never put relation
|
|
221
|
+
data a client sent on a row. A to-one step whose reference fields the row carries (`campaign` through `campaignId`) is
|
|
222
|
+
asked on the row those values name, so a question asked before the write sees where the row is moving; anything else
|
|
223
|
+
is asked about the row itself, by its identifier.
|
|
224
|
+
|
|
225
|
+
`write: { except: [...] }` keeps fields out of what a create or an update may set. A relation and its reference fields
|
|
226
|
+
are locked together: `except: ["author"]` locks `authorId` too, and `except: ["authorId"]` locks `author`.
|
|
227
|
+
|
|
228
|
+
### Governed fields
|
|
229
|
+
|
|
230
|
+
`.govern("User", ["email"])` takes a field out of every default projection. Only a grant that names it
|
|
231
|
+
(`fields: ["User.email"]`) opens it, and only when the request selects it. Selecting a governed field without such a
|
|
232
|
+
grant is refused with `403 A4002`. The field is not quietly dropped from the result.
|
|
233
|
+
|
|
234
|
+
A relation and its reference fields tell each other (`author.id` is `authorId`), so they are governed together:
|
|
235
|
+
`.govern("Post", ["author"])` without `"authorId"`, or the reverse, is refused when the rules compile.
|
|
236
|
+
|
|
237
|
+
`fields` on a Resource with no governed field is a type error that says what to do:
|
|
238
|
+
`no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it`.
|
|
239
|
+
|
|
240
|
+
### The principal type
|
|
241
|
+
|
|
242
|
+
`.principal<…>()` types `me` in every callback. There are two ways to use it.
|
|
243
|
+
|
|
244
|
+
- **Your own type**, as above. With `createAccess`, you then pass a `principal` function that maps the verified token to
|
|
245
|
+
it.
|
|
246
|
+
- **The token's identity.** `AccessIdentity<typeof claims>` is `{ sub, claims }`, exactly what a verified token
|
|
247
|
+
carries. Pass the same `claims` object to `createAccess` and leave `principal` out.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// src/identity.policies.ts
|
|
251
|
+
import { definePolicies } from "@aventara/access";
|
|
252
|
+
import type { AccessIdentity } from "@aventara/access/auth";
|
|
253
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
254
|
+
|
|
255
|
+
/** The claims a token carries beside `sub`, by type. */
|
|
256
|
+
export const claims = { role: "string", banned: "boolean" } as const;
|
|
257
|
+
|
|
258
|
+
export const identityPolicies = definePolicies(model)
|
|
259
|
+
.principal<AccessIdentity<typeof claims>>()
|
|
260
|
+
.grants(({ grant, me }) => [
|
|
261
|
+
grant("signedIn", "read", "User", { where: { id: me.sub } }),
|
|
262
|
+
grant("signedIn", "create", "Note", { where: { authorId: me.sub } }),
|
|
263
|
+
])
|
|
264
|
+
.denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Without `.principal<…>()` the rules have no principal type, so `me.sub` in a `where` and `claims` in a role are type
|
|
268
|
+
errors.
|
|
269
|
+
|
|
270
|
+
### Namespaces and foreign Resources
|
|
271
|
+
|
|
272
|
+
When several services share one Access server, each service owns a namespace and writes the rules for its own
|
|
273
|
+
Resources. `definePolicies(model, { namespace: "chat" })` names it. A namespace name matches
|
|
274
|
+
`^[a-z][a-z0-9_-]{0,62}$`.
|
|
275
|
+
|
|
276
|
+
A Resource another namespace owns is marked `{ foreign: "<namespace>" }` in the schema. The rules can read it in
|
|
277
|
+
conditions, but cannot grant, deny or govern on it. That is a type error, and the engine and the registry refuse it
|
|
278
|
+
too. A foreign Resource is only readable while both services share one database: the ownership beacon
|
|
279
|
+
([below](#the-ownership-beacon)) checks that at push, at pull and at startup.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// src/chat.policies.ts
|
|
283
|
+
import { definePolicies } from "@aventara/access";
|
|
284
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
285
|
+
|
|
286
|
+
/** The schema with Campaign marked as the campaign namespace's. */
|
|
287
|
+
const schema = {
|
|
288
|
+
...model,
|
|
289
|
+
resources: {
|
|
290
|
+
...model.resources,
|
|
291
|
+
Campaign: { ...model.resources.Campaign, foreign: "campaign" },
|
|
292
|
+
},
|
|
293
|
+
} as const;
|
|
294
|
+
|
|
295
|
+
export const chatPolicies = definePolicies(schema, { namespace: "chat" })
|
|
296
|
+
.principal<{ id: string }>()
|
|
297
|
+
.grants(({ grant, me }) => [
|
|
298
|
+
// Read through Campaign in a condition: allowed.
|
|
299
|
+
grant("signedIn", "read", "Note", {
|
|
300
|
+
where: { campaign: { members: { some: { userId: me.id } } } },
|
|
301
|
+
}),
|
|
302
|
+
// grant("signedIn", "read", "Campaign") would be a type error here.
|
|
303
|
+
]);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
A row of your own can hold a reference to a foreign Resource (a chat `Room`'s `campaignId`). No caller reads a foreign
|
|
307
|
+
Resource, so Aventara cannot hold such a reference to the rows the caller may read, as it does for your own Resources:
|
|
308
|
+
the engine lists the rules' foreign Resources in `unheld` (`@aventara/core` `1.0.0-rc.10` or later), and the write grant
|
|
309
|
+
alone decides which row may be linked. Constrain it through the relation, with a `where` on a create grant and an
|
|
310
|
+
`after` on an update grant (an update grant's `where` is checked on the row it leaves too):
|
|
311
|
+
|
|
312
|
+
```text
|
|
313
|
+
grant("signedIn", "create", "Room", { where: { campaign: { members: { some: { userId: me.id } } } } }),
|
|
314
|
+
grant("signedIn", "update", "Room", {
|
|
315
|
+
where: { ownerId: me.id },
|
|
316
|
+
after: { campaign: { members: { some: { userId: me.id } } } },
|
|
317
|
+
}),
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Without such a condition the caller can link any row of the foreign Resource by its identifier. When Aventara hosts the
|
|
321
|
+
engine, it warns about each role whose create or update grant may write a reference to a foreign Resource with no
|
|
322
|
+
condition through that relation (`ACCESS_FOREIGN_REFERENCE_UNCHECKED`, a Node.js warning, once per process and
|
|
323
|
+
namespace). Reads are unchanged: through Aventara a read of a foreign Resource, or an include of one, is still refused
|
|
324
|
+
(`A4002`).
|
|
325
|
+
|
|
326
|
+
## createAccess
|
|
327
|
+
|
|
328
|
+
`createAccess` builds Better Auth with Access beside it. It opens no port and mounts nothing. Better Auth's options sit
|
|
329
|
+
in the `betterAuth` block, exactly as `betterAuth({...})` takes them, so you can paste them from an existing Better Auth
|
|
330
|
+
setup. Access's own concerns sit in their own blocks beside it.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// src/access.ts
|
|
334
|
+
import { createAccess } from "@aventara/access/auth";
|
|
335
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
336
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
337
|
+
import { policies } from "./access.policies.js";
|
|
338
|
+
import { PrismaClient } from "./generated/prisma/client.js";
|
|
339
|
+
|
|
340
|
+
/** Better Auth's own Prisma client: it reads and writes its tables through it. */
|
|
341
|
+
const prisma = new PrismaClient({
|
|
342
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/** What every token carries beside `sub`. */
|
|
346
|
+
const claims = { role: "string", banned: "boolean" } as const;
|
|
347
|
+
|
|
348
|
+
export const access = createAccess({
|
|
349
|
+
// Better Auth's options, as betterAuth({...}) takes them.
|
|
350
|
+
betterAuth: {
|
|
351
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
352
|
+
emailAndPassword: { enabled: true },
|
|
353
|
+
user: {
|
|
354
|
+
additionalFields: {
|
|
355
|
+
role: { type: "string", required: false, input: false },
|
|
356
|
+
banned: { type: "boolean", required: false, input: false },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
// The rules, and the claims they read. Each claim is read from the user's field
|
|
361
|
+
// of the same name.
|
|
362
|
+
policies: { rules: policies, claims },
|
|
363
|
+
// The rules are typed with your own Principal, so map the token to it.
|
|
364
|
+
principal: ({ sub, claims }) => ({
|
|
365
|
+
id: sub,
|
|
366
|
+
claims: {
|
|
367
|
+
...(claims.role === "admin" ? { role: "admin" as const } : {}),
|
|
368
|
+
...(claims.banned === undefined ? {} : { banned: claims.banned }),
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export default access;
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Better Auth reads `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` from the environment, as it always does, or takes
|
|
377
|
+
`secret` and `baseURL` in the block. Access needs a base URL it can fix when the instance is built: its tokens' issuer
|
|
378
|
+
and audience are that URL's origin (`baseURL`, else `BETTER_AUTH_URL`, or a dynamic `baseURL`'s `fallback`). With none,
|
|
379
|
+
`createAccess` refuses with `ACCESS_OPTIONS_INVALID`. The issuer is the origin alone, not the path: two apps on one
|
|
380
|
+
origin that share a database (and so Better Auth's key set) accept each other's tokens. Give each app its own origin,
|
|
381
|
+
or its own database.
|
|
382
|
+
|
|
383
|
+
Each declared claim is read from the user's field of the same name, so that field needs `input: false`, as `role` and
|
|
384
|
+
`banned` have above. A field without it is one the user sets themselves, at sign-up or with `/update-user`, and a
|
|
385
|
+
claim read from it would be the user's own choice: `createAccess` refuses it (`ACCESS_OPTIONS_INVALID`), for fields a
|
|
386
|
+
plugin adds too. To read a claim from such a field anyway, return it from `policies.claimsOf`.
|
|
387
|
+
|
|
388
|
+
`policies.claimsOf` must return every declared boolean claim, as `banned: user.banned === true`: an answer without
|
|
389
|
+
one is a type error (its return type, `AccessClaimsAnswer`, requires each boolean claim's key), and at run time it is
|
|
390
|
+
refused (`ACCESS_OPTIONS_INVALID`, naming the claim) and no token is issued, so a flag it forgot never reads as unset. `null` or `undefined` there is `false`. Beyond that, Access does not check what `policies.claimsOf` returns. A
|
|
391
|
+
claim it reads from a field the user can set (an `email`, a
|
|
392
|
+
`username`, an additional field without `input: false`) is again the user's own choice, so read claims there only from
|
|
393
|
+
fields your server alone writes.
|
|
394
|
+
|
|
395
|
+
Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma,
|
|
396
|
+
`npx auth@~1.7.12 generate --config src/access.ts` writes Better Auth's models (and Access's own tables, when `audit`
|
|
397
|
+
or `registry` is on) into `prisma/schema.prisma`. It reads the module's default export. Then migrate with Prisma and run
|
|
398
|
+
`aventara:prepare` again, so the framework's model has the new tables. Access closes them to every request until you
|
|
399
|
+
open them (see [Users and roles as Resources](#users-and-roles-as-resources)).
|
|
400
|
+
|
|
401
|
+
On its first run, `npx auth generate` may print a Babel note that an exported identifier such as `AvComputedFields`
|
|
402
|
+
"is not declared in Babel's scope tracker". It comes from a type the generated discovery artifact re-exports, and
|
|
403
|
+
changes nothing: the schema is written all the same.
|
|
404
|
+
|
|
405
|
+
### The scaffold's numeric ids
|
|
406
|
+
|
|
407
|
+
The project `aventara new` creates has `model User { id Int @id @default(autoincrement()) … }`. Better Auth's ids are
|
|
408
|
+
strings by default, so `npx auth@~1.7.12 generate` writes `String` foreign keys (`Session.userId`, `Account.userId`)
|
|
409
|
+
against that `Int`, and `prisma validate` fails. Better Auth has its own option for a database that assigns numeric
|
|
410
|
+
ids. Put it in the `betterAuth` block of `src/access.ts`, beside `database`:
|
|
411
|
+
`advanced: { database: { generateId: "serial" } }`.
|
|
412
|
+
|
|
413
|
+
Every Better Auth table, and Access's own, then gets `Int @id @default(autoincrement())`, and its references are `Int`.
|
|
414
|
+
Add the option before you run `npx auth@~1.7.12 generate --config src/access.ts`, then run `npx prisma validate`. The
|
|
415
|
+
command only adds what the schema lacks: on a schema that already has Better Auth's models with `String` ids, it
|
|
416
|
+
answers "Your schema is already up to date" and changes nothing. Delete the models it wrote (and Access's), then run it
|
|
417
|
+
again.
|
|
418
|
+
|
|
419
|
+
- Better Auth hands an id out as a string (`"1"`), so a token's `sub` is `"1"`. Type your principal's `id` as `number`
|
|
420
|
+
and map it, `principal: ({ sub, claims }) => ({ id: Number(sub), claims: … })`. A rule that compares `User.id` with a
|
|
421
|
+
string, such as `where: { id: me.id }` with `id: string` or `me.sub` from `AccessIdentity`, is a type error.
|
|
422
|
+
- Better Auth warns that its single-use checks (SAML assertion ids, DPoP proofs, `private_key_jwt` assertions) need
|
|
423
|
+
string ids and are not enforced with `"serial"`. If you use those, change `User.id` to `String` instead and leave
|
|
424
|
+
`generateId` out.
|
|
425
|
+
- The scaffold's `User` is now Better Auth's `user` table, so a rule on `User` needs `resources: { users: true }`
|
|
426
|
+
([Users and roles as Resources](#users-and-roles-as-resources)).
|
|
427
|
+
|
|
428
|
+
### The options
|
|
429
|
+
|
|
430
|
+
| Option | Takes | Default |
|
|
431
|
+
|---|---|---|
|
|
432
|
+
| `betterAuth` | Better Auth's options | required |
|
|
433
|
+
| `policies.rules` | The builder (`definePolicies(…)`), its `.contract()`, or a pulled policies file as JSON | required, except on a registry-only server |
|
|
434
|
+
| `policies.claims` | The claims a token carries beside `sub`, by type: `{ role: "string", banned: "boolean" }` | required with `rules` |
|
|
435
|
+
| `policies.claimsOf` | `({ user, session }) => claims`, when the claims are not the user's fields of the same name | each claim from the user's field |
|
|
436
|
+
| `policies.guardrails` | The org's guardrails, as pulled ([Guardrails](#guardrails)) | none |
|
|
437
|
+
| `policies.lock` | The project's `avAccess.lock.json` ([The lock at startup](#the-lock-at-startup)) | none |
|
|
438
|
+
| `policies.requireLock` | `true`: refuse to start without `policies.lock`; recommended in production | `false` |
|
|
439
|
+
| `policies.tokenTtl` | How long a token lives, in whole seconds from 300 to 900 | 900 |
|
|
440
|
+
| `principal` | `(identity) => principal \| null`: maps `{ sub, claims }` to your principal | `me` is the identity |
|
|
441
|
+
| `profile` | `{ kind: "web" }`, `{ kind: "service" }`, or `{ kind: "kiosk", canEnrollDevice, idleTimeout?, insecureLan? }` ([The kiosk profile](#the-kiosk-profile)) | `web` |
|
|
442
|
+
| `registry` | `{ enabled: true }` turns on the registry ([The registry](#the-registry-and-avaccess)) | `{ enabled: false }` |
|
|
443
|
+
| `registry.ownership` | `"first-push"` or `"assigned"`: how a Resource gets its owner ([Who owns a Resource](#who-owns-a-resource)) | `"first-push"` |
|
|
444
|
+
| `audit` | `true`, `false` or `{ sink }` ([The audit log](#the-audit-log)) | off |
|
|
445
|
+
| `resources` | `{ users?: boolean, members?: boolean, authTables? }` ([Users and roles](#users-and-roles-as-resources)) | both off; `authTables` required off Prisma's adapter |
|
|
446
|
+
| `passwords` | `{ argon2id: "owasp-minimum" \| "rfc9106-low-memory", legacyScrypt?: boolean }` ([Passwords](#passwords)) | `"owasp-minimum"`, unless `betterAuth.emailAndPassword.password` is set |
|
|
447
|
+
|
|
448
|
+
- `principal` is required when the rules are typed with your own principal, and optional when they are typed with
|
|
449
|
+
`AccessIdentity<typeof claims>`. Return `null` to leave the caller anonymous. The principal must carry the token's
|
|
450
|
+
claims (`identity.claims`) as they are: a boolean claim it drops is unknown to the rules, so a deny such as
|
|
451
|
+
`banned: true` refuses it. Return a new object for each user (one
|
|
452
|
+
cached per user is fine): the audit log names the user each principal stands for, so an object `principal` already
|
|
453
|
+
returned for another user is refused with `ACCESS_OPTIONS_INVALID`.
|
|
454
|
+
- A claim the rules read that `policies.claims` does not declare is a type error, and is refused at construction
|
|
455
|
+
(`ACCESS_CLAIMS_UNDECLARED`). A token carries `sub` and the declared claims only. Every declared boolean claim is
|
|
456
|
+
in it: one the user's field leaves unset (absent, `null` or `undefined`), or `claimsOf` returns as `null` or
|
|
457
|
+
`undefined`, is `false`.
|
|
458
|
+
- `profile: { kind: "service" }` hands no caller a new session, and reads the principal from bearer tokens only. The
|
|
459
|
+
routes that start a session answer `404`: `/sign-in/*`, `/sign-up/*`, the OAuth callbacks, and the plugins' routes
|
|
460
|
+
that sign a user in (`/phone-number/verify`, `/magic-link/verify`, `/two-factor/verify-*`, `/device/token`,
|
|
461
|
+
`/admin/impersonate-user` and the rest). They are matched by Better Auth's endpoint path, whatever the URL's spelling,
|
|
462
|
+
`basePath` or `baseURL`. Any other route cannot create a session either: Better Auth's session hook refuses the row,
|
|
463
|
+
so none exists even while the route runs, and the answer is a bare `404`, its cookies and headers dropped. So is an
|
|
464
|
+
answer that sets the session cookie of a session that already exists, and no session cookie (nor a `multiSession()`
|
|
465
|
+
cookie) leaves `access.handler` at all: it strips them itself, after Better Auth and every plugin's `onResponse`, so
|
|
466
|
+
a route that returns its own `Response` or a plugin that answers last is covered too. A route that puts a session token in a JSON body is not
|
|
467
|
+
covered; no built-in plugin does. Your server's own calls to Better Auth's API with no request still sign users in;
|
|
468
|
+
a call that forwards the host's `request` (`auth.api.signInEmail({ body, request })`) is treated as that request,
|
|
469
|
+
and refused with `NOT_FOUND`. A plugin route Access does not know may answer `500` with an empty body when its
|
|
470
|
+
session is refused. `web` is Better Auth as you configured it.
|
|
471
|
+
- `tokenTtl` bounds how long a changed claim takes to reach every service. A user you ban or suspend gets the new claim
|
|
472
|
+
with their next token.
|
|
473
|
+
- An option `createAccess` does not take, or a block it cannot read, is refused with `ACCESS_OPTIONS_INVALID` and a
|
|
474
|
+
sentence that says what it takes.
|
|
475
|
+
|
|
476
|
+
### The plugins createAccess installs
|
|
477
|
+
|
|
478
|
+
`createAccess` installs Better Auth's `jwt()` plugin and its own Access plugin (plugin id `aventara-access`, the one
|
|
479
|
+
`aventaraAccess()` returns). Putting either in `betterAuth.plugins` is a type error at that plugin, which reads
|
|
480
|
+
`createAccess installs jwt() itself: remove it from betterAuth.plugins`. At runtime it is refused with
|
|
481
|
+
`ACCESS_PLUGIN_DUPLICATE`, under any case or spacing of its id (`"JWT"`, `"jwt "`). So is a plugin that defines one of
|
|
482
|
+
`jwt()`'s endpoints (`getJwks`, `getToken`, `signJWT`, `verifyJWT`, or a route at `/jwks` or `/token`): it would answer
|
|
483
|
+
for Access's key set or its tokens. Every other Better Auth plugin passes through as it is.
|
|
484
|
+
|
|
485
|
+
### Passwords
|
|
486
|
+
|
|
487
|
+
Better Auth hashes passwords with scrypt. `createAccess` hashes them with argon2id instead, in one of three ways. The
|
|
488
|
+
Better Auth plugin, `aventaraAccess()`, takes the same `passwords` and does the same
|
|
489
|
+
([The Better Auth plugin](#the-better-auth-plugin)):
|
|
490
|
+
|
|
491
|
+
1. **The default.** Set neither `passwords` nor `betterAuth.emailAndPassword.password`, and passwords are hashed with
|
|
492
|
+
argon2id at the `"owasp-minimum"` profile.
|
|
493
|
+
2. **A named profile.** `passwords: { argon2id: "rfc9106-low-memory" }` beside `betterAuth`. A misspelled profile is a
|
|
494
|
+
type error, and is refused at runtime with `ACCESS_OPTIONS_INVALID`. Add `legacyScrypt: true` to accept passwords
|
|
495
|
+
Better Auth already stored with scrypt (see below).
|
|
496
|
+
3. **Your own hook.** Set Better Auth's `emailAndPassword.password` (`hash` and `verify`) in the `betterAuth` block,
|
|
497
|
+
and it is used as you wrote it: Access installs nothing. To keep Better Auth's scrypt, pass its own functions.
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
// src/access.passwords.ts
|
|
501
|
+
import type { AccessPasswordsOptions } from "@aventara/access/auth";
|
|
502
|
+
import type { BetterAuthOptions } from "better-auth";
|
|
503
|
+
import { hashPassword, verifyPassword } from "better-auth/crypto";
|
|
504
|
+
|
|
505
|
+
// 1. The default: leave `passwords` out, and `emailAndPassword.password` too.
|
|
506
|
+
export const byDefault = {
|
|
507
|
+
enabled: true,
|
|
508
|
+
} satisfies BetterAuthOptions["emailAndPassword"];
|
|
509
|
+
|
|
510
|
+
// 2. A named profile: createAccess({ betterAuth, policies, passwords }).
|
|
511
|
+
export const passwords = {
|
|
512
|
+
argon2id: "rfc9106-low-memory",
|
|
513
|
+
} satisfies AccessPasswordsOptions;
|
|
514
|
+
|
|
515
|
+
// 2b. The same, for an app whose users Better Auth signed up with scrypt.
|
|
516
|
+
export const movingOffScrypt = {
|
|
517
|
+
argon2id: "owasp-minimum",
|
|
518
|
+
legacyScrypt: true,
|
|
519
|
+
} satisfies AccessPasswordsOptions;
|
|
520
|
+
|
|
521
|
+
// 3. Your own hook, here Better Auth's scrypt: betterAuth: { emailAndPassword }, and no `passwords`.
|
|
522
|
+
export const emailAndPassword = {
|
|
523
|
+
enabled: true,
|
|
524
|
+
password: {
|
|
525
|
+
hash: hashPassword,
|
|
526
|
+
verify: verifyPassword,
|
|
527
|
+
},
|
|
528
|
+
} satisfies BetterAuthOptions["emailAndPassword"];
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Setting both `passwords` and `betterAuth.emailAndPassword.password` is refused with `ACCESS_OPTIONS_INVALID`, so it is
|
|
532
|
+
never in doubt which one hashes.
|
|
533
|
+
|
|
534
|
+
| Profile | Memory | Passes (`t`) | Lanes (`p`) | Source |
|
|
535
|
+
|---|---|---|---|---|
|
|
536
|
+
| `"owasp-minimum"` (the default) | 19 MiB (`m=19456`) | 2 | 1 | [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), Argon2id: "m=19456 (19 MiB), t=2, p=1" |
|
|
537
|
+
| `"rfc9106-low-memory"` | 64 MiB (`m=65536`) | 3 | 4 | [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106.html) section 4, the second recommended option |
|
|
538
|
+
|
|
539
|
+
Both use a 16-byte random salt (RFC 9106: "16 bytes is RECOMMENDED for password hashing") and a 32-byte tag. RFC 9106's
|
|
540
|
+
first recommended option (2 GiB of memory per hash) is not offered: a server that signs users in concurrently cannot
|
|
541
|
+
spend 2 GiB on each.
|
|
542
|
+
|
|
543
|
+
- A hash is a PHC string, `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`. Verifying reads the parameters from the
|
|
544
|
+
stored string, so you can move to another profile later and every older hash still verifies. The hash is compared in
|
|
545
|
+
constant time.
|
|
546
|
+
- A sign-in whose password matches a hash stored at other parameters than your profile's (an older profile, a cheaper
|
|
547
|
+
or a costlier one) stores the password again at your profile, so active users' hashes follow a profile change. The
|
|
548
|
+
hook checks the password against the stored hash itself first, and writes only while the account still holds that
|
|
549
|
+
hash, so a password changed or reset meanwhile is never overwritten. A hash already at your profile costs one read
|
|
550
|
+
of the credential account per password sign-in, and no write. The one that moves costs two argon2id computations
|
|
551
|
+
more (the check again, and the new hash) and one write. The write goes through Better Auth's adapter, past its
|
|
552
|
+
`account` update hooks: it is the same password, stored anew. A failure is logged (never the password or a hash),
|
|
553
|
+
and the sign-in stands. An account with two-factor on may not be rehashed by a password sign-in its second step holds
|
|
554
|
+
back. Kiosk PINs move the same way at the next switch.
|
|
555
|
+
- Hashes Better Auth stored with scrypt (`<salt>:<key>` in hex) fail the check by default, with a warning in Better
|
|
556
|
+
Auth's log. With `legacyScrypt: true` they verify, through Better Auth's own `verifyPassword`, so an existing Better
|
|
557
|
+
Auth app can move to `createAccess` without resetting passwords, and a sign-in that matches one (`/sign-in/email`,
|
|
558
|
+
`/sign-in/username`, any `/sign-in/*` route that takes a password) stores the password again with argon2id. Each
|
|
559
|
+
such account leaves scrypt at its next sign-in. Once none is left, turn `legacyScrypt` off.
|
|
560
|
+
- The time a check takes does not say what an account holds. Every check costs one argon2id computation at your
|
|
561
|
+
profile, as Better Auth's hash of the guess costs for an unknown user, whether the stored value is an argon2id hash,
|
|
562
|
+
a scrypt hash or garbage. With `legacyScrypt: true`, every hash and check also costs one scrypt, so sign-up and
|
|
563
|
+
sign-in are about twice as slow while it is on. One exception: after you move to another profile, a hash stored at
|
|
564
|
+
the old one is checked at the old one's cost, so the time a sign-in takes can tell such an account apart (and that it
|
|
565
|
+
exists) until its user next signs in, which rehashes it at your profile. Active users' hashes move; an account
|
|
566
|
+
nobody signs in to keeps the old cost until its password is set again.
|
|
567
|
+
- A stored hash that costs more than the largest profile (`m` over 65536, `t` over 3, `p` over 4) is refused without
|
|
568
|
+
being computed, with a warning in Better Auth's log, so a tampered row cannot make a sign-in allocate gigabytes. A
|
|
569
|
+
corrupted or tampered value, or a failure inside the argon2 computation (logged as an error), fails the check like a
|
|
570
|
+
wrong password. A check never throws.
|
|
571
|
+
- A password longer than Better Auth's `emailAndPassword.maxPasswordLength` (128 by default) is never hashed: hashing
|
|
572
|
+
it is refused with Better Auth's `PASSWORD_TOO_LONG` (`400`), and checking it fails. Passwords are NFKC-normalised
|
|
573
|
+
before hashing, as Better Auth's scrypt does.
|
|
574
|
+
- Better Auth's plugins that hash or check a password (`admin`, `username`, `phone-number`, `email-otp`, two-factor)
|
|
575
|
+
use the same hook, so the profile applies to them too, whether or not `emailAndPassword.enabled` is set.
|
|
576
|
+
|
|
577
|
+
#### Node.js 22
|
|
578
|
+
|
|
579
|
+
argon2id runs on Node.js's own `crypto.argon2`, which Node.js 24.7.0 added, with no native dependency. Node.js 22 has
|
|
580
|
+
none, so there Access uses `@node-rs/argon2`, an optional peer you install yourself, at the exact version Access is
|
|
581
|
+
tested against:
|
|
582
|
+
|
|
583
|
+
```sh
|
|
584
|
+
npm install --save-exact @node-rs/argon2@2.2.2
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
It computes the same function, and Access writes and reads the PHC strings itself, so a hash made on Node.js 22 verifies
|
|
588
|
+
on Node.js 24 and back. On Node.js 24 it is never loaded, installed or not. On Node.js 22 without it, `createAccess`
|
|
589
|
+
refuses with `ACCESS_OPTIONS_INVALID`, unless you hash passwords yourself (`betterAuth.emailAndPassword.password`).
|
|
590
|
+
|
|
591
|
+
### What the instance holds
|
|
592
|
+
|
|
593
|
+
| Member | What it is |
|
|
594
|
+
|---|---|
|
|
595
|
+
| `access.handler` | Better Auth's request handler, `(Request) => Promise<Response>`, with Access's routes |
|
|
596
|
+
| `access.api` | Better Auth's server API, as `auth.api` |
|
|
597
|
+
| `access.options` | The options Better Auth runs with: your block plus the installed plugins (what `npx auth@~1.7.12 generate` reads) |
|
|
598
|
+
| `access.$Infer` | Types only: `Session`, `Me` (the principal) and `Claims` |
|
|
599
|
+
| `access.policy` | The policy engine. It is built on first read and is not enumerable. |
|
|
600
|
+
| `access.state({ headers })` | The request's state, `{ me }`: from a bearer token checked against Better Auth's key set, or (profiles `web` and `kiosk`) the session cookie; `null` when neither |
|
|
601
|
+
| `access.registry` | The registry's operator API, present only with `registry: { enabled: true }` |
|
|
602
|
+
| `access.client` | Where Access is mounted and the `$access` verbs it serves, for the framework's clients ([below](#the-access-verbs)); on the kiosk profile, the kiosk's verbs too |
|
|
603
|
+
| `access.kiosk` | The kiosk profile's operator API: `listDevices()` and `revokeDevice(deviceId)` ([The kiosk profile](#the-kiosk-profile)) |
|
|
604
|
+
|
|
605
|
+
A bearer token that does not verify gives `me: null`, so the policies then refuse the request.
|
|
606
|
+
|
|
607
|
+
### Tokens and sessions
|
|
608
|
+
|
|
609
|
+
`state` takes the request's `Authorization` header first. When the header is there, the session cookie is never read:
|
|
610
|
+
a valid `Bearer` token gives its identity, and anything else (another scheme, a malformed token, an empty header) gives
|
|
611
|
+
`me: null`.
|
|
612
|
+
|
|
613
|
+
A token is accepted when all of this holds:
|
|
614
|
+
|
|
615
|
+
- it is signed by a key of the instance's key set (`GET /api/auth/jwks`). Access reads the set once, reads it again
|
|
616
|
+
every 5 minutes, and at once (no more often than every 5 seconds) for a token signed with a key it does not hold. A
|
|
617
|
+
key removed from the set stops verifying within 5 minutes. When a read fails, the last set read is kept;
|
|
618
|
+
- its `iss` and its `aud` are both the instance's issuer, the base URL's origin, fixed when the instance was built. An
|
|
619
|
+
audience list that merely includes it is refused;
|
|
620
|
+
- it has `sub`, `exp` and `iat`, it has not expired, and it was issued no longer ago than `tokenTtl` and not in the
|
|
621
|
+
future. 5 seconds of clock skew are allowed;
|
|
622
|
+
- it carries no claim beyond the declared ones.
|
|
623
|
+
|
|
624
|
+
The session cookie is read from Better Auth's store, never from its cookie cache, so a session you revoke (a sign-out,
|
|
625
|
+
a revoked session, a deleted user) gives `me: null` at once. With `session.cookieCache` on, `GET /token` and the
|
|
626
|
+
`set-auth-jwt` header issue a token only for a session the store still has.
|
|
627
|
+
|
|
628
|
+
On a request that may change something (any method but `GET`, `HEAD` and `OPTIONS`, and a request whose method the
|
|
629
|
+
host did not pass), the cookie counts only when the request comes from a site Better Auth trusts: its `Origin` is your
|
|
630
|
+
base URL's or one of `trustedOrigins`, or, with no `Origin`, its `Sec-Fetch-Site` is `same-origin` or `none`. A request
|
|
631
|
+
with neither header is no browser's, and its cookie is read.
|
|
632
|
+
|
|
633
|
+
A token is not revoked. One issued before a sign-out, a revoked session or a deleted user keeps verifying until it
|
|
634
|
+
expires, for at most `tokenTtl` (15 minutes by default). Keep `tokenTtl` short when that matters; a service that must
|
|
635
|
+
see a sign-out at once reads the session instead.
|
|
636
|
+
|
|
637
|
+
The session cookie is `SameSite=Lax`, Better Auth's default: a browser sends it on another site's links, never on
|
|
638
|
+
another site's `POST` or `fetch`. Better Auth checks the `Origin` of its own routes that carry a cookie, and the
|
|
639
|
+
framework's API takes JSON, which a cross-site HTML form cannot send. Keep it that way: an endpoint of your own that
|
|
640
|
+
acts on a `GET`, or takes a form body, with the cookie as its credential, is open to cross-site requests. A session
|
|
641
|
+
cookie set to `sameSite: "none"` (in `advanced.defaultCookieAttributes` or `advanced.cookies.session_token`) is
|
|
642
|
+
refused with `ACCESS_OPTIONS_INVALID` unless `trustedOrigins` names the origins your app is served from. With them,
|
|
643
|
+
`state` holds every request to the trusted-site rule above, `GET` included.
|
|
644
|
+
|
|
645
|
+
## Mount the routes
|
|
646
|
+
|
|
647
|
+
Access's routes are Better Auth's, plus its own, and they mount the way Better Auth's do. Better Auth's own helpers take
|
|
648
|
+
the Access instance as it is. The framework's hosts mount no Access route.
|
|
649
|
+
|
|
650
|
+
### Next.js
|
|
651
|
+
|
|
652
|
+
The catch-all route file, as in Better Auth's Next.js docs:
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
// src/app/api/auth/[...all]/route.ts
|
|
656
|
+
import { toNextJsHandler } from "better-auth/next-js";
|
|
657
|
+
import { access } from "@/access";
|
|
658
|
+
|
|
659
|
+
export const { GET, POST } = toNextJsHandler(access);
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
A Next.js server bundle carries Better Auth (Turbopack or webpack, and `output: "standalone"` too). Next.js evaluates
|
|
663
|
+
your `access.ts` once per bundle, so one process can hold several Access instances. That is expected: each one reads
|
|
664
|
+
Better Auth's key set from your database once.
|
|
665
|
+
|
|
666
|
+
### NestJS, Express and Fastify
|
|
667
|
+
|
|
668
|
+
Mount Better Auth's Node.js handler before the body parser. In NestJS, that is `app.use()` between
|
|
669
|
+
`NestFactory.create()` and `listen()`, with a path test. It works on Express and Fastify alike:
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
// src/main.ts
|
|
673
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
674
|
+
import { NestFactory } from "@nestjs/core";
|
|
675
|
+
import { toNodeHandler } from "better-auth/node";
|
|
676
|
+
import { access } from "./access.js";
|
|
677
|
+
import { AppModule } from "./app.module.js";
|
|
678
|
+
|
|
679
|
+
async function bootstrap() {
|
|
680
|
+
const app = await NestFactory.create(AppModule);
|
|
681
|
+
// Access's routes, under /api/auth, before Nest's body parser (listen() adds it).
|
|
682
|
+
const auth = toNodeHandler(access);
|
|
683
|
+
app.use(
|
|
684
|
+
(request: IncomingMessage, response: ServerResponse, next: () => void) =>
|
|
685
|
+
request.url?.startsWith("/api/auth/") ? auth(request, response) : next(),
|
|
686
|
+
);
|
|
687
|
+
await app.listen(process.env.PORT ?? 3000);
|
|
688
|
+
}
|
|
689
|
+
await bootstrap();
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
On Fastify, `app.use("/api/auth", …)` hands Better Auth the URL without its base path, so use the path test above. Your
|
|
693
|
+
own controllers keep Nest's body parser. In a plain Express app, mount `toNodeHandler(access)` the same way, before
|
|
694
|
+
`express.json()`.
|
|
695
|
+
|
|
696
|
+
If you enable CORS, register it before this mount, so Access's routes answer the same origins.
|
|
697
|
+
|
|
698
|
+
### Standalone
|
|
699
|
+
|
|
700
|
+
`serve` runs Access as its own HTTP server around the same handler, with `GET /health` answering
|
|
701
|
+
`200 {"status":"ok"}`:
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
// src/access.server.ts
|
|
705
|
+
import { serve } from "@aventara/access/standalone";
|
|
706
|
+
import access from "../access.config.js";
|
|
707
|
+
|
|
708
|
+
const server = await serve(access, { port: 4000 });
|
|
709
|
+
console.log(`Access listens on ${server.url}`);
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
`port` defaults to `3000`, and `0` picks a free one. `hostname` defaults to `"127.0.0.1"`, this host only; pass
|
|
713
|
+
`"0.0.0.0"` to listen on every interface. `serve` resolves to `{ port, url, close() }` once the server listens.
|
|
714
|
+
`avaccess serve` runs the same server from a configuration file ([Running `avaccess serve`](#running-avaccess-serve)).
|
|
715
|
+
|
|
716
|
+
`serve` speaks plain HTTP. Sessions, tokens and registry keys travel in headers, so put it behind a proxy that
|
|
717
|
+
terminates TLS before anything outside the host reaches it. What it does for the handler behind it:
|
|
718
|
+
|
|
719
|
+
- A request body over `maxBodyBytes` (default 1 MiB, `1048576`) is refused with `413` before it is handed on: by its
|
|
720
|
+
`Content-Length` without reading it, or as soon as a streamed body passes the cap. A push to the registry has its
|
|
721
|
+
key checked before its body is read.
|
|
722
|
+
- The client's address is the socket's. `X-Forwarded-For` (or the first of Better Auth's
|
|
723
|
+
`advanced.ipAddress.ipAddressHeaders`) is replaced with it, unless the socket is a proxy listed in Better Auth's
|
|
724
|
+
`advanced.ipAddress.trustedProxies`: then the proxy's header is kept, and Better Auth reads the client from it.
|
|
725
|
+
Better Auth's rate limit counts each client on its own, and no client picks its own address.
|
|
726
|
+
- `close()` stops accepting connections, lets the requests in flight finish for up to 10 seconds, then ends the rest.
|
|
727
|
+
|
|
728
|
+
### Sign up and sign in
|
|
729
|
+
|
|
730
|
+
Better Auth's routes are under `/api/auth`. With `emailAndPassword` on, from a terminal:
|
|
731
|
+
|
|
732
|
+
```sh
|
|
733
|
+
# Sign up: the user is created and signed in.
|
|
734
|
+
curl -X POST http://localhost:3000/api/auth/sign-up/email \
|
|
735
|
+
-H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
|
|
736
|
+
-d '{ "email": "ada@example.com", "password": "correct-horse-battery", "name": "Ada" }'
|
|
737
|
+
|
|
738
|
+
# Sign in: the session cookie goes into cookies.txt.
|
|
739
|
+
curl -c cookies.txt -X POST http://localhost:3000/api/auth/sign-in/email \
|
|
740
|
+
-H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
|
|
741
|
+
-d '{ "email": "ada@example.com", "password": "correct-horse-battery" }'
|
|
742
|
+
|
|
743
|
+
# A token for a service: the session cookie traded for a JWT, sent as a bearer token.
|
|
744
|
+
curl -b cookies.txt http://localhost:3000/api/auth/token
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
A browser's `fetch` to the same routes sends `Origin` itself.
|
|
748
|
+
|
|
749
|
+
With `emailAndPassword` on, anyone who reaches the server can sign up. A server whose users are created another way
|
|
750
|
+
(an admin, an invitation, a social provider) sets `emailAndPassword: { enabled: true, disableSignUp: true }`, and a
|
|
751
|
+
server that signs in no one, such as one that only runs the registry, leaves `emailAndPassword` out.
|
|
752
|
+
|
|
753
|
+
Send an `Origin` header that matches Better Auth's base URL (`BETTER_AUTH_URL`) or one of its `trustedOrigins`. A
|
|
754
|
+
request that carries a cookie is refused `403 MISSING_OR_NULL_ORIGIN` without one, and `403 INVALID_ORIGIN` with
|
|
755
|
+
another. The Aventara API then reads the user from the session cookie (profiles `web` and `kiosk`) or from
|
|
756
|
+
`Authorization: Bearer <token>`.
|
|
757
|
+
|
|
758
|
+
## Wire it into Aventara
|
|
759
|
+
|
|
760
|
+
The instance fits the framework's `access` option (`AvAccessProvider`) by shape: it brings the policy engine and each
|
|
761
|
+
request's `me`. With `access`, the configuration has no `policy` and the host has no `state` option.
|
|
762
|
+
|
|
763
|
+
### Next.js
|
|
764
|
+
|
|
765
|
+
Put `access` in `aventaraConfig`, in `src/aventara/config.ts`, not in `server.ts`:
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
// src/aventara/config.ts
|
|
769
|
+
import { access } from "../access";
|
|
770
|
+
|
|
771
|
+
export const aventaraConfig = {
|
|
772
|
+
api: "/api",
|
|
773
|
+
exposed: true,
|
|
774
|
+
// The rest of the configuration `aventara new` wrote stays as it is.
|
|
775
|
+
access,
|
|
776
|
+
} as const;
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
The build (`aventara:prepare`, which `next build` runs first) reads `config.ts` to compile the Contracts. With `access`
|
|
780
|
+
there, the prebuilt Contracts match what the server runs, and the governed fields reach the generated client. Given
|
|
781
|
+
only in `server.ts`, the framework warns `AVENTARA_PREBUILT_STALE` and compiles the Contracts at startup. `server.ts`
|
|
782
|
+
needs no change: it already spreads `aventaraConfig` into `Aventara()`.
|
|
783
|
+
|
|
784
|
+
`aventara-build` runs `config.ts`, and so your `access.ts`, at build time. It reads the project's `.env` files first,
|
|
785
|
+
as `avclient generate` does (a variable already set wins, then `.env.<mode>.local`, `.env.<mode>`, `.env.local` and
|
|
786
|
+
`.env`), so a database client that reads `DATABASE_URL` at import finds it.
|
|
787
|
+
|
|
788
|
+
### NestJS
|
|
789
|
+
|
|
790
|
+
Put `access` beside `adapter` in the `config` that the scaffold's `forRootAsync` factory returns. This is the scaffold's
|
|
791
|
+
`src/app.module.ts` with the two `access` lines added:
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
// src/app.module.ts
|
|
795
|
+
import { Module } from '@nestjs/common';
|
|
796
|
+
import { AppController } from './app.controller.js';
|
|
797
|
+
import { AppService } from './app.service.js';
|
|
798
|
+
import { AventaraModule } from '@aventara/nest';
|
|
799
|
+
import { createPrismaAdapter } from '@aventara/prisma7-adapter';
|
|
800
|
+
import adapter from './aventara.adapter.js';
|
|
801
|
+
import { aventaraConfig } from './aventara.config.js';
|
|
802
|
+
import { PrismaModule, PrismaService } from './prisma.service.js';
|
|
803
|
+
import { access } from './access.js';
|
|
804
|
+
|
|
805
|
+
@Module({
|
|
806
|
+
imports: [
|
|
807
|
+
PrismaModule,
|
|
808
|
+
AventaraModule.forRootAsync({
|
|
809
|
+
imports: [PrismaModule],
|
|
810
|
+
inject: [PrismaService],
|
|
811
|
+
useFactory: async (prisma: PrismaService) => ({
|
|
812
|
+
config: {
|
|
813
|
+
...aventaraConfig,
|
|
814
|
+
adapter: await createPrismaAdapter({
|
|
815
|
+
...adapter,
|
|
816
|
+
client: prisma,
|
|
817
|
+
}),
|
|
818
|
+
access,
|
|
819
|
+
},
|
|
820
|
+
}),
|
|
821
|
+
}),
|
|
822
|
+
],
|
|
823
|
+
controllers: [AppController],
|
|
824
|
+
providers: [AppService],
|
|
825
|
+
})
|
|
826
|
+
export class AppModule {}
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Here `aventaraConfig` is the configuration without `policy`, since Access brings the engine. Nest compiles its
|
|
830
|
+
Contracts at startup, so nothing goes stale.
|
|
831
|
+
|
|
832
|
+
For the MCP endpoint, see [Agents over MCP](#agents-over-mcp): `mcp: { path: "/mcp", authorization: access.mcp }`.
|
|
833
|
+
|
|
834
|
+
### Anywhere else
|
|
835
|
+
|
|
836
|
+
`createFramework({ ...config, access })`.
|
|
837
|
+
|
|
838
|
+
With the Better Auth plugin, `accessOf(auth)` is the `access` value, in each of the places above.
|
|
839
|
+
|
|
840
|
+
### The `$access` verbs
|
|
841
|
+
|
|
842
|
+
`access.client` is what the framework's `$access` reads (the provider's `client` member): where Access is mounted, and
|
|
843
|
+
the verbs this instance serves. `url` is Better Auth's base URL with its base path (`http://localhost:3000/api/auth`),
|
|
844
|
+
or the base path alone (`/api/auth`) when `baseURL` is dynamic and follows the request's host. Each verb has a method,
|
|
845
|
+
a path under `url`, the JSON Schema of its arguments and of its result, and `call`:
|
|
846
|
+
|
|
847
|
+
| Verb | Route | Arguments | Result |
|
|
848
|
+
|---|---|---|---|
|
|
849
|
+
| `signIn` | `POST /sign-in/email` | `email`, `password`, optional `callbackURL`, `rememberMe` | `redirect`, `url`, `user: { id }` |
|
|
850
|
+
| `signUp` | `POST /sign-up/email` | `name`, `email`, `password`, optional `image`, `callbackURL`, `rememberMe` | `user: { id }` |
|
|
851
|
+
| `signOut` | `POST /sign-out` | none | `success` |
|
|
852
|
+
| `resetPassword` | `POST /reset-password` | `newPassword`, `token` | `status` |
|
|
853
|
+
| `me` | `GET /me` | none | `{ sub, claims }`, or `null` when no one is signed in |
|
|
854
|
+
|
|
855
|
+
The arguments are the ones Better Auth's client sends on each route. A result names the user's id and Better Auth's own
|
|
856
|
+
flags, never the session token or a field of the user. `me` is the token identity: `sub` and the claims
|
|
857
|
+
`policies.claims` declares, the same as the token carries, whatever `principal` maps the request to. `GET /api/auth/me`
|
|
858
|
+
is Access's own route, served by `access.handler`; a plugin of yours with an endpoint at `/me` (or named `accessMe`) is
|
|
859
|
+
refused with `ACCESS_PLUGIN_DUPLICATE`.
|
|
860
|
+
|
|
861
|
+
A verb the instance does not serve is left out: `signIn`, `signUp` and `resetPassword` need `emailAndPassword`,
|
|
862
|
+
`signUp` is left out with `disableSignUp`, a path in `disabledPaths` takes its verb out, and the `service` profile has
|
|
863
|
+
no `signIn` or `signUp`.
|
|
864
|
+
|
|
865
|
+
`call({ args, headers })` runs the verb on the server, in the same process, through Better Auth's API with the
|
|
866
|
+
request's headers. That is how a Server Action signs a user in: Better Auth's `nextCookies()` plugin, last in
|
|
867
|
+
`betterAuth.plugins`, sets the browser's cookie from the answer. The call is checked as the route would be: the
|
|
868
|
+
arguments against the verb's schema (`400` naming the key, never the value), Better Auth's origin check (a cookie from
|
|
869
|
+
a site Better Auth does not trust, or a `callbackURL` outside `trustedOrigins`, is refused `403`), every plugin's
|
|
870
|
+
`onRequest` in the order Better Auth's router runs them (so `captcha()` refuses a call without its answer, as it refuses
|
|
871
|
+
the route; a plugin's refusal is thrown as Better Auth's `APIError` with the plugin's status and body, and a plugin's new
|
|
872
|
+
request is the one the route runs with), the profile's refusals, and the audit log, which records the sign-in with
|
|
873
|
+
origin `client`.
|
|
874
|
+
|
|
875
|
+
Better Auth's rate limit runs in its router, which a call to Better Auth's API skips, so Access limits the calls of
|
|
876
|
+
`signIn`, `signUp` and `resetPassword` itself, with Better Auth's own `rateLimit` options: on when they turn it on
|
|
877
|
+
(`enabled`, production by default), with the rule Better Auth's router applies to the route (3 tries in 10 seconds on
|
|
878
|
+
`/sign-in/*` and `/sign-up/*` by default, a plugin's rules, `customRules`, else `window` and `max`), in the storage they
|
|
879
|
+
name (`customStorage`, `secondaryStorage`, the database, or memory). Each call is counted in two buckets, and refused
|
|
880
|
+
when either is full:
|
|
881
|
+
|
|
882
|
+
- **by client address**, read from the request's headers as Better Auth reads it (`advanced.ipAddress`: its
|
|
883
|
+
`ipAddressHeaders`, `trustedProxies` and `ipv6Subnet`), under the key Better Auth's router uses for the route. With
|
|
884
|
+
the database, a secondary or a custom storage, a client's calls and its requests to the route spend one budget; in
|
|
885
|
+
memory they do not, since Better Auth's memory store is its own. With `disableIpTracking` there is no address bucket;
|
|
886
|
+
- **by target**: the email the call names (normalized as `/sign-in/email` normalizes it), or a password reset's token,
|
|
887
|
+
as its HMAC, so guesses on one account from many addresses count together. It counts failures: 10, each within 5
|
|
888
|
+
minutes of the one before, refuse the account's next try, the right password too, until 5 minutes have passed. A
|
|
889
|
+
success clears the count, so a few failures by someone else never keep the owner out. A try is counted when it is let
|
|
890
|
+
through, so parallel guesses cannot all pass on one reading. With `customStorage`, which can count but not clear, the
|
|
891
|
+
target's count is kept in the server's memory.
|
|
892
|
+
|
|
893
|
+
A refused call throws Better Auth's own `429` (`APIError` `TOO_MANY_REQUESTS`, with `X-Retry-After`), and the audit
|
|
894
|
+
log records a **sign-in-failed** with reason `rate-limited`. Give the server the client's address: behind a proxy, set
|
|
895
|
+
`trustedProxies`, or calls whose address cannot be read share one bucket per route, as Better Auth's requests do.
|
|
896
|
+
|
|
897
|
+
### The kiosk profile
|
|
898
|
+
|
|
899
|
+
`profile: { kind: "kiosk", canEnrollDevice, idleTimeout?, insecureLan? }` is the web profile plus staff switching by PIN on shared
|
|
900
|
+
devices, such as the tills of a shop. A manager enrolls a device once; after that, anyone with a PIN switches the
|
|
901
|
+
device to themselves with their user id and PIN, and the session that opens is bound to that device and locks when
|
|
902
|
+
idle.
|
|
903
|
+
|
|
904
|
+
| Option | Takes | Default |
|
|
905
|
+
|---|---|---|
|
|
906
|
+
| `canEnrollDevice` | `(identity) => boolean`: who may enroll, list or revoke devices, asked with `{ sub, claims }` (typed from `policies.claims`) of a fresh session opened with full credentials. Anything but `true` refuses. | required |
|
|
907
|
+
| `idleTimeout` | Seconds without a request that may change something (any method but GET, HEAD or OPTIONS) after which a session on the device locks: whole seconds from 60 to 3600 | 300 |
|
|
908
|
+
| `insecureLan` | The private IPv4 ranges of a till network without TLS, on whose addresses a plain http: base URL is taken ([A till network without TLS](#a-till-network-without-tls)) | none |
|
|
909
|
+
|
|
910
|
+
`profile` takes one kind. The kiosk's keys on `web` or `service`, or two kinds at once (`kind: ["kiosk",
|
|
911
|
+
"service"]`), are a type error and are refused with `ACCESS_OPTIONS_INVALID`: a service hands no caller a session,
|
|
912
|
+
and a kiosk is made of them. The kiosk also refuses, each because it is a way around the device binding:
|
|
913
|
+
|
|
914
|
+
- Better Auth's cookie cache (a locked session must end at once, and the cookie cache would keep answering it);
|
|
915
|
+
- `secondaryStorage` without `session.storeSessionInDatabase: true` (the kiosk finds a device's sessions in the
|
|
916
|
+
database to end them);
|
|
917
|
+
- Better Auth's `bearer()` (it hands a session's token to page code and takes it back from an `Authorization` header,
|
|
918
|
+
where the device's credential does not go). Under any other id, no answer carries its `set-auth-token` header;
|
|
919
|
+
- Better Auth's `multiSession()` (it keeps the session before a switch in the browser and switches back to it);
|
|
920
|
+
- `advanced.crossSubDomainCookies`, and `SameSite=None` in `advanced.defaultCookieAttributes` or on any cookie in
|
|
921
|
+
`advanced.cookies` (the credential and the sessions would reach other hosts, or come from other sites);
|
|
922
|
+
- a plain http: base URL, except on a loopback host (`localhost`, 127.0.0.0/8, `[::1]`) or on an address
|
|
923
|
+
`insecureLan` lists: the credential and the PIN sessions would cross the network in the clear. A dynamic `baseURL`
|
|
924
|
+
is read whole: it takes `protocol: "https"`, unless every allowed host is a loopback host.
|
|
925
|
+
|
|
926
|
+
On the web and service profiles there is no PIN route: each answers `404`.
|
|
927
|
+
|
|
928
|
+
The routes, under Better Auth's base path, and the verbs `access.client.verbs` publishes for them on this profile,
|
|
929
|
+
after Better Auth's (the framework's `$access` takes them as optional verbs):
|
|
930
|
+
|
|
931
|
+
| Verb | Route | Arguments | Result | Who |
|
|
932
|
+
|---|---|---|---|---|
|
|
933
|
+
| `enrollDevice` | `POST /kiosk/enroll-device` | `name` | `device: { id, name }` | A fresh full-credential session `canEnrollDevice` allows |
|
|
934
|
+
| `revokeDevice` | `POST /kiosk/revoke-device` | `deviceId` | `success` | The same |
|
|
935
|
+
| `setPin` | `POST /kiosk/set-pin` | `pin` | `success` | A fresh full-credential session, for its own user |
|
|
936
|
+
| `switchUser` | `POST /kiosk/switch-user` | `userId`, `pin` | `user: { id }` | An enrolled device |
|
|
937
|
+
| `lock` | `POST /kiosk/lock` | none | `success` | An enrolled device |
|
|
938
|
+
|
|
939
|
+
`GET /kiosk/list-devices` answers a manager (the same as `enrollDevice`) with `devices: [{ id, name, enrolledBy,
|
|
940
|
+
enrolledAt, lastSwitchAt? }]`, never a credential. It is a route only: the framework's set of verbs is closed. Call the
|
|
941
|
+
verbs' routes from the device, or their `call` from your server. A `call` of `switchUser`, `lock` or `enrollDevice`
|
|
942
|
+
sets the session cookie and the device's credential through your cookie plugin (Better Auth's `nextCookies()` in a
|
|
943
|
+
Server Action), as `signIn`'s does. Without one the browser keeps the credential the switch replaced, and the device's
|
|
944
|
+
next switch is refused as a copy: call the route from the device instead.
|
|
945
|
+
|
|
946
|
+
Your server has its own way in, which no route opens:
|
|
947
|
+
|
|
948
|
+
| Call | Does |
|
|
949
|
+
|---|---|
|
|
950
|
+
| `access.kiosk.listDevices()` | The enrolled devices, as `GET /kiosk/list-devices` answers them (dates as `Date`) |
|
|
951
|
+
| `access.kiosk.revokeDevice(deviceId)` | Revokes a device, ending every session on it; `false` when there is none |
|
|
952
|
+
| `access.api.kioskSetUserPin({ body: { userId, pin } })` | Sets anyone's PIN, so staff who have no password get one from a manager's screen your app guards. Better Auth's server-only endpoint: its router never serves it, and a call with a PIN session's headers is refused |
|
|
953
|
+
|
|
954
|
+
`access.kiosk` is there on the kiosk profile only.
|
|
955
|
+
|
|
956
|
+
- **A fresh full-credential session** is one opened with a password (or any sign-in but a PIN) within Better Auth's
|
|
957
|
+
`session.freshAge`, one day by default. A PIN session never enrolls, lists, revokes or sets a PIN.
|
|
958
|
+
- **Enrolling** gives the device its credential, 32 random bytes in an HTTP-only cookie (`better-auth.kiosk_device`,
|
|
959
|
+
kept 400 days, always `SameSite=Lax`; on an https base URL, `__Host-better-auth.kiosk_device`, `Secure`, at `/` and
|
|
960
|
+
with no `Domain`, whatever Better Auth's cookie options say; under `insecureLan`, `better-auth.kiosk_device`, not
|
|
961
|
+
`Secure`, at `/` and with no `Domain`). A Strict device cookie would not be sent on a link the
|
|
962
|
+
till opens from another site (a magic link from webmail), and the session it opens would not be bound. Enrolling
|
|
963
|
+
ends the session that enrolled it, so no manager's session stays on
|
|
964
|
+
a shared device: the device starts locked. Enrolling a device that already has a credential revokes the old
|
|
965
|
+
enrollment first.
|
|
966
|
+
- **A PIN** is 6 to 12 digits, refused when it is set (`400 PIN_TOO_WEAK`) if it is one a guesser tries first: the
|
|
967
|
+
blocklist Apple's iOS uses for 6-digit passcodes (as Markert et al. extracted it, IEEE S&P 2020), widened to every
|
|
968
|
+
length (one digit or a block repeated, `000000`, `121212`, `123123`; two digits alternating; a palindrome,
|
|
969
|
+
`123321`; a straight run, `123456`; doubled digits, `112233`; lines of the keypad, `147258`, `159753`), and the
|
|
970
|
+
most common 6-digit PINs of leaked passwords (`789456`, `246810`, `520131`). Dates are not refused. It is hashed with
|
|
971
|
+
argon2id at the `passwords` profile, whatever hashes your users' passwords, and never stored, logged or answered.
|
|
972
|
+
Setting a PIN clears its user's failed PINs.
|
|
973
|
+
- **A switch** checks the device's credential, then the PIN. A right one ends every session on the device, and any
|
|
974
|
+
other session the request carried, and opens a new one for the user named, with that user's own claims: nothing of
|
|
975
|
+
the session before it carries over. The device's credential is replaced in the same answer, so a copy of the old one
|
|
976
|
+
is spent. The audit log records a `kiosk-switch` naming the user before (`previousUserId`) and after. A session
|
|
977
|
+
Better Auth refuses to open (a user the admin plugin banned, or your own session hook) leaves the device as it was.
|
|
978
|
+
- **Lock** ends the same sessions and keeps the device enrolled. A session on the device idle for `idleTimeout` is
|
|
979
|
+
ended on its next request, as if locked (`kiosk-lock`, reason `idle`). Only a request that may change something
|
|
980
|
+
counts as use: any method but GET, HEAD or OPTIONS, through Better Auth's routes or through the framework
|
|
981
|
+
(`access.state` reads the host's method). A page left open that keeps reading, such as Better Auth's client polling
|
|
982
|
+
`/get-session`, does not keep its session alive.
|
|
983
|
+
- **Revoking** a device ends every session on it at once. Nothing is pushed to the till: it learns on its next
|
|
984
|
+
request, which finds no session, and its next switch is refused `KIOSK_DEVICE_NOT_ENROLLED`.
|
|
985
|
+
|
|
986
|
+
What protects a PIN, a weak secret:
|
|
987
|
+
|
|
988
|
+
- **Per user, on one device**: 5 wrong PINs free, then one try per backoff that doubles from 1 minute to 1 hour
|
|
989
|
+
(`429 KIOSK_PIN_LOCKED`, with `X-Retry-After`), the right PIN included. After 100 in a row (NIST SP 800-63B's
|
|
990
|
+
ceiling) the PIN alone stops working on that device (`403 KIOSK_PIN_DISABLED`); the backoff makes that take days,
|
|
991
|
+
every try logged. It is kept per device, so whoever stands at a device can slow or disable a colleague's PIN on that
|
|
992
|
+
device only. On every other device it still works, and the user's next full sign-in, or a new PIN, clears it
|
|
993
|
+
everywhere. An admin's impersonation is no full sign-in and clears nothing. The account itself is never locked.
|
|
994
|
+
- **Per device**: 10 wrong PINs at once, for any users, then one more every 90 seconds (a token bucket, so no
|
|
995
|
+
window's edge lets a second burst through). A right PIN gives back its own try and no more, so knowing one PIN does
|
|
996
|
+
not reset the count between guesses at others. While the device is held, a user with no wrong PIN on it since their
|
|
997
|
+
last right one still gets one try, so one person spending a till's tries does not lock everyone else out of it.
|
|
998
|
+
- Both are counted in the database before the PIN is checked, so parallel guesses cannot all pass one reading, and they
|
|
999
|
+
hold across restarts and instances. They are kept for any user id, whether it holds a PIN or names no user, and a
|
|
1000
|
+
user id with no PIN costs the same argon2id check as one with a PIN, so no answer tells who holds a PIN.
|
|
1001
|
+
|
|
1002
|
+
What binds a session to its device:
|
|
1003
|
+
|
|
1004
|
+
- **Every session opened on an enrolled device is bound to it**: a PIN switch's, and a password sign-in's on the
|
|
1005
|
+
device too. A bound session is held to the idle timeout, is issued no token (`GET /token` refuses it), and
|
|
1006
|
+
`/get-session` answers it without its token, so page code never holds one. With `mcp` or `audience`, that includes
|
|
1007
|
+
the OAuth provider's tokens: a bound session is refused at `/oauth2/authorize`, `/oauth2/consent` and
|
|
1008
|
+
`/oauth2/continue`, a sign-in on an enrolled device that resumes an authorization request (the login page's
|
|
1009
|
+
`oauth_query`) is refused before it runs, a social sign-in begun elsewhere with one, whose callback lands on the
|
|
1010
|
+
till, opens no session there, and a token issued in a bound session anyway verifies as nobody. A bound session is
|
|
1011
|
+
refused at the provider's routes that manage clients and consents too (`/oauth2/create-client`,
|
|
1012
|
+
`/oauth2/get-consents`, `/oauth2/delete-consent` and the like). An agent is authorized, and its clients managed,
|
|
1013
|
+
from a manager's own browser, off the till.
|
|
1014
|
+
- **The device's credential, on every request.** On every call of Better Auth's that carries a session, by its cookie
|
|
1015
|
+
or by an `Authorization: Bearer` header (its routes, its API with headers or a `request`, `access.state` and `GET /me`), a bound
|
|
1016
|
+
session is ended, and the call goes on without it, unless the request brings its device's current credential
|
|
1017
|
+
(`kiosk-lock`, reason `device-mismatch`). A session copied to another browser, or to another enrolled device, is no
|
|
1018
|
+
session there, and no longer one on the device it came from. A session bound to no device, brought with an enrolled
|
|
1019
|
+
device's credential (current, or one a switch replaced), is ended the same way: it was opened off the device, so it
|
|
1020
|
+
is no session on it.
|
|
1021
|
+
- **One credential at a time, and its whole family remembered.** The credential is replaced at every switch, and every
|
|
1022
|
+
credential a switch replaced is kept as a hash for 400 days, the device cookie's life. Any of them brought back revokes the device with every session on it
|
|
1023
|
+
(`kiosk-device`, reason `token-reuse`): a copy is in use somewhere, however many switches ago it was taken. The one
|
|
1024
|
+
exception is the credential the last switch replaced, within 30 seconds of it: that is a request that left before
|
|
1025
|
+
the device had its answer (a double tap, two calls at once), and it is refused (`409 KIOSK_DEVICE_BUSY`, logged as
|
|
1026
|
+
`kiosk-device`, reason `stale-in-grace`) with the device and its cookie left as they are; so is a sign-in that brings
|
|
1027
|
+
it, which opens no session. A device whose answer was lost in transit is revoked after those 30 seconds,
|
|
1028
|
+
and a manager enrolls it again.
|
|
1029
|
+
- **No way out of the device.** A PIN session reaches only `/get-session`, `/sign-out`, `/me`, `/jwks`,
|
|
1030
|
+
`/kiosk/switch-user`, `/kiosk/lock` and the routes that sign in with full credentials, whether the call comes
|
|
1031
|
+
through `access.handler` or your server's own `access.api` call with its cookie; any other is refused `403
|
|
1032
|
+
KIOSK_SESSION_NOT_ALLOWED`, so it mints no token, no other session (one-time tokens, impersonation), no credential
|
|
1033
|
+
(passkeys, API keys, linked accounts) and changes no account.
|
|
1034
|
+
|
|
1035
|
+
The kiosk's tables are `accessKioskDevice`, `accessKioskPin`, `accessKioskLockout` and `accessKioskRetiredSecret`,
|
|
1036
|
+
and Better Auth's `session` gains `kioskDeviceId`, `kioskSeenAt` and `kioskPin` (no user sets them). `npx auth@~1.7.12
|
|
1037
|
+
generate` writes them as it writes Better Auth's own; off Prisma's adapter, name them in `resources.authTables`. Like
|
|
1038
|
+
Access's other tables they stay closed to every request, and a credential's or a PIN's hash is a secret column no grant
|
|
1039
|
+
opens.
|
|
1040
|
+
|
|
1041
|
+
| Code | Status | When |
|
|
1042
|
+
|---|---|---|
|
|
1043
|
+
| `KIOSK_DEVICE_NOT_ENROLLED` | 401 | The request brings no current device credential |
|
|
1044
|
+
| `INVALID_USER_OR_PIN` | 401 | A wrong PIN, a user with none, or no such user (also `400` for a PIN that is not 6 to 12 digits) |
|
|
1045
|
+
| `KIOSK_PIN_LOCKED` | 429 | The user's backoff on this device, or the device's limit; `X-Retry-After` says when |
|
|
1046
|
+
| `KIOSK_PIN_DISABLED` | 403 | 100 wrong PINs in a row for this user on this device; a full sign-in or a new PIN restores it |
|
|
1047
|
+
| `KIOSK_DEVICE_BUSY` | 409 | Two switches on one device at the same moment, or a request or sign-in with the credential the last switch replaced, within 30 seconds of it; try again |
|
|
1048
|
+
| `KIOSK_FULL_CREDENTIAL_REQUIRED`, `SESSION_NOT_FRESH` | 403 | Enrolling, listing, revoking or setting a PIN from a PIN session, or a session older than `freshAge` |
|
|
1049
|
+
| `KIOSK_ENROLL_REFUSED` | 403 | `canEnrollDevice` did not answer `true` |
|
|
1050
|
+
| `KIOSK_SESSION_NOT_ALLOWED` | 403 | A PIN session on a route it does not reach, a device-bound session asking for a token or managing OAuth clients or consents, or a sign-in on an enrolled device that would resume an authorization request |
|
|
1051
|
+
| `KIOSK_DEVICE_NOT_FOUND` | 404 | Revoking a device that is not enrolled |
|
|
1052
|
+
| `USER_NOT_FOUND` | 404 | `kioskSetUserPin` for no such user |
|
|
1053
|
+
| `PIN_TOO_WEAK` | 400 | A PIN that is not 6 to 12 digits, or one a guesser tries first |
|
|
1054
|
+
|
|
1055
|
+
A plugin in `betterAuth.plugins` with an endpoint under `/kiosk/` (or named `kioskSwitchUser` and the like) is refused
|
|
1056
|
+
with `ACCESS_PLUGIN_DUPLICATE` on this profile.
|
|
1057
|
+
|
|
1058
|
+
#### A till network without TLS
|
|
1059
|
+
|
|
1060
|
+
An on-prem shop may run its server on the tills' own network: a router of its own, WPA3, with nothing else on
|
|
1061
|
+
it, and no certificate a tablet would trust. `insecureLan` lists that network's ranges, and the kiosk then takes a
|
|
1062
|
+
plain http: base URL on an address inside one, such as `baseURL: "http://192.168.1.10:3000"` with
|
|
1063
|
+
`insecureLan: ["192.168.1.0/24"]`. Everything that crosses that network does so in the clear: the device's
|
|
1064
|
+
credential, the PIN sessions and the PINs themselves. Anyone who joins the network, or reads its radio, can take a
|
|
1065
|
+
till's session or learn a PIN. Use it only on a network that is the tills' alone.
|
|
1066
|
+
|
|
1067
|
+
- **A range** is an IPv4 CIDR written in full, four decimal numbers without leading zeros and a prefix length, with
|
|
1068
|
+
its host bits clear (`192.168.1.0/24`, or `192.168.1.10/32` for the server alone). It is a /16 or narrower and lies
|
|
1069
|
+
wholly inside 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16. A hostname (`localhost` and `till.lan` included), an IPv6
|
|
1070
|
+
range, a shared (100.64.0.0/10), link-local, loopback or public range, an empty list, a range listed twice and a
|
|
1071
|
+
range inside another listed one are refused with `ACCESS_OPTIONS_INVALID`; the type takes a non-empty list of `a.b.c.d/n` strings, so a hostname or a
|
|
1072
|
+
missing prefix length is a type error first.
|
|
1073
|
+
- **The base URL** is http: on an IPv4 address inside a listed range: never a hostname, which a resolver could point
|
|
1074
|
+
elsewhere, and never IPv6. It is written as the browser writes it: `http://192.168.001.010` (which a browser reads
|
|
1075
|
+
as 192.168.1.8) or `http://3232235786` is refused, naming the form to write, so the address you read is the one
|
|
1076
|
+
used. A range's network or broadcast address (`192.168.1.0` or `192.168.1.255` in a /24) is refused too: no server
|
|
1077
|
+
has it. It is a fixed `baseURL` (or `BETTER_AUTH_URL`), not a dynamic one. An https: base URL, or a loopback one,
|
|
1078
|
+
ignores `insecureLan`.
|
|
1079
|
+
- **Cookies** follow http: Better Auth's session cookie is not `Secure`, and the device's credential is
|
|
1080
|
+
`better-auth.kiosk_device`, HTTP-only, `SameSite=Lax`, at `/`, with no `Domain`, and not `Secure`. A browser drops a
|
|
1081
|
+
`Secure` cookie, or one named `__Secure-` or `__Host-`, on a plain http page off a loopback host, so
|
|
1082
|
+
`advanced.useSecureCookies: true`, a `secure: true` in `advanced.defaultCookieAttributes` or `advanced.cookies`, and
|
|
1083
|
+
such a prefix in `advanced.cookiePrefix` or a cookie's `name` are refused here. Cookies are not kept apart by port:
|
|
1084
|
+
run nothing else on the server's address. A service on another port of that address receives every till's
|
|
1085
|
+
credential and session, and can set cookies in any browser that visits it. With a till's credential planted in a
|
|
1086
|
+
browser, it can end that browser's session and bind its next sign-in to the till.
|
|
1087
|
+
- **Origins**: Better Auth's origin check takes the base URL's origin as it does on https, and refuses another, a
|
|
1088
|
+
second address on the same network included.
|
|
1089
|
+
- **At startup** Better Auth's logger warns, naming the base URL and the ranges. `logger: { disabled: true }` in
|
|
1090
|
+
`betterAuth` silences it with the rest of Better Auth's log.
|
|
1091
|
+
|
|
1092
|
+
Everything else is as on https: the device binding, the credential replaced at every switch, the idle lock, the PIN
|
|
1093
|
+
limits and the refusals of tokens.
|
|
1094
|
+
|
|
1095
|
+
#### A till that is an Expo app
|
|
1096
|
+
|
|
1097
|
+
A till can be a React Native app on Better Auth's Expo integration (`@better-auth/expo`) instead of a browser. Its
|
|
1098
|
+
client keeps the cookies in SecureStore and sends them in a `Cookie` header, so the device's credential and the session
|
|
1099
|
+
work as they do in a browser: enrolling, switching (each switch's new credential is the one it keeps), the idle lock,
|
|
1100
|
+
lock and revoking. On the server:
|
|
1101
|
+
|
|
1102
|
+
- Add Better Auth's `expo()` to `betterAuth.plugins`, and the app's scheme to `trustedOrigins` (`"tillapp://"`). The
|
|
1103
|
+
app names itself in an `expo-origin` header, which `expo()` makes the request's `Origin` when it has none, and Better
|
|
1104
|
+
Auth's origin check then takes it like a trusted site. A browser always sends its own `Origin`, which `expo()` leaves
|
|
1105
|
+
as it is. In Expo Go the origin is `exp://` and the dev server's address; `expo()` trusts `exp://` only when
|
|
1106
|
+
`NODE_ENV` is `development`.
|
|
1107
|
+
- Send the app's cookies to your Aventara API too (`Cookie: await authClient.getCookie()`): the device's credential is
|
|
1108
|
+
one of them, and without it a bound session is ended.
|
|
1109
|
+
- On the till, staff sign in with their password (`signIn.email`) or switch by PIN. Two of the Expo client's sign-ins
|
|
1110
|
+
open the session off the device and it is not bound, so the till ends it on its next request: a native ID-token
|
|
1111
|
+
sign-in (`signIn.social` with `idToken`), which the Expo client sends with no cookie, and a sign-in in the system
|
|
1112
|
+
browser handed back to the app (a magic link, social sign-in's redirect). For an ID-token sign-in on a till, pass the
|
|
1113
|
+
cookies and the origin yourself, `{ headers: { cookie: await authClient.getCookie(), "expo-origin": "tillapp://" } }`
|
|
1114
|
+
as `signIn.social`'s second argument, and the session is bound. A redirect sign-in cannot be bound: the system
|
|
1115
|
+
browser does not hold the device's credential, and it keeps that session itself, where the next person at the till
|
|
1116
|
+
could open it. On a till, use a password or a PIN; if a till must sign in that way, open the browser session as an
|
|
1117
|
+
ephemeral one (`preferEphemeralSession` in `expo-web-browser`'s `openAuthSessionAsync`, on iOS), so the system
|
|
1118
|
+
browser keeps nothing.
|
|
1119
|
+
- `expo()` hands a redirect sign-in's session cookie to the app in the redirect URL (`?cookie=`). On Android another
|
|
1120
|
+
app that registers the same scheme could receive it. That is Better Auth's Expo integration, not the kiosk, and on a
|
|
1121
|
+
till such a session is not bound, so the till ends it.
|
|
1122
|
+
- A native app has no HTTP-only cookie: the device's credential and the session token sit in SecureStore, where the
|
|
1123
|
+
app's own code can read them. `/get-session` still answers a bound session without its token.
|
|
1124
|
+
- Under `insecureLan`, React Native refuses plain http by default: allow the server's address in Android's network
|
|
1125
|
+
security configuration (`cleartextTrafficPermitted`) and in iOS's App Transport Security (an exception for the
|
|
1126
|
+
address, or `NSAllowsLocalNetworking`). The Expo client sends the cookies to the base URL whatever their attributes,
|
|
1127
|
+
so nothing on the till stops the credential from going over http: the network must be the tills' alone.
|
|
1128
|
+
|
|
1129
|
+
## Agents over MCP
|
|
1130
|
+
|
|
1131
|
+
Better Auth authenticates; Access authorizes. An agent's client gets its token from Better Auth's own OAuth provider,
|
|
1132
|
+
`@better-auth/oauth-provider`, which you add to `betterAuth.plugins` with its options as Better Auth's docs give them.
|
|
1133
|
+
Access issues no token and serves no OAuth route. With `mcp: { resource }` it reads the provider's tokens for your MCP
|
|
1134
|
+
endpoint, holds the agent to its user's grants and its token's scopes, and gives the framework its `mcp.authorization`.
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
// src/access.mcp.ts
|
|
1138
|
+
import { createAccess } from "@aventara/access/auth";
|
|
1139
|
+
import { cimd } from "@better-auth/cimd";
|
|
1140
|
+
import { fetchClientMetadataResource } from "@better-auth/cimd/node";
|
|
1141
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
1142
|
+
import { Pool } from "pg";
|
|
1143
|
+
import { policies } from "./access.policies.js";
|
|
1144
|
+
|
|
1145
|
+
/** The MCP endpoint, as its protected-resource metadata names it. */
|
|
1146
|
+
const resource = "https://app.example.com/mcp";
|
|
1147
|
+
|
|
1148
|
+
const claims = { role: "string", banned: "boolean" } as const;
|
|
1149
|
+
|
|
1150
|
+
export const access = createAccess({
|
|
1151
|
+
betterAuth: {
|
|
1152
|
+
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
1153
|
+
baseURL: "https://app.example.com",
|
|
1154
|
+
emailAndPassword: { enabled: true },
|
|
1155
|
+
user: {
|
|
1156
|
+
additionalFields: {
|
|
1157
|
+
role: { type: "string", required: false, input: false },
|
|
1158
|
+
banned: { type: "boolean", required: false, input: false },
|
|
1159
|
+
},
|
|
1160
|
+
},
|
|
1161
|
+
plugins: [
|
|
1162
|
+
// Better Auth's OAuth provider, as its docs give it: it issues every token.
|
|
1163
|
+
oauthProvider({
|
|
1164
|
+
loginPage: "/sign-in",
|
|
1165
|
+
consentPage: "/consent",
|
|
1166
|
+
scopes: ["aventara:read", "aventara:write", "offline_access"],
|
|
1167
|
+
resources: [resource],
|
|
1168
|
+
clientRegistrationDefaultResources: [resource],
|
|
1169
|
+
}),
|
|
1170
|
+
// Client ID Metadata Documents: an agent's client is the domain its document is served from.
|
|
1171
|
+
cimd({ fetchClientMetadataResource, metadataProfile: "mcp-2026-07-28" }),
|
|
1172
|
+
],
|
|
1173
|
+
},
|
|
1174
|
+
policies: { rules: policies, claims },
|
|
1175
|
+
principal: ({ sub, claims }) => ({
|
|
1176
|
+
id: sub,
|
|
1177
|
+
claims: {
|
|
1178
|
+
...(claims.role === "admin" ? { role: "admin" as const } : {}),
|
|
1179
|
+
...(claims.banned === undefined ? {} : { banned: claims.banned }),
|
|
1180
|
+
},
|
|
1181
|
+
}),
|
|
1182
|
+
mcp: { resource },
|
|
1183
|
+
});
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
Then the framework's configuration takes it in one line: `mcp: { path: "/mcp", authorization: access.mcp }`, beside
|
|
1187
|
+
`access`. `access.mcp` is there exactly when `mcp` is given, and is `{ servers, verify }`:
|
|
1188
|
+
|
|
1189
|
+
- `servers` is Access's issuer, the origin of Better Auth's base URL, alone. The provider's metadata, the `iss` of each
|
|
1190
|
+
authorization response (RFC 9207) and every token's `iss` carry the same string. Mount `access.handler` at
|
|
1191
|
+
`/.well-known/oauth-authorization-server` too, at the root of that origin, where a client looks for the provider's
|
|
1192
|
+
metadata (RFC 8414). It is https, or on this machine `http://127.0.0.1` or `http://[::1]` with any port: set
|
|
1193
|
+
`baseURL` to that address, not `localhost` (a name is resolved, and may resolve elsewhere). An http issuer is for
|
|
1194
|
+
development: a client that holds to RFC 8414 strictly refuses one.
|
|
1195
|
+
- `verify(token)` takes the provider's JWT access token for the MCP endpoint and nothing else, and resolves who the
|
|
1196
|
+
agent acts for: `{ sub, claims, clientId, scopes }`, the claims read from the user now. It refuses a session token
|
|
1197
|
+
(`GET /token`), a token for another resource, an opaque token (a client that sent no `resource`), a token bound to a
|
|
1198
|
+
DPoP key, a client-credentials token (it acts for no user), and one whose client is disabled, whose user is gone or
|
|
1199
|
+
no longer consents to its scopes, or whose session (when it was issued in one) has ended.
|
|
1200
|
+
- A JWT access token is not revoked by `POST /oauth2/revoke`: the provider answers that a self-contained token cannot
|
|
1201
|
+
be revoked server-side, and the token keeps verifying until it expires. To stop an agent at once, revoke the user's
|
|
1202
|
+
consent to its client, disable the client or end the session it was issued in (each is read on every request);
|
|
1203
|
+
keep the provider's `accessTokenExpiresIn` short. A refresh token `/oauth2/revoke` does revoke.
|
|
1204
|
+
- With Better Auth's `secondaryStorage`, the provider requires `session.storeSessionInDatabase: true`, so the session a
|
|
1205
|
+
token names is a database row; with `preserveSessionInDatabase` too, Better Auth ends that row when the session ends,
|
|
1206
|
+
and the token stops.
|
|
1207
|
+
- Better Auth's OAuth provider (1.7.12 to 1.7.14) cannot register its resource on Better Auth's Prisma adapter: it writes
|
|
1208
|
+
`allowedScopes: null` into a `String[]` column, which Prisma will not leave unset. Run Better Auth on a `pg` `Pool`
|
|
1209
|
+
(its built-in Kysely) for the provider's tables, as the samples here do; the tables can still be in your Prisma
|
|
1210
|
+
schema (`auth generate` writes them, `prisma db push` makes them, `resources.authTables` names each).
|
|
1211
|
+
|
|
1212
|
+
Install the provider and `@better-auth/cimd` in the same range as Better Auth (`npm install
|
|
1213
|
+
@better-auth/oauth-provider@~1.7.12 @better-auth/cimd@~1.7.12`); npm takes the newest patch of each, with Better Auth's
|
|
1214
|
+
at the same patch. With it an agent's client registers itself by its `client_id`, the URL of its metadata document, and
|
|
1215
|
+
your consent page can name the domain it comes from. Open dynamic registration (`allowDynamicClientRegistration` with
|
|
1216
|
+
`allowUnauthenticatedClientRegistration: true`) registers any client as whoever it says it is, so Access refuses it
|
|
1217
|
+
unless you acknowledge it with `mcp: { resource, openRegistration: true }`. Registration by a signed-in user or with
|
|
1218
|
+
an initial access token needs no acknowledgement.
|
|
1219
|
+
|
|
1220
|
+
The request state reads the same token, so `ctx.$state.me` is the user's principal, as `principal` maps it. The
|
|
1221
|
+
policy holds it to the token before the rules:
|
|
1222
|
+
|
|
1223
|
+
- under the `agent` origin, `aventara:read` admits the read questions and `aventara:write` the writes (`create`,
|
|
1224
|
+
`update`, `delete`); a question the scopes do not admit is refused, and one they admit is the user's own, so an agent
|
|
1225
|
+
never does what its user may not. Your rules can narrow it further by origin;
|
|
1226
|
+
- under any other origin it is refused everything: the token opens the MCP endpoint alone, never the protocol routes,
|
|
1227
|
+
`_access/can`, a server-side call or Access's own `GET /me` route, which answers it `null`;
|
|
1228
|
+
- a refused question is an audit event like any other, with the reason `deny:mcp-scope` or `deny:mcp-audience`.
|
|
1229
|
+
|
|
1230
|
+
The claims are read from the user on every request, so a ban or a role taken away reaches the agent on its next call,
|
|
1231
|
+
not when its token expires.
|
|
1232
|
+
|
|
1233
|
+
`me()` is the token identity, `{ sub, claims }`, everywhere, the MCP surface included: the framework's MCP `me` tool
|
|
1234
|
+
calls `access.client.verbs.me.call` in-process with the agent's headers, and it answers who the agent acts for, marked
|
|
1235
|
+
so that `agentOf` (below) tells it is an agent's. Only the HTTP route `GET /me` answers an agent's token `null`.
|
|
1236
|
+
|
|
1237
|
+
To tell an agent's request apart in your own code, call `agentOf(me)`, exported from `@aventara/access/auth` and
|
|
1238
|
+
`@aventara/access/better-auth`. It answers `{ clientId, scopes }` for the principal an agent's token yields, and for the
|
|
1239
|
+
identity `principal` is given for one, so your mapping can see it too; for any other principal it answers `null`. The
|
|
1240
|
+
mark does not show in the principal's own fields, its spread or its JSON.
|
|
1241
|
+
|
|
1242
|
+
```ts
|
|
1243
|
+
// src/agent.check.ts
|
|
1244
|
+
import { agentOf } from "@aventara/access/auth";
|
|
1245
|
+
|
|
1246
|
+
/** Whether a request's principal is an agent acting for its user, and which client. */
|
|
1247
|
+
export const actingAgent = (me: unknown): string | null => agentOf(me)?.clientId ?? null;
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
Access refuses to start (`ACCESS_OPTIONS_INVALID`) on a provider that would let an agent's authorization fall short:
|
|
1251
|
+
none in the plugins, or two; opaque access tokens (`disableJwtPlugin`); the endpoint missing from its `resources`; no
|
|
1252
|
+
`aventara:read` among its `scopes`; a refresh reuse window (`refreshTokenReuseInterval` other than 0, the provider's
|
|
1253
|
+
default: a spent refresh token is never answered again); a `validateRedirectUri` that accepts redirect URIs the client
|
|
1254
|
+
did not register; a `requestUriResolver` (an authorization request by reference); a `postLogin.consentReferenceId`
|
|
1255
|
+
(a JWT access token does not say which reference its consent was stored under); open registration without
|
|
1256
|
+
`openRegistration`; an endpoint that is the provider's own UserInfo URL; or the `service` profile, which hands no
|
|
1257
|
+
browser a session to consent in. A provider or a `cimd()` outside `~1.7.12` is refused
|
|
1258
|
+
with `ACCESS_BETTER_AUTH_VERSION`. Every authorization request must use PKCE with its SHA-256 method and carry its own
|
|
1259
|
+
parameters: Access refuses one without, or one by reference (`request_uri`), with `400 invalid_request`, from any
|
|
1260
|
+
client. `verify` also refuses a token without the session it was issued in (`sid`), one signed with an algorithm
|
|
1261
|
+
`jwt()` does not sign with, one for a resource the provider no longer has, and one whose user's consent does not name
|
|
1262
|
+
the endpoint.
|
|
1263
|
+
|
|
1264
|
+
With the plugin, pass the same `mcp` to `aventaraAccess()` and put `oauthProvider()` after it in `plugins`: Better Auth
|
|
1265
|
+
starts its plugins in order, and the provider reads the `jwt()` the Access plugin carries as it starts.
|
|
1266
|
+
`accessOf(auth).mcp` is then the framework's `mcp.authorization`.
|
|
1267
|
+
|
|
1268
|
+
Your consent page is your app's, as Better Auth's provider leaves it. Show the client's name, the domain of its
|
|
1269
|
+
metadata document and the host of the redirect URI it asked for, and say what each scope lets the agent do.
|
|
1270
|
+
|
|
1271
|
+
### One audience per service
|
|
1272
|
+
|
|
1273
|
+
By default a service takes the one-org token `GET /token` issues, whose audience is the issuer. A service can opt in
|
|
1274
|
+
to tokens addressed to it alone (RFC 8707): `audience` names its URL, as a client names it when it asks the provider
|
|
1275
|
+
for a token (`resource`), and the provider lists it among its `resources`. Each client the service takes is linked to
|
|
1276
|
+
it, one by one (`auth.api.adminLinkClientResource`, the provider's own operator call), and asks for one of Access's
|
|
1277
|
+
scopes.
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
// src/access.audience.ts
|
|
1281
|
+
import { createAccess } from "@aventara/access/auth";
|
|
1282
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
1283
|
+
import { Pool } from "pg";
|
|
1284
|
+
import { policies } from "./access.policies.js";
|
|
1285
|
+
|
|
1286
|
+
/** This service, as a client names it when it asks for a token. */
|
|
1287
|
+
const audience = "https://app.example.com/api";
|
|
1288
|
+
|
|
1289
|
+
const claims = { role: "string", banned: "boolean" } as const;
|
|
1290
|
+
|
|
1291
|
+
export const access = createAccess({
|
|
1292
|
+
betterAuth: {
|
|
1293
|
+
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
1294
|
+
baseURL: "https://app.example.com",
|
|
1295
|
+
emailAndPassword: { enabled: true },
|
|
1296
|
+
user: {
|
|
1297
|
+
additionalFields: {
|
|
1298
|
+
role: { type: "string", required: false, input: false },
|
|
1299
|
+
banned: { type: "boolean", required: false, input: false },
|
|
1300
|
+
},
|
|
1301
|
+
},
|
|
1302
|
+
plugins: [
|
|
1303
|
+
oauthProvider({
|
|
1304
|
+
loginPage: "/sign-in",
|
|
1305
|
+
consentPage: "/consent",
|
|
1306
|
+
scopes: ["aventara:read", "aventara:write", "offline_access"],
|
|
1307
|
+
resources: [audience],
|
|
1308
|
+
}),
|
|
1309
|
+
],
|
|
1310
|
+
},
|
|
1311
|
+
policies: { rules: policies, claims },
|
|
1312
|
+
principal: ({ sub, claims }) => ({
|
|
1313
|
+
id: sub,
|
|
1314
|
+
claims: {
|
|
1315
|
+
...(claims.role === "admin" ? { role: "admin" as const } : {}),
|
|
1316
|
+
...(claims.banned === undefined ? {} : { banned: claims.banned }),
|
|
1317
|
+
},
|
|
1318
|
+
}),
|
|
1319
|
+
audience,
|
|
1320
|
+
});
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
With `audience`, the request state takes a bearer token only when it is the provider's access token addressed to the
|
|
1324
|
+
service alone, checked as `verify` checks an agent's (its client, user, consent and session), and carrying
|
|
1325
|
+
`aventara:read` or `aventara:write`; one with neither is nobody's. Its principal is the user's, held to those scopes
|
|
1326
|
+
under every origin, as an agent's is under `agent`: `aventara:read` reads, `aventara:write` writes, and a scope never
|
|
1327
|
+
grants what the user's own grants do not. The one-org token is refused; a session cookie is read as before. Without it
|
|
1328
|
+
nothing changes. `aventaraAccess()` takes the same `audience`. It cannot be the MCP endpoint, the issuer itself or the
|
|
1329
|
+
provider's UserInfo URL.
|
|
1330
|
+
|
|
1331
|
+
Access refuses (`ACCESS_OPTIONS_INVALID`) a provider whose `enforcePerClientResources: false` lets any client ask for a
|
|
1332
|
+
token for the service, and open registration, which `audience` alone never takes. With `mcp` beside it, the service's
|
|
1333
|
+
audience in `clientRegistrationDefaultResources` or `clientRegistrationAllowedResources` is refused too: an agent's
|
|
1334
|
+
client that registers itself would be linked to the service, and a token it refreshed for it would pass for the
|
|
1335
|
+
service's own.
|
|
1336
|
+
|
|
1337
|
+
## The Better Auth plugin
|
|
1338
|
+
|
|
1339
|
+
`createAccess` builds Better Auth for you. When your app already calls `betterAuth({...})` itself, add Access to it as
|
|
1340
|
+
a plugin instead. `aventaraAccess()` takes the same `policies`, `principal`, `resources`, `audit`, `passwords` and
|
|
1341
|
+
`registry` as `createAccess`.
|
|
1342
|
+
`accessOf(auth)` gives the framework its `access` option from that Better Auth instance.
|
|
1343
|
+
|
|
1344
|
+
This app's rules are its organizations' (Better Auth's `organization()` plugin): the organization bridge, below, puts a
|
|
1345
|
+
member's role and organization into the token, and every rule on that role is held to that organization.
|
|
1346
|
+
|
|
1347
|
+
```ts
|
|
1348
|
+
// src/org.policies.ts
|
|
1349
|
+
import { definePolicies } from "@aventara/access";
|
|
1350
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
1351
|
+
|
|
1352
|
+
/** What every token carries beside `sub`: the ban, and the organization bridge's two claims. */
|
|
1353
|
+
export const claims = {
|
|
1354
|
+
banned: "boolean",
|
|
1355
|
+
orgRole: "string",
|
|
1356
|
+
org: "string",
|
|
1357
|
+
} as const;
|
|
1358
|
+
|
|
1359
|
+
/** The principal: the user, with the session's active organization copied up for the rules. */
|
|
1360
|
+
export type Principal = {
|
|
1361
|
+
id: string;
|
|
1362
|
+
org?: string;
|
|
1363
|
+
claims: { banned?: boolean; orgRole?: string; org?: string };
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
export const orgPolicies = definePolicies(model)
|
|
1367
|
+
.principal<Principal>()
|
|
1368
|
+
.roles(() => ({
|
|
1369
|
+
// The bridge's role: an admin of the session's active organization.
|
|
1370
|
+
orgAdmin: { global: { claims: { orgRole: "admin" } } },
|
|
1371
|
+
}))
|
|
1372
|
+
.grants(({ grant, me }) => [
|
|
1373
|
+
// Every grant is held to the principal's organization: a role from the bridge is
|
|
1374
|
+
// that organization's, never a global one.
|
|
1375
|
+
grant("signedIn", "read", "Campaign", { where: { organizationId: me.org } }),
|
|
1376
|
+
grant("orgAdmin", ["create", "update", "delete"], "Campaign", {
|
|
1377
|
+
where: { organizationId: me.org },
|
|
1378
|
+
}),
|
|
1379
|
+
])
|
|
1380
|
+
.denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
```ts
|
|
1384
|
+
// src/auth.ts
|
|
1385
|
+
import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
|
|
1386
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
1387
|
+
import { betterAuth } from "better-auth";
|
|
1388
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
1389
|
+
import { organization } from "better-auth/plugins/organization";
|
|
1390
|
+
import { PrismaClient } from "./generated/prisma/client.js";
|
|
1391
|
+
import { claims, orgPolicies } from "./org.policies.js";
|
|
1392
|
+
|
|
1393
|
+
const prisma = new PrismaClient({
|
|
1394
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
export const auth = betterAuth({
|
|
1398
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
1399
|
+
emailAndPassword: { enabled: true },
|
|
1400
|
+
user: {
|
|
1401
|
+
additionalFields: {
|
|
1402
|
+
banned: { type: "boolean", required: false, input: false },
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
plugins: [
|
|
1406
|
+
organization({
|
|
1407
|
+
// Invitations are accepted only by a verified email (GHSA-fmh4-wcc4-5jm3, below).
|
|
1408
|
+
requireEmailVerificationOnInvitation: true,
|
|
1409
|
+
// Who may create an organization, and so own it: here, nobody but the server.
|
|
1410
|
+
allowUserToCreateOrganization: false,
|
|
1411
|
+
}),
|
|
1412
|
+
aventaraAccess({
|
|
1413
|
+
policies: { rules: orgPolicies, claims },
|
|
1414
|
+
// A member's role in the session's active organization, and that organization.
|
|
1415
|
+
organization: {
|
|
1416
|
+
claim: "orgRole",
|
|
1417
|
+
organizationClaim: "org",
|
|
1418
|
+
roles: [
|
|
1419
|
+
["owner", "admin"],
|
|
1420
|
+
["admin", "admin"],
|
|
1421
|
+
["member", "member"],
|
|
1422
|
+
],
|
|
1423
|
+
},
|
|
1424
|
+
// The organization claim copied onto the principal, where the rules read it.
|
|
1425
|
+
principal: ({ sub, claims }) => ({
|
|
1426
|
+
id: sub,
|
|
1427
|
+
...(claims.org === undefined ? {} : { org: claims.org }),
|
|
1428
|
+
claims,
|
|
1429
|
+
}),
|
|
1430
|
+
// Every sign-in and every decision: `true` for the `accessAudit` table.
|
|
1431
|
+
audit: { sink: (event) => console.log(event.kind, event.userId) },
|
|
1432
|
+
}),
|
|
1433
|
+
],
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
/** The framework's `access` option: the policy engine and each request's `me`. */
|
|
1437
|
+
export const access = accessOf(auth);
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
What the plugin does:
|
|
1441
|
+
|
|
1442
|
+
- It carries Better Auth's `jwt()` inside it, so `GET /api/auth/token` and `GET /api/auth/jwks` are there, and its
|
|
1443
|
+
tokens carry `sub` and the declared claims only, under your base URL's origin as issuer and audience, for
|
|
1444
|
+
`policies.tokenTtl`. Do not add `jwt()` beside it. It is carried under the Access plugin's id, so a Better Auth
|
|
1445
|
+
plugin that looks `jwt()` up by its own id (`"jwt"`) does not find it.
|
|
1446
|
+
- It runs `createAccess`'s own checks on your Better Auth options when Better Auth starts it, and `accessOf(auth)` runs
|
|
1447
|
+
them again when the framework is built: a base URL to fix the issuer, a session cookie that is `sameSite: "none"`
|
|
1448
|
+
only with `trustedOrigins`, a claim read from a field the user cannot set, which Resource is which of Better Auth's
|
|
1449
|
+
tables (`resources.authTables` off the Prisma adapter), no `jwt()` and no second Access plugin. A refusal is the
|
|
1450
|
+
same `AccessError` `createAccess` throws. Refused when Better Auth starts the plugin, Better Auth's own promise
|
|
1451
|
+
rejects, so your server fails at startup with the sentence.
|
|
1452
|
+
- `accessOf(auth)` reads requests as `createAccess`'s `state` does: a bearer token checked against the key set, or the
|
|
1453
|
+
session cookie from a site Better Auth trusts. Its engine closes Better Auth's tables and seals their secrets alike.
|
|
1454
|
+
`accessOf(auth).$Infer.Me` is the principal type, and `$Infer.Claims` the claims.
|
|
1455
|
+
- `audit` logs what `createAccess`'s does (see [The audit log](#the-audit-log)): sign-ins, refused sign-ins,
|
|
1456
|
+
sign-outs and tokens from Better Auth's hooks, and each decision of `accessOf(auth).policy`.
|
|
1457
|
+
- Passwords are hashed with argon2id as under `createAccess` ([Passwords](#passwords)): by default, at the profile
|
|
1458
|
+
`passwords` names, or not at all when your `betterAuth({...})` sets `emailAndPassword.password` (setting both is
|
|
1459
|
+
refused with `ACCESS_OPTIONS_INVALID`). The plugin puts its hook in Better Auth's context when Better Auth starts it.
|
|
1460
|
+
- What else `createAccess` adds around Better Auth is not here: profiles, `access.handler`, the standalone host, and
|
|
1461
|
+
the `$access` verbs (`access.client`, the `me` route). You mount `auth.handler` as you already do.
|
|
1462
|
+
- Call `aventaraAccess()` once for each `betterAuth({...})`. One plugin in two instances whose options differ (the
|
|
1463
|
+
database, secondary storage, base URL, session cookie or cookie cache) is refused with `ACCESS_PLUGIN_DUPLICATE`.
|
|
1464
|
+
|
|
1465
|
+
`aventaraAccess()` inside `createAccess({ betterAuth: { plugins } })` is a type error and is refused with
|
|
1466
|
+
`ACCESS_PLUGIN_DUPLICATE`: `createAccess` installs it itself.
|
|
1467
|
+
|
|
1468
|
+
### The app as the hub
|
|
1469
|
+
|
|
1470
|
+
With `registry: { enabled: true }` the app that runs Better Auth is the registry other services push their files to
|
|
1471
|
+
and pull them from (see [The registry and avaccess](#the-registry-and-avaccess)), with no Access server beside it.
|
|
1472
|
+
|
|
1473
|
+
```ts
|
|
1474
|
+
// src/hub.ts
|
|
1475
|
+
import { definePolicies } from "@aventara/access";
|
|
1476
|
+
import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
|
|
1477
|
+
import { betterAuth } from "better-auth";
|
|
1478
|
+
import { Pool } from "pg";
|
|
1479
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
1480
|
+
|
|
1481
|
+
/** The hub's own rules, in its own namespace: it pushes them like any other service. */
|
|
1482
|
+
const rules = definePolicies(model, { namespace: "campaign" }).grants(({ grant }) => [
|
|
1483
|
+
grant("signedIn", "read", "Campaign"),
|
|
1484
|
+
]);
|
|
1485
|
+
|
|
1486
|
+
export const auth = betterAuth({
|
|
1487
|
+
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
1488
|
+
emailAndPassword: { enabled: true },
|
|
1489
|
+
plugins: [aventaraAccess({ policies: { rules, claims: {} }, registry: { enabled: true } })],
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
/** The framework's `access` option, and the registry's operator API as `access.registry`. */
|
|
1493
|
+
export const access = accessOf(auth);
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
- The registry's tables are the plugin's schema, so `npx auth@~1.7.12 migrate` (or `generate` for an ORM's schema)
|
|
1497
|
+
creates them beside Better Auth's, through your adapter. They are the tables `createAccess` uses, so the data moves
|
|
1498
|
+
with you if you switch.
|
|
1499
|
+
- Its routes are the plugin's endpoints under Better Auth's base path, served wherever you mount `auth.handler`. A
|
|
1500
|
+
service reaches them only with its namespace's key in `x-access-key`. A session cookie or a token of the hub opens
|
|
1501
|
+
none of them, and a registry key is no session or token.
|
|
1502
|
+
- `access.registry` is the operator's API (`createNamespace`, `rotateKey`, `revokeKey`, `pushGuardrails`,
|
|
1503
|
+
`assignResource`, `releaseResource`), typed only when the registry is on. No route opens it: run it in the app's own
|
|
1504
|
+
process, or with `avaccess keys … --server src/hub.ts`, which loads the module and uses its `access` export.
|
|
1505
|
+
- A key is checked against the database on every request, so a revoked or rotated key is refused at once by every
|
|
1506
|
+
instance of the app.
|
|
1507
|
+
- On any mount, a push's key is checked before Better Auth reads its body: a push with no key, or one that is not the
|
|
1508
|
+
namespace's push key, is refused (`401`/`403`, logged as a push's refusal), and one whose declared length is over
|
|
1509
|
+
2 MiB is answered `413`, its body unread. A push with the right key and no declared length is read as your mount
|
|
1510
|
+
reads bodies; the registry refuses a policies file over 1 MiB with `413` once it is read. `serve` also caps every
|
|
1511
|
+
body before reading it.
|
|
1512
|
+
- Better Auth's rate limit counts the registry's routes by client address, which it reads from `X-Forwarded-For`
|
|
1513
|
+
unless `advanced.ipAddress.trustedProxies` names your proxy: a caller that sends its own header gets a fresh bucket
|
|
1514
|
+
each time. Behind a proxy, set `trustedProxies`; on a server facing clients directly, have it set the client's
|
|
1515
|
+
address itself (as `serve` does).
|
|
1516
|
+
- The hub's own rules never come from the registry: they are the `policies` you pass in the app's process.
|
|
1517
|
+
- Another plugin is refused with `ACCESS_PLUGIN_DUPLICATE` when one of its endpoints is named like one of the
|
|
1518
|
+
registry's, sits at a path under `/access/v1` (a path is read as Better Auth's router reads it: `access/v1/key`
|
|
1519
|
+
is `/access/v1/key`) or has parameters that can match one of the registry's routes (`/:a/v1/key`), or when one of
|
|
1520
|
+
its `middlewares` has a path that can match one there (`/access/v1/**`, `/access/**`, `/**`, `/*/v1/key`). Other plugins' `onRequest` and `hooks` are not checked: every plugin you install
|
|
1521
|
+
runs in your app's process, inside its trust boundary, and sees every request, registry keys included. Install only
|
|
1522
|
+
plugins you trust with them.
|
|
1523
|
+
- The hub's tokens are Better Auth's `jwt()` tokens: one org token, with your base URL as issuer and audience. One org
|
|
1524
|
+
token is one trust domain: a token any service sees works at every service that checks the hub's key set, and at
|
|
1525
|
+
the hub itself, until it expires (`policies.tokenTtl`, 15 minutes by default). Access adds no token endpoint and
|
|
1526
|
+
leaves `/token` as Better Auth serves it. A token bound to one service is an opt-in that comes from Better Auth's
|
|
1527
|
+
OAuth provider with resource indicators ([One audience per service](#one-audience-per-service)); use it where
|
|
1528
|
+
services must not trust each other's callers.
|
|
1529
|
+
|
|
1530
|
+
### The organization bridge
|
|
1531
|
+
|
|
1532
|
+
`organization: { claim, organizationClaim, roles }` puts a member's role in Better Auth's organization plugin into a
|
|
1533
|
+
claim, and the organization it holds in into another. Both must be declared in `policies.claims` as `"string"`. When a
|
|
1534
|
+
token is issued (and when `me` is read from the session cookie), Access reads the membership of the session's active
|
|
1535
|
+
organization from Better Auth's store and maps its role through `roles`, an ordered list of
|
|
1536
|
+
`[Better Auth role, value]`: a member with several roles gets the value of the first one listed. No active
|
|
1537
|
+
organization, no membership, or a role `roles` does not name gives neither claim. Removing a member, or changing their
|
|
1538
|
+
role, changes their next token. The bridge owns both claims: neither the user's field of the same name nor
|
|
1539
|
+
`policies.claimsOf` fills them.
|
|
1540
|
+
|
|
1541
|
+
An organization role is that organization's. The rules' roles are global, so a rule on the bridged role alone
|
|
1542
|
+
would let an admin of one organization act on every organization's rows. Hold every grant on it to the organization:
|
|
1543
|
+
copy the organization claim onto the principal (`principal` above), and give each grant
|
|
1544
|
+
`where: { organizationId: me.org }`. A principal without the claim matches no such grant.
|
|
1545
|
+
|
|
1546
|
+
The member's roles are read as Better Auth reads them: split on `,`, each taken as it is. A stored role with
|
|
1547
|
+
whitespace in it (`member, owner`) is one Better Auth does not grant, and the bridge gives nothing for it. A role in
|
|
1548
|
+
`roles` is one name, with no comma and no whitespace.
|
|
1549
|
+
|
|
1550
|
+
The bridge refuses an organization plugin that lets a member be made otherwise than the app decided:
|
|
1551
|
+
|
|
1552
|
+
- `requireEmailVerificationOnInvitation` must be `true` (GHSA-fmh4-wcc4-5jm3, below).
|
|
1553
|
+
- `allowUserToCreateOrganization` must be `false`, or a function that decides who may: by default any signed-in user
|
|
1554
|
+
creates an organization and is its owner.
|
|
1555
|
+
- With `dynamicAccessControl` on, an organization's admins name roles themselves, so `roles` may map only the plugin's
|
|
1556
|
+
static roles (`owner`, `admin`, `member`, or the keys of its own `roles`).
|
|
1557
|
+
|
|
1558
|
+
Each is refused with `ACCESS_OPTIONS_INVALID`. Without `organization()` in your plugins the bridge is refused with
|
|
1559
|
+
`ACCESS_RESOURCE_MISSING`. `resources: { members: true }`, with `createAccess` or the plugin, needs
|
|
1560
|
+
`requireEmailVerificationOnInvitation: true` too.
|
|
1561
|
+
|
|
1562
|
+
Better Auth's advisory [GHSA-fmh4-wcc4-5jm3](https://github.com/better-auth/better-auth/security/advisories/GHSA-fmh4-wcc4-5jm3)
|
|
1563
|
+
is why the flag is required. From `better-auth` 1.6.14 on, with Better Auth's own invitation ids, the organization
|
|
1564
|
+
plugin lets a signed-in user whose email is not verified accept an invitation sent to that email, unless
|
|
1565
|
+
`requireEmailVerificationOnInvitation` is `true`. Someone who signs up with the invited address before its owner, and
|
|
1566
|
+
learns the invitation's id (a forwarded email, an admin screen, an invitation list), joins at the invited role, and
|
|
1567
|
+
the bridge would hand them its claims. Better Auth has no fixed version: its advisory lists `>= 1.6.14` as affected
|
|
1568
|
+
with none. The GitHub advisory database's entry is out of date and still lists `< 1.6.11` only, so `npm audit` reports
|
|
1569
|
+
1.7.14 as clean. Access refuses an organization plugin without the flag (`ACCESS_OPTIONS_INVALID`, naming the
|
|
1570
|
+
advisory). Only `true` passes.
|
|
1571
|
+
|
|
1572
|
+
### The client plugin
|
|
1573
|
+
|
|
1574
|
+
`aventaraAccessClient({ api })` adds `authClient.access.can` to Better Auth's client. It asks the framework, not
|
|
1575
|
+
Better Auth: one `POST {api}/_access/can` for the whole page, answered by the same engine that decides the real
|
|
1576
|
+
requests, one boolean per question.
|
|
1577
|
+
|
|
1578
|
+
```ts
|
|
1579
|
+
// src/auth-client.ts
|
|
1580
|
+
import { aventaraAccessClient } from "@aventara/access/better-auth";
|
|
1581
|
+
import { createAuthClient } from "better-auth/client";
|
|
1582
|
+
|
|
1583
|
+
export const authClient = createAuthClient({
|
|
1584
|
+
plugins: [aventaraAccessClient({ api: "/api" })],
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
/** One request for the page: may the user edit this campaign, and add notes? */
|
|
1588
|
+
export const pageAnswers = async (campaignId: string) => {
|
|
1589
|
+
const { data, error } = await authClient.access.can([
|
|
1590
|
+
{ resource: "Campaign", action: "update", where: { id: campaignId } },
|
|
1591
|
+
{ resource: "Note", action: "create" },
|
|
1592
|
+
]);
|
|
1593
|
+
if (error !== null) {
|
|
1594
|
+
throw new Error(`can: ${error.status} ${error.code ?? error.statusText}`);
|
|
1595
|
+
}
|
|
1596
|
+
const [editCampaign = false, createNote = false] = data;
|
|
1597
|
+
return { editCampaign, createNote };
|
|
1598
|
+
};
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
- `api` is the framework's API path or URL. A path is read against the auth client's `baseURL`, else the page's
|
|
1602
|
+
address.
|
|
1603
|
+
- The request goes through Better Auth's own fetch, so it carries the session cookie (or the bearer token you give
|
|
1604
|
+
Better Auth's client), and the answer is Better Auth's `{ data, error }`. Your client plugins see it as they see
|
|
1605
|
+
Better Auth's own requests: a fetch plugin of theirs (Better Auth's `redirectPlugin` among them) runs on it too.
|
|
1606
|
+
- A question with `where` asks about one row, by its unique where. A row the user cannot read answers `false`, exactly
|
|
1607
|
+
as a missing one does. Without `where`, it asks whether the user may run the action on the Resource at all.
|
|
1608
|
+
- The questions are asked under the ClientContract's hash: `contractHash` when you give it (your generated client's),
|
|
1609
|
+
else the hash `GET {api}/_contract` serves, read once and read again when the framework answers that it changed.
|
|
1610
|
+
- `can` only shapes the page. The real request is still checked.
|
|
1611
|
+
- `_access/can` is the framework's route for apps configured with `access` (`@aventara/core` `1.0.0-rc.10` or later).
|
|
1612
|
+
|
|
1613
|
+
## The engine alone
|
|
1614
|
+
|
|
1615
|
+
You can run the engine without Better Auth: `policies.engine()` builds it from the rules, and `createAccessEngine(file)`
|
|
1616
|
+
from a compiled policies file. Hand it to the framework's `policy` option, with a function that reads the principal
|
|
1617
|
+
from the request's state. Your host's `state` option (or your MCP `verify`) puts the user there.
|
|
1618
|
+
|
|
1619
|
+
```ts
|
|
1620
|
+
// src/aventara.config.ts
|
|
1621
|
+
import type { AvState } from "@aventara/core";
|
|
1622
|
+
import { type Principal, policies } from "./access.policies.js";
|
|
1623
|
+
|
|
1624
|
+
declare module "@aventara/core" {
|
|
1625
|
+
interface AvState {
|
|
1626
|
+
user?: Principal;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
export const aventaraConfig = {
|
|
1631
|
+
api: "/api",
|
|
1632
|
+
exposed: true,
|
|
1633
|
+
policy: {
|
|
1634
|
+
engine: policies.engine(),
|
|
1635
|
+
principal: ({ $state }: { readonly $state: AvState }) =>
|
|
1636
|
+
$state.user ?? null,
|
|
1637
|
+
},
|
|
1638
|
+
} as const;
|
|
1639
|
+
```
|
|
1640
|
+
|
|
1641
|
+
Any object with `policy` (an engine) and `state` (`({ headers }) => ({ me })`) also fits the framework's `access`
|
|
1642
|
+
option, so an app with its own auth gets the same wiring.
|
|
1643
|
+
|
|
1644
|
+
The engine answers:
|
|
1645
|
+
|
|
1646
|
+
| Method | Answers |
|
|
1647
|
+
|---|---|
|
|
1648
|
+
| `can(question)` | Whether the principal may do `action` on `resource`; on `row` when one is given |
|
|
1649
|
+
| `scopeOf(question)` | The rows the principal may read, update or delete: `true`, `false`, or an Aventara `where` |
|
|
1650
|
+
| `fieldsOf(question)` | The fields usable on every reachable row (`always`), and those usable on some rows only (`perRow`) |
|
|
1651
|
+
| `grantsOf(question)` | The update grants that cover a row before it is updated, as opaque keys, for `can`'s `beforeGrants` ([Updates](#updates)) |
|
|
1652
|
+
| `explain(question)` | In development, which rules took part in a `can` decision. It throws when `NODE_ENV` is `production`. |
|
|
1653
|
+
| `checkBeacons(reader, pins, environment)` | The startup check that the service reaches each foreign owner's database ([The ownership beacon](#the-ownership-beacon)). Await it before serving. |
|
|
1654
|
+
| `governed` | The governed fields, by Resource |
|
|
1655
|
+
| `afterChecks` | The Resources where the row an update leaves is checked, so the host asks `grantsOf` before the write |
|
|
1656
|
+
| `paths` | Every relation path and operator each rule filters on, by rule id |
|
|
1657
|
+
| `unheld` | The Resources the rules read as foreign: Aventara holds no written reference to them to the caller's read scope |
|
|
1658
|
+
|
|
1659
|
+
A deny on a field that may be NULL lets the NULL rows through: `can` reads `topic = 'off-the-record'` as false on a
|
|
1660
|
+
row whose `topic` is NULL, so the deny does not apply. `scopeOf` keeps the same rows in a database, which reads that
|
|
1661
|
+
comparison as unknown: on a nullable field it writes the deny's negation as
|
|
1662
|
+
`{ OR: [{ NOT: { topic: { equals: "off-the-record" } } }, { topic: { equals: null } }] }`. A field that is not
|
|
1663
|
+
nullable keeps the plain `NOT`. Which fields are nullable comes from the schema, through the policies file; a to-one
|
|
1664
|
+
relation is nullable when its reference field is, and a deny through it lets the rows with no related row through
|
|
1665
|
+
(`{ replyToId: { equals: null } }`). Access assumes the database enforces each reference: under Prisma's `relationMode = "prisma"`, which creates no foreign keys, a reference to a row that no longer exists makes `scopeOf` and `can` disagree on that row.
|
|
1666
|
+
|
|
1667
|
+
A `where` that matches no row is written `{ OR: [] }`, an empty `OR`: Aventara's `where` has no `false`, and every
|
|
1668
|
+
ORM translator reads the empty `OR` as no row (Prisma does). A `scopeOf` that allows nothing answers `false` itself;
|
|
1669
|
+
`{ OR: [] }` appears inside a `where`, and where a generated client's Prisma functions turn `false` into a Prisma
|
|
1670
|
+
`where`.
|
|
1671
|
+
|
|
1672
|
+
Each question states `principal`, `origin` and `resource`. `can` adds `action` (and, for a row, `row` and `exists`),
|
|
1673
|
+
`scopeOf` adds `action` (`read`, `update` or `delete`), and `fieldsOf` adds what the fields are for:
|
|
1674
|
+
|
|
1675
|
+
```ts
|
|
1676
|
+
// scripts/fields.read.ts
|
|
1677
|
+
import { type Principal, policies } from "../src/access.policies.js";
|
|
1678
|
+
|
|
1679
|
+
const engine = policies.engine();
|
|
1680
|
+
const pat: Principal = { id: "pat", claims: {} };
|
|
1681
|
+
|
|
1682
|
+
// The fields pat may read on User.
|
|
1683
|
+
console.log(
|
|
1684
|
+
engine.fieldsOf({ principal: pat, origin: "client", resource: "User", access: "read" }),
|
|
1685
|
+
);
|
|
1686
|
+
// The fields pat may set when she updates a Note.
|
|
1687
|
+
console.log(
|
|
1688
|
+
engine.fieldsOf({
|
|
1689
|
+
principal: pat,
|
|
1690
|
+
origin: "client",
|
|
1691
|
+
resource: "Note",
|
|
1692
|
+
access: "write",
|
|
1693
|
+
action: "update",
|
|
1694
|
+
}),
|
|
1695
|
+
);
|
|
1696
|
+
```
|
|
1697
|
+
|
|
1698
|
+
`access` is `"read"`, or `"write"` with an optional `action` (`"create"` or `"update"`; both when it is left out). A
|
|
1699
|
+
question without `access`, or with an `action` that does not fit it, is refused with `ACCESS_QUESTION_INVALID`.
|
|
1700
|
+
|
|
1701
|
+
`policies.engine({ guardrails, lock, requireLock })` and `createAccessEngine(file, { guardrails, lock, requireLock })`
|
|
1702
|
+
take the org's guardrails and the lock (below). Any other option is refused. The engine runs from its own frozen copy of
|
|
1703
|
+
the file: changing the object you handed it afterwards changes nothing.
|
|
1704
|
+
|
|
1705
|
+
## The audit log
|
|
1706
|
+
|
|
1707
|
+
`audit: true` writes each event (sign-ins, refused sign-ins, sign-outs, tokens issued, decisions) to the `accessAudit`
|
|
1708
|
+
table. `npx auth@~1.7.12 generate` writes it
|
|
1709
|
+
into an ORM's schema (Prisma: `model AccessAudit`), and `npx auth@~1.7.12 migrate` creates it, beside Better Auth's
|
|
1710
|
+
tables. Better Auth refuses to start while the table is missing.
|
|
1711
|
+
|
|
1712
|
+
Read the log through your app's ORM. With Prisma:
|
|
1713
|
+
`prisma.accessAudit.findMany({ orderBy: { at: "desc" } })`. It is not reachable through the framework's API: Access's
|
|
1714
|
+
own tables stay closed to every request, whatever `resources` says. A read of `AccessAudit` is refused `403 A4002`,
|
|
1715
|
+
and the audit log names the rule `deny:better-auth:AccessAudit`.
|
|
1716
|
+
|
|
1717
|
+
`audit: { sink }` hands each event to your function instead, and declares no table:
|
|
1718
|
+
|
|
1719
|
+
```ts
|
|
1720
|
+
// src/audit.sink.ts
|
|
1721
|
+
import type { AccessAuditEvent } from "@aventara/access/auth";
|
|
1722
|
+
|
|
1723
|
+
/** Logs every refusal with the rule that refused it. */
|
|
1724
|
+
export const auditSink = (event: AccessAuditEvent): void => {
|
|
1725
|
+
if (event.kind === "decision" && !event.allowed) {
|
|
1726
|
+
console.warn(
|
|
1727
|
+
`${event.userId ?? "anonymous"} refused ${event.action} on ${event.resource}: ${event.reason}`,
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
```
|
|
1732
|
+
|
|
1733
|
+
Then `audit: { sink: auditSink }`. Each event is one of:
|
|
1734
|
+
|
|
1735
|
+
```text
|
|
1736
|
+
{ at, kind: "sign-in", userId, origin, impersonatedBy? }
|
|
1737
|
+
{ at, kind: "sign-in-failed", userId: null, route, reason, target?, deviceId?, origin, suppressed?, windowStart?, windowEnd? }
|
|
1738
|
+
{ at, kind: "sign-out", userId, origin, impersonatedBy? }
|
|
1739
|
+
{ at, kind: "token", userId, origin, impersonatedBy? }
|
|
1740
|
+
{ at, kind: "kiosk-switch", userId, previousUserId, deviceId, origin }
|
|
1741
|
+
{ at, kind: "kiosk-lock", userId, deviceId, reason, origin }
|
|
1742
|
+
{ at, kind: "kiosk-device", userId, deviceId, reason, origin }
|
|
1743
|
+
{ at, kind: "kiosk-pin", userId, origin }
|
|
1744
|
+
{ at, kind: "decision", userId, resource, action, allowed, reason, origin, suppressed?, windowStart?, windowEnd? }
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
- A **sign-in** is a session Better Auth created: a sign-in, or a sign-up that signs the user in. Its `origin` is
|
|
1748
|
+
`client` through Access's routes and `application` when your server calls Better Auth's API. `impersonatedBy` is the
|
|
1749
|
+
admin's user id when the session is an impersonation (Better Auth's `admin()` plugin).
|
|
1750
|
+
- A **sign-in-failed** is a route that starts a session refusing the caller: `route` is Better Auth's endpoint
|
|
1751
|
+
(`/sign-in/email`) and `reason` the code it refused with (`INVALID_EMAIL_OR_PASSWORD`). `target` is an HMAC-SHA-256
|
|
1752
|
+
of the identifier the route reads, as it reads it, keyed by Better Auth's secret: the email on `/sign-in/email` and
|
|
1753
|
+
`/sign-in/email-otp` (lower-cased), the username on `/sign-in/username` (normalized by the `username()` plugin's
|
|
1754
|
+
`usernameNormalization`, lower-cased by default), the phone number on `/sign-in/phone-number` (as given). Every
|
|
1755
|
+
attempt on one identifier has the same target, whatever else the body carries, and without the secret it cannot be
|
|
1756
|
+
turned back into the identifier or checked against a guess. Other routes' refusals have no target. The identifier
|
|
1757
|
+
itself, and anything else the caller sent, is never kept. A server call of `signIn`, `signUp` or `resetPassword`
|
|
1758
|
+
that the rate limit refuses is one too, with reason `rate-limited`, the verb's route and its target.
|
|
1759
|
+
- A **sign-out** is a session ended through `/sign-out`.
|
|
1760
|
+
- The **kiosk** events are the kiosk profile's ([The kiosk profile](#the-kiosk-profile)). A failed PIN is a
|
|
1761
|
+
**sign-in-failed** on `/kiosk/switch-user`, with the device's `deviceId` and, as its `target`, the HMAC of the user
|
|
1762
|
+
id it named. With `audit: true` on the kiosk profile, the table has two more columns, `deviceId` and
|
|
1763
|
+
`previousUserId`.
|
|
1764
|
+
- A **token** is a JWT issued for a session by `GET /token`. The `set-auth-jwt` header of `/get-session` is not
|
|
1765
|
+
logged: a page asks for its session on every load.
|
|
1766
|
+
- A **decision** is a `can` answer. A request's admission is one event, allowed or refused, however many rows it reads
|
|
1767
|
+
or writes. A row question (the row a create inserts, or an update leaves) adds an event only when the rules refuse
|
|
1768
|
+
that row. `scopeOf`, `fieldsOf` and `explain` log nothing.
|
|
1769
|
+
- `reason` names the deciding rule: `grant:<id>`, `deny:<id>`, or `no grant`. A guardrail reads `deny:guardrail:<id>`.
|
|
1770
|
+
- `userId` is the `sub` of the token or session the principal came from, or `null` for an anonymous caller.
|
|
1771
|
+
- Anonymous refusals (a refused decision with no `userId`, and every refused sign-in) are logged once a minute for
|
|
1772
|
+
each origin and reason (a refused sign-in, for each route and `target` too). The first is logged; the repeats in the
|
|
1773
|
+
minute are counted, and when the minute closes the count is logged as a summary: the first event's fields with
|
|
1774
|
+
`suppressed` (the count), `windowStart` and `windowEnd`. The summary is written on the next event of any kind after
|
|
1775
|
+
the minute, or by a timer at its end. A sign-in writes its target's summary first, so a guessed password shows after
|
|
1776
|
+
its failures. A flood of anonymous requests writes two rows a minute, not one per request. At most 10,000 windows
|
|
1777
|
+
are open at once: past that, a refusal that would open another closes the oldest early (its count logged, with
|
|
1778
|
+
`windowEnd` then) and opens its own, so every target keeps its own rows.
|
|
1779
|
+
- An event holds no token, password, cookie, key or header.
|
|
1780
|
+
- The request waits for the write or the sink. A sink that throws, or a write that fails, does not fail the request:
|
|
1781
|
+
Better Auth's logger warns and the request goes on. Queue slow work in the sink.
|
|
1782
|
+
|
|
1783
|
+
## Users and roles as Resources
|
|
1784
|
+
|
|
1785
|
+
Better Auth's tables are in your schema, so the framework sees them as Resources. Access runs built-in rules over every
|
|
1786
|
+
one of them. These rules only narrow. `resources` decides which tables your rules may open:
|
|
1787
|
+
|
|
1788
|
+
| Key | Opens | Writes through Aventara |
|
|
1789
|
+
|---|---|---|
|
|
1790
|
+
| `users: true` | `user`, `session` and `account` (session and account read only) | on `user`, an update of `role` |
|
|
1791
|
+
| `members: true` | the organization plugin's `member`, and its `organization` (read only) | on `member`, a create, an update of `role`, and a delete |
|
|
1792
|
+
|
|
1793
|
+
- Every other Better Auth table stays closed: `verification`, `jwks`, the organization plugin's `invitation`,
|
|
1794
|
+
`twoFactor`, `deviceCode`, and Access's own tables. A key that is off leaves its tables closed too.
|
|
1795
|
+
- An opened table is read as your rules grant. Writes are limited because a write through Aventara skips Better Auth's
|
|
1796
|
+
hooks, its cookie cache and its secondary storage. Change a name or an email through Better Auth's API.
|
|
1797
|
+
- `members: true` needs Better Auth's `organization()` plugin in `betterAuth.plugins`, with
|
|
1798
|
+
`requireEmailVerificationOnInvitation: true`: your rules trust its memberships, and without the flag a user whose
|
|
1799
|
+
email is not verified can accept an invitation sent to that address
|
|
1800
|
+
([GHSA-fmh4-wcc4-5jm3](#the-organization-bridge)). Without it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID`.
|
|
1801
|
+
- `explain` and the audit log name a built-in deny `better-auth:<Resource>` (a closed table) or
|
|
1802
|
+
`better-auth:<Resource>/<actions>` (a write an opened table never takes).
|
|
1803
|
+
|
|
1804
|
+
Secret columns are sealed. No grant opens them, on any table, so a request that selects, filters or sorts by one is
|
|
1805
|
+
refused:
|
|
1806
|
+
|
|
1807
|
+
| Table | Sealed columns |
|
|
1808
|
+
|---|---|
|
|
1809
|
+
| `account` | `password`, `accessToken`, `refreshToken`, `idToken` |
|
|
1810
|
+
| `session` | `token` |
|
|
1811
|
+
| `verification` | `identifier`, `value` |
|
|
1812
|
+
| `jwks` | `privateKey` |
|
|
1813
|
+
| `twoFactor` | `secret`, `backupCodes` |
|
|
1814
|
+
| `deviceCode` | `deviceCode`, `userCode` |
|
|
1815
|
+
| `accessKey` | `keyHash` |
|
|
1816
|
+
|
|
1817
|
+
Exempt these columns from your Contracts, or govern them, so a read that names no field leaves them out instead of
|
|
1818
|
+
being refused.
|
|
1819
|
+
|
|
1820
|
+
Access must know which Resource is which of Better Auth's tables, or it could not keep them closed and their secrets
|
|
1821
|
+
sealed:
|
|
1822
|
+
|
|
1823
|
+
- With Better Auth on its Prisma adapter (`prismaAdapter`), Access finds each table by the Prisma model the adapter
|
|
1824
|
+
reads it through: the model whose client delegate is the table's name (your `modelName`, or the table's own, plus
|
|
1825
|
+
`s` with `usePlural`). That is exact, whatever the model is called or mapped to (`model User`, `model user`,
|
|
1826
|
+
`model Users`), so there is nothing to configure. `resources.authTables` is refused there. A table whose delegate no
|
|
1827
|
+
Resource answers to, while the schema has one that differs only in case or a plural `s` (an adapter without
|
|
1828
|
+
`usePlural` on a plural schema), is refused: the schema and the adapter disagree.
|
|
1829
|
+
- With Better Auth on any other adapter (its built-in Kysely on a `pg` Pool or SQLite, Drizzle, MongoDB, your own),
|
|
1830
|
+
nothing ties a table to a Resource by name: a Prisma schema can give a table any model name with `@@map`, and its
|
|
1831
|
+
columns any field names with `@map`. So you name them: `resources.authTables` maps every table Better Auth is built
|
|
1832
|
+
with (its plugins' and Access's own included) to its Resource, or to `null` when the rules' schema does not have it.
|
|
1833
|
+
Without it, or with a table missing from it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID` and lists the
|
|
1834
|
+
tables. A rules' schema with no Resource at all (a registry-only server) needs no map.
|
|
1835
|
+
|
|
1836
|
+
```ts
|
|
1837
|
+
// src/auth-tables.ts
|
|
1838
|
+
/**
|
|
1839
|
+
* Better Auth on a pg Pool, its tables mapped to models of the app's own naming:
|
|
1840
|
+
* `resources: { users: true, authTables }`. Better Auth here has `user`, `session`,
|
|
1841
|
+
* `account`, `verification` and `jwks` (and `twoFactor` with that plugin, and so on).
|
|
1842
|
+
*/
|
|
1843
|
+
export const authTables = {
|
|
1844
|
+
user: "AuthUser",
|
|
1845
|
+
session: "AuthSession",
|
|
1846
|
+
account: "AuthAccount",
|
|
1847
|
+
verification: null,
|
|
1848
|
+
jwks: null,
|
|
1849
|
+
};
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
`null` means "this table is not in the rules' schema", never "closed": a table that is in the schema is named, and
|
|
1853
|
+
then closed, or opened by its key, as any other. The map is held to the schema, and refused with
|
|
1854
|
+
`ACCESS_OPTIONS_INVALID` where they disagree:
|
|
1855
|
+
|
|
1856
|
+
- a table's Resource must exist, be no other table's, and have every column of the table under the name Better Auth's
|
|
1857
|
+
`fieldName` gives it (an `@map` that renames one is refused: Access could not seal what it cannot find);
|
|
1858
|
+
- a table mapped to `null` must not be in the schema: a Resource the map leaves unnamed that has every column of it,
|
|
1859
|
+
or one of its secret columns beside another of its columns (a model that keeps only some, `{ token, userId }`), is
|
|
1860
|
+
refused, naming that Resource. A table of your own with a lone `token` column is yours.
|
|
1861
|
+
|
|
1862
|
+
A table a key opens must have each of its secret columns under Better Auth's field name for it, on either adapter.
|
|
1863
|
+
|
|
1864
|
+
These checks run when `access.policy` is first read, which is when the framework is built. A key whose tables Better
|
|
1865
|
+
Auth lacks, or that the rules' schema lacks, is refused with `ACCESS_RESOURCE_MISSING`: add the plugin, or run
|
|
1866
|
+
`npx auth@~1.7.12 generate` and regenerate the model. A grant on a closed table, or of a write the table never takes,
|
|
1867
|
+
is refused with `ACCESS_OPTIONS_INVALID`. The instance itself still builds, so `npx auth@~1.7.12 generate` can load
|
|
1868
|
+
your configuration before the schema has the tables.
|
|
1869
|
+
|
|
1870
|
+
## The registry and avaccess
|
|
1871
|
+
|
|
1872
|
+
When several services share one Access server, the registry holds each namespace's policies file and the org's
|
|
1873
|
+
guardrails. Each service pushes its own file and pulls it back with the guardrails, and builds never touch the network.
|
|
1874
|
+
|
|
1875
|
+
### The server
|
|
1876
|
+
|
|
1877
|
+
Turn the registry on in the server's configuration. A server that only runs the registry needs no `policies`: its
|
|
1878
|
+
engine then denies every question, and its tokens carry `sub` alone.
|
|
1879
|
+
|
|
1880
|
+
```ts
|
|
1881
|
+
// access.config.ts
|
|
1882
|
+
import { createAccess } from "@aventara/access/auth";
|
|
1883
|
+
import { Pool } from "pg";
|
|
1884
|
+
|
|
1885
|
+
export const access = createAccess({
|
|
1886
|
+
betterAuth: {
|
|
1887
|
+
database: new Pool({ connectionString: process.env.DATABASE_URL }),
|
|
1888
|
+
},
|
|
1889
|
+
registry: { enabled: true },
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
export default access;
|
|
1893
|
+
```
|
|
1894
|
+
|
|
1895
|
+
This server signs in no one: it has no `emailAndPassword`, so nobody can sign up on it. Add the sign-in methods its
|
|
1896
|
+
users need, and see [Sign up and sign in](#sign-up-and-sign-in) before you turn on open sign-up.
|
|
1897
|
+
|
|
1898
|
+
The registry's tables (`accessNamespace`, `accessKey`, `accessPolicyRevision`, `accessGuardrailRevision`, `accessHead`,
|
|
1899
|
+
`accessPushLog`, `accessResourceOwner`) are Better Auth plugin tables, created by Better Auth's migration or by
|
|
1900
|
+
`npx auth@~1.7.12 generate` for an ORM's schema. `access.handler` serves the registry's routes under Better Auth's base path, so every mount carries them:
|
|
1901
|
+
|
|
1902
|
+
| Route | Does |
|
|
1903
|
+
|---|---|
|
|
1904
|
+
| `GET /access/v1/namespaces/:ns` | The namespace |
|
|
1905
|
+
| `GET …/:ns/revisions` | Its revisions |
|
|
1906
|
+
| `POST …/:ns/revisions` | Push a policies file |
|
|
1907
|
+
| `GET …/:ns/revisions/:hash` | One revision |
|
|
1908
|
+
| `GET …/:ns/head` | The current revision |
|
|
1909
|
+
| `GET …/:ns/guardrails` | The org's guardrails |
|
|
1910
|
+
| `GET …/:ns/foreign/:owner` | What `:ns` reads of `:owner` as foreign: `:owner`'s beacon hash, and its entry of each Resource `?resources=A,B` names |
|
|
1911
|
+
| `GET /access/v1/key` | The namespace and kind of the key sent |
|
|
1912
|
+
|
|
1913
|
+
A service sends its key in the `x-access-key` header. With the registry off, `/access/v1/*` answers `404`. One org per
|
|
1914
|
+
server. A revision's `:hash` is `sha256:` and 64 lower-case hex digits; anything else answers `404`.
|
|
1915
|
+
|
|
1916
|
+
The push log (`accessPushLog`) records every push made with a key the registry has, accepted or refused, with the key's
|
|
1917
|
+
id. A push with no key, or a key that is not this registry's, adds no row of its own: those are counted in one row per
|
|
1918
|
+
namespace per minute, its `count`.
|
|
1919
|
+
|
|
1920
|
+
### Keys
|
|
1921
|
+
|
|
1922
|
+
Each namespace has a push key and a read-only pull key. The registry stores only their SHA-256 hashes. The operator
|
|
1923
|
+
issues them from the server's own module, in their own process, never over HTTP:
|
|
1924
|
+
|
|
1925
|
+
```sh
|
|
1926
|
+
avaccess keys issue campaign --server access.config.ts
|
|
1927
|
+
avaccess keys rotate campaign push --server access.config.ts
|
|
1928
|
+
avaccess keys revoke campaign pull --server access.config.ts
|
|
1929
|
+
```
|
|
1930
|
+
|
|
1931
|
+
`avaccess keys` loads that module in its own process and reads no `.env`. Give it the environment the server runs with:
|
|
1932
|
+
`DATABASE_URL` (or whatever your database client reads), `BETTER_AUTH_SECRET`, and `BETTER_AUTH_URL`, the server's
|
|
1933
|
+
base URL, which Better Auth cannot infer outside a request.
|
|
1934
|
+
|
|
1935
|
+
`issue` creates the namespace and prints both keys once. `rotate` prints the new key once. No other command prints a
|
|
1936
|
+
key. `--server` names the module that holds the `createAccess` instance (its default export, or `access`), or, for a
|
|
1937
|
+
Better Auth app that is the hub, `accessOf(auth)` ([The app as the hub](#the-app-as-the-hub)). The same
|
|
1938
|
+
calls are on `access.registry`: `createNamespace(name)`, `rotateKey(namespace, kind)`, `revokeKey(namespace, kind)`
|
|
1939
|
+
and `pushGuardrails(file)`. A namespace has one live key of each kind: two rotations at the same moment leave one.
|
|
1940
|
+
|
|
1941
|
+
A push key pushes and reads its own namespace. A pull key only reads it. A wrong, revoked or rotated-out key, a pull key
|
|
1942
|
+
that pushes, or another namespace's key is refused with `ACCESS_KEY_REFUSED`. Another namespace's key gets the same
|
|
1943
|
+
answer as a namespace the registry does not have (`401`), so a key tells its holder nothing about other namespaces.
|
|
1944
|
+
|
|
1945
|
+
### Who owns a Resource
|
|
1946
|
+
|
|
1947
|
+
A namespace owns the Resources its file names as its own, and only the owner grants, denies or governs fields on them.
|
|
1948
|
+
The registry keeps one owner per Resource in `accessResourceOwner`, and checks and claims ownership in the push's own
|
|
1949
|
+
transaction, so two pushes at the same moment cannot both claim one.
|
|
1950
|
+
|
|
1951
|
+
`registry.ownership` says how a Resource gets its owner:
|
|
1952
|
+
|
|
1953
|
+
- `"first-push"` (the default): the first push that names it as its own, unless the operator assigned it first. A
|
|
1954
|
+
Resource claimed this way is free again once the namespace's head no longer names it.
|
|
1955
|
+
- `"assigned"`: the operator alone. A push that names an unassigned Resource as its own is refused.
|
|
1956
|
+
|
|
1957
|
+
The operator assigns and releases Resources on the server's instance:
|
|
1958
|
+
|
|
1959
|
+
```ts
|
|
1960
|
+
// scripts/owners.assign.ts
|
|
1961
|
+
import access from "../access.config.js";
|
|
1962
|
+
|
|
1963
|
+
await access.registry.assignResource("Invoice", "billing");
|
|
1964
|
+
await access.registry.releaseResource("Draft");
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
`assignResource(resource, namespace)` gives the Resource to the namespace, whoever named it first: from then on
|
|
1968
|
+
another namespace's push that names it as its own is refused. `releaseResource(resource)` takes it from its owner. A
|
|
1969
|
+
refusal names the Resource and the rule, never the namespace that owns it.
|
|
1970
|
+
|
|
1971
|
+
### A service's commands
|
|
1972
|
+
|
|
1973
|
+
The registry is `--registry <url>` or `AVACCESS_REGISTRY_URL`: the Access server's URL. With no path, Better Auth's
|
|
1974
|
+
`/api/auth` is added. The key is `AVACCESS_KEY`, never a flag. The URL is `https:`; `http:` is taken only on a loopback
|
|
1975
|
+
address (`localhost`, `127.0.0.0/8`, `[::1]`), since the key travels in a header. The CLI follows no redirect: a `3xx`
|
|
1976
|
+
answer stops it (exit `2`), and the key goes to no other address.
|
|
1977
|
+
|
|
1978
|
+
| Command | Does |
|
|
1979
|
+
|---|---|
|
|
1980
|
+
| `avaccess push <rules> [--beacon <module>]` | Pushes the namespace's policies file: a `.json` file, or the module that holds the rules (default export, or `rules`), TypeScript included. `--beacon` sends the beacons too ([The ownership beacon](#the-ownership-beacon)). |
|
|
1981
|
+
| `avaccess pull [--namespace <ns>] [--out <dir>] [--foreign <ns>:<Resource>,…] [--beacon <module>] [--env <env>]` | Writes the namespace's current file to `access/<ns>.policies.json`, and the org's guardrails to `access/guardrails.json` when the registry has guardrails, and pins their hashes in `avAccess.lock.json` |
|
|
1982
|
+
| `avaccess verify [--env <env>]` | Holds the pulled files to the lock, offline. A file edited by hand fails with `ACCESS_LOCK_MISMATCH`. It holds every `avAccess.beacons.<env>.json` in the project to the rules too, and with `--env` (or `AVACCESS_ENV`) fails when that environment's file is missing. |
|
|
1983
|
+
| `avaccess status` | Says whether the lock is behind the registry's current revisions, and exits `1` when it is. Run it in CI. |
|
|
1984
|
+
| `avaccess diff [<from> [<to>]] [--namespace <ns>]` | Names the rules that changed between two revisions: `lock`, `head`, a hash, or its first seven or more hex digits. The default, `lock` to `head`, is what a pull would bring. The namespace is `--namespace`, else the lock's. |
|
|
1985
|
+
|
|
1986
|
+
Exit codes: `0` done, `1` the check failed or the registry refused, `2` the command could not run.
|
|
1987
|
+
|
|
1988
|
+
- `push` takes the namespace from the file. The registry stores each file once, in its canonical form, under its hash,
|
|
1989
|
+
and never updates it. Pushing a file the registry already has moves the head back to it.
|
|
1990
|
+
- At push, a namespace may name as its own only Resources no other namespace owns, and its rules may grant, deny and
|
|
1991
|
+
govern only on those (`ACCESS_OWNERSHIP`, see [Who owns a Resource](#who-owns-a-resource)). A file the engine would
|
|
1992
|
+
refuse is refused too (`ACCESS_POLICIES_INVALID`), and so is a file over 1 MiB (`413`).
|
|
1993
|
+
- `pull` takes the namespace from `--namespace`, else the lock, else the key. It hashes each file again before it
|
|
1994
|
+
writes it, and refuses a file of another namespace than the one asked for. When the lock pins the org's guardrails
|
|
1995
|
+
and the registry serves none, the pull is refused and writes nothing: it would drop the org's denies. Commit the two
|
|
1996
|
+
files and the lock.
|
|
1997
|
+
- A pull trusts the registry it reaches: a file is held to its hash, not to who wrote it. Signed revisions are not
|
|
1998
|
+
built yet, so reach the registry over `https:` only.
|
|
1999
|
+
- `pull` and `generate` write only inside the project: a path through a symbolic link that leads out of it is refused
|
|
2000
|
+
(exit `2`). A lock's paths are relative, `/`-separated, with no `..`, drive letter, backslash or control character.
|
|
2001
|
+
- Text the registry sends (a refusal's code and message) is printed without control characters.
|
|
2002
|
+
|
|
2003
|
+
`avAccess.lock.json` pins content, not a registry:
|
|
2004
|
+
|
|
2005
|
+
```json
|
|
2006
|
+
{
|
|
2007
|
+
"format": "aventara-access/lock@1",
|
|
2008
|
+
"namespace": "campaign",
|
|
2009
|
+
"policies": { "path": "access/campaign.policies.json", "hash": "sha256:…" },
|
|
2010
|
+
"guardrails": { "path": "access/guardrails.json", "hash": "sha256:…" }
|
|
2011
|
+
}
|
|
2012
|
+
```
|
|
2013
|
+
|
|
2014
|
+
`guardrails` is `null` when the registry has none. Put `avaccess verify` first in `aventara:prepare`, so the build
|
|
2015
|
+
stops before anything is generated from a changed file:
|
|
2016
|
+
|
|
2017
|
+
```json
|
|
2018
|
+
{
|
|
2019
|
+
"scripts": {
|
|
2020
|
+
"aventara:prepare": "avaccess verify && aventara-prisma7-generate … && aventara-build"
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
```
|
|
2024
|
+
|
|
2025
|
+
In a Next.js app, put it before `aventara-build` the same way.
|
|
2026
|
+
|
|
2027
|
+
### The lock at startup
|
|
2028
|
+
|
|
2029
|
+
Read the lock and the pulled files as JSON and hand them to the instance:
|
|
2030
|
+
|
|
2031
|
+
```ts
|
|
2032
|
+
// src/access.pulled.ts
|
|
2033
|
+
import { readFileSync } from "node:fs";
|
|
2034
|
+
import type { AccessGuardrailsJson, AccessLock } from "@aventara/access";
|
|
2035
|
+
|
|
2036
|
+
/** A file `avaccess pull` wrote, from the project's root. */
|
|
2037
|
+
const pulled = (path: string): unknown =>
|
|
2038
|
+
JSON.parse(readFileSync(path, "utf8"));
|
|
2039
|
+
|
|
2040
|
+
/** avAccess.lock.json: the namespace, and the hash of each pulled file. */
|
|
2041
|
+
export const lock = pulled("avAccess.lock.json") as AccessLock;
|
|
2042
|
+
|
|
2043
|
+
/** The org's guardrails, as the registry serves them to this namespace. */
|
|
2044
|
+
export const guardrails = pulled(
|
|
2045
|
+
"access/guardrails.json",
|
|
2046
|
+
) as AccessGuardrailsJson;
|
|
2047
|
+
```
|
|
2048
|
+
|
|
2049
|
+
Then `policies: { rules: policies, claims, guardrails, lock }`. The instance refuses to start
|
|
2050
|
+
(`ACCESS_LOCK_MISMATCH`) unless the guardrails hash to the lock's pin and are given exactly when it pins some, and the
|
|
2051
|
+
rules' namespace is the lock's. A policies file you hand over (not the typed rules, which are your own source) must hash
|
|
2052
|
+
to its own pin. The engine options take the same `lock`: `createAccessEngine(file, { guardrails, lock })`.
|
|
2053
|
+
|
|
2054
|
+
The lock is optional, so a project without a registry runs without one. In production, set
|
|
2055
|
+
`policies: { …, lock, requireLock: true }` (or `createAccessEngine(file, { lock, requireLock: true })`): the instance
|
|
2056
|
+
then refuses to start without a lock (`ACCESS_LOCK_MISMATCH`), so a deploy that lost `avAccess.lock.json` stops
|
|
2057
|
+
instead of running files nothing pins. With `requireLock`, typed rules are held to the lock's policies hash too: a
|
|
2058
|
+
lock that pins other rules is refused.
|
|
2059
|
+
|
|
2060
|
+
A pulled file imported as JSON fits these options as it is: TypeScript widens a JSON import's literals, so they take
|
|
2061
|
+
the file's outline (`AccessPoliciesJson`, `AccessGuardrailsJson`), and the engine checks the rest when it is built.
|
|
2062
|
+
|
|
2063
|
+
### The ownership beacon
|
|
2064
|
+
|
|
2065
|
+
A namespace can read another namespace's Resources in conditions (`{ foreign: "<owner>" }`) only while both services
|
|
2066
|
+
run on one database: the facts must come from the owner's own tables, not from a copy. The beacon proves it. Each
|
|
2067
|
+
service writes one row in `_aventara_beacon` (`namespace`, `value`, `createdAt`) through its own data layer, with a
|
|
2068
|
+
random value (`avb_…`), once. Access names no ORM and writes no SQL: you hand it an `AccessBeaconStore` over your
|
|
2069
|
+
data layer, a module that exports `beacon` (a generated client's `access.prismaBeacon(prisma)` is one, over a Prisma
|
|
2070
|
+
model of the table). The table holds one row per namespace: in a deployment with a schema per service each service's
|
|
2071
|
+
schema holds its own row, and in one with a single schema one table holds them all.
|
|
2072
|
+
|
|
2073
|
+
- **At push.** `avaccess push <rules> --beacon <module>` sends the namespace's own beacon with the file, writing
|
|
2074
|
+
its row the first time, and for each namespace the file reads as foreign, that namespace's row as this service's
|
|
2075
|
+
connection reads it. The registry keeps each beacon's hash bound to its namespace (`sha256` of
|
|
2076
|
+
`<namespace>:<value>`), never the value: a value pushed under one namespace never matches or blocks another's.
|
|
2077
|
+
Only a namespace's own push registers or replaces its beacon; a reader only refers to the owner's. A file that
|
|
2078
|
+
reads another namespace is stored only when the push carries that namespace's beacon and it is the one that
|
|
2079
|
+
namespace pushed; otherwise the push is refused with `ACCESS_FOREIGN_CROSS_DATABASE`, which suggests carrying the
|
|
2080
|
+
fact in a JWT claim instead. A beacon a namespace replaced never comes back (`ACCESS_BEACON_MISMATCH`, `409`). A
|
|
2081
|
+
push never adopts a row it did not write: a row the registry holds no hash of, or another hash, is refused
|
|
2082
|
+
(`ACCESS_BEACON_MISMATCH`) until an operator verifies it or deletes it, and the next push writes a new one.
|
|
2083
|
+
- **At pull.** First push a file that marks the Resources it reads as the owner's (`{ foreign: "campaign" }` on your
|
|
2084
|
+
model's entries): the registry serves another namespace's facts only for what your current file reads as foreign.
|
|
2085
|
+
Then `avaccess pull --foreign campaign:Campaign,Member --beacon <module> --env <environment>` brings the owner's
|
|
2086
|
+
schema entries of those Resources into `access/foreign.json` (pinned in the lock, checked by `verify`), and
|
|
2087
|
+
`avaccess schema` adds them to your model, marked as the owner's. Every pull of rules that read another namespace
|
|
2088
|
+
reads that namespace's row through `--beacon` and holds it to the hash the namespace pushed; on another database it
|
|
2089
|
+
is refused and nothing is written. The hashes go to `avAccess.beacons.<environment>.json`, one file per environment
|
|
2090
|
+
(`--env`, or `AVACCESS_ENV`), never into the lock: a database's beacon is its own, and the lock holds against any
|
|
2091
|
+
environment's registry.
|
|
2092
|
+
- **At startup.** An engine whose rules read another namespace decides nothing until
|
|
2093
|
+
`await access.policy.checkBeacons(beacon, pins, environment)` (or `engine.checkBeacons`) has passed, with or without
|
|
2094
|
+
a lock: it holds the pins to `environment`, reads each owner's row through your connection and holds it to its pin.
|
|
2095
|
+
A service started against another database refuses with `ACCESS_BEACON_MISMATCH`, and a failed check latches: the
|
|
2096
|
+
engine stays shut until the process restarts. Await it before the service takes requests. A generated client does
|
|
2097
|
+
the same with `access.checkBeacons(reader, pins, environment)`, and throws an error with the same `code`.
|
|
2098
|
+
|
|
2099
|
+
In a single shared schema, every service with write access to `_aventara_beacon` can overwrite or delete another
|
|
2100
|
+
namespace's row. It cannot make a reader pass (the registry holds the owner's hash, bound to the owner), but the
|
|
2101
|
+
readers then refuse at startup until the owner's row is restored and pushed again. The check fails closed; where the
|
|
2102
|
+
database can, give each service's role write access to its own row only.
|
|
2103
|
+
|
|
2104
|
+
```ts
|
|
2105
|
+
// src/access.beacons.ts
|
|
2106
|
+
import { readFileSync } from "node:fs";
|
|
2107
|
+
import type { AccessBeaconPins, AccessBeaconStore } from "@aventara/access";
|
|
2108
|
+
|
|
2109
|
+
/** Your data layer's view of _aventara_beacon, one row per namespace. */
|
|
2110
|
+
declare const beaconRows: {
|
|
2111
|
+
find(namespace: string): Promise<{ readonly value: string } | null>;
|
|
2112
|
+
insert(row: { readonly namespace: string; readonly value: string }): Promise<void>;
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
/** What `avaccess push --beacon src/access.beacons.ts` loads, and what the startup check reads through. */
|
|
2116
|
+
export const beacon: AccessBeaconStore = {
|
|
2117
|
+
read: async (namespace) => (await beaconRows.find(namespace))?.value ?? null,
|
|
2118
|
+
create: (namespace, value) => beaconRows.insert({ namespace, value }),
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
/** The environment this process runs in: required, never guessed. */
|
|
2122
|
+
export const environment = ((): string => {
|
|
2123
|
+
const named = process.env.AVACCESS_ENV;
|
|
2124
|
+
if (named === undefined || !/^[a-z][a-z0-9_-]{0,62}$/.test(named)) {
|
|
2125
|
+
throw new Error(
|
|
2126
|
+
"AVACCESS_ENV names the environment this service runs in (such as production); its beacon pins are avAccess.beacons.<environment>.json",
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
return named;
|
|
2130
|
+
})();
|
|
2131
|
+
|
|
2132
|
+
/** This environment's pins, as `avaccess pull --env <environment>` wrote them. */
|
|
2133
|
+
export const beaconPins = JSON.parse(
|
|
2134
|
+
readFileSync(`avAccess.beacons.${environment}.json`, "utf8"),
|
|
2135
|
+
) as AccessBeaconPins;
|
|
2136
|
+
```
|
|
2137
|
+
|
|
2138
|
+
Then, before the service takes requests: `await access.policy.checkBeacons(beacon, beaconPins, environment)`.
|
|
2139
|
+
|
|
2140
|
+
With the registry on, an Access server whose rules name a namespace keeps its own beacon through Better Auth's
|
|
2141
|
+
adapter: its migration creates the `aventaraBeacon` table (Prisma model `AventaraBeacon`, as Better Auth's generator
|
|
2142
|
+
writes it), and `access.beacon` is the store. The auth-tables guard keeps every beacon table closed, the Access
|
|
2143
|
+
server's and any Resource named like one (`AventaraBeacon`, `_aventara_beacon`), with its `value` sealed.
|
|
2144
|
+
|
|
2145
|
+
## Guardrails
|
|
2146
|
+
|
|
2147
|
+
The org's guardrails are deny-only rules that every namespace's engine adds on top of its own. They only narrow. The
|
|
2148
|
+
operator writes them with `defineGuardrails`, which has `.denies()` and no `.grants()`:
|
|
2149
|
+
|
|
2150
|
+
```ts
|
|
2151
|
+
// src/org.guardrails.ts
|
|
2152
|
+
import { defineGuardrails } from "@aventara/access";
|
|
2153
|
+
|
|
2154
|
+
export const guardrails = defineGuardrails({ claims: { suspended: "boolean" } })
|
|
2155
|
+
.denies(({ deny }) => [
|
|
2156
|
+
// A suspended user reads, and writes nothing, in every service.
|
|
2157
|
+
deny(["create", "update", "delete"], "*", {
|
|
2158
|
+
name: "suspended-read-only",
|
|
2159
|
+
if: { claims: { suspended: true } },
|
|
2160
|
+
}),
|
|
2161
|
+
])
|
|
2162
|
+
.contract();
|
|
2163
|
+
```
|
|
2164
|
+
|
|
2165
|
+
- `deny(actions, resources, options?)` names whole Resources: `"*"`, or by namespace, `{ campaign: ["Note"] }` or
|
|
2166
|
+
`{ chat: "*" }`.
|
|
2167
|
+
- A guardrail's options are a `name` and `if: { claims, origin }`. A `grant`, or a row condition (`where`, `after`), is
|
|
2168
|
+
a type error.
|
|
2169
|
+
- `.contract()` compiles them to an `aventara-access/guardrails@1` file.
|
|
2170
|
+
|
|
2171
|
+
Push them through the server's own instance. No namespace key can push them:
|
|
2172
|
+
|
|
2173
|
+
```ts
|
|
2174
|
+
// scripts/guardrails.push.ts
|
|
2175
|
+
import access from "../access.config.js";
|
|
2176
|
+
import { guardrails } from "../src/org.guardrails.js";
|
|
2177
|
+
|
|
2178
|
+
const pushed = await access.registry.pushGuardrails(guardrails);
|
|
2179
|
+
console.log(pushed);
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
Each service then gets them with `avaccess pull` and passes them as `policies.guardrails` (or the engine's
|
|
2183
|
+
`guardrails` option). Each guardrail becomes a deny on the Resources it reaches: every one for `"*"`, and the file's own
|
|
2184
|
+
namespace's list, where `{ chat: "*" }` is every Resource the file owns. A Resource the file does not list has no
|
|
2185
|
+
grant at all (a grant on `"*"` reaches only the file's own Resources), so nothing there is left for a guardrail to
|
|
2186
|
+
take away. Another namespace's list is left alone. A Resource in the file's own list that the file lacks, or
|
|
2187
|
+
reads as foreign, is refused, and so is a namespaced list when the file names no namespace. `createAccess` refuses
|
|
2188
|
+
guardrails that compare a claim `policies.claims` does not declare (`ACCESS_CLAIMS_UNDECLARED`), so declare
|
|
2189
|
+
`suspended` there too.
|
|
2190
|
+
|
|
2191
|
+
A guardrail's `if` fails closed on a claim the token lacks, as a deny's does: the guardrail applies, whatever value it
|
|
2192
|
+
compares with. Every service that declares `suspended` issues it in each token, `false` when unset, so
|
|
2193
|
+
`if: { claims: { suspended: true } }` leaves alone a user who is not suspended and applies to a token without
|
|
2194
|
+
`suspended`, such as one a service issued before it declared the claim; `if: { claims: { tier: "trial" } }` applies to
|
|
2195
|
+
a user with no `tier`. Write a guardrail so that a missing claim is the safe case.
|
|
2196
|
+
|
|
2197
|
+
Guardrails reach a service at build time, with a pull and a deploy. A change that must take effect sooner travels as
|
|
2198
|
+
a claim: a suspended user gets the claim with their next token, within `policies.tokenTtl`.
|
|
2199
|
+
|
|
2200
|
+
## Services not on Aventara
|
|
2201
|
+
|
|
2202
|
+
A service that does not run on Aventara decides with a generated client: two TypeScript files that import nothing
|
|
2203
|
+
else. `src/generated/avAccess.ts` is the one the service imports. It exports `access` and the client's types
|
|
2204
|
+
(`ResourceName`, `ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `GrantsQuestionOf`,
|
|
2205
|
+
`ScopeQuestionOf`, `FieldsQuestionOf`, and with `--orm prisma7` only, `PrismaClientOf`, `PrismaSelectOf`,
|
|
2206
|
+
`PrismaWhere` and `PrismaBeaconClient`), and holds
|
|
2207
|
+
the pulled policies file, the denies
|
|
2208
|
+
the org's guardrails add to it and the namespace's Resource types. `avAccess.internal.ts` beside it holds the copy of
|
|
2209
|
+
Access's decisions it imports; nothing in it is the client's API. `avAccess.ts` names it with the extension the
|
|
2210
|
+
project's `tsconfig.json` asks for, as Prisma 7's `prisma-client` does: `.js` under `nodenext`, none under
|
|
2211
|
+
`moduleResolution: "bundler"` or `module: "commonjs"`, `.ts` with `allowImportingTsExtensions` or
|
|
2212
|
+
`rewriteRelativeImportExtensions`. The tsconfig is found and read with `get-tsconfig`, as Prisma 7 and the
|
|
2213
|
+
framework's `avclient` read it: the nearest one above the generated file (`extends` followed; in a solution-style
|
|
2214
|
+
root such as Vite's, the referenced project that includes the file), or the one `--tsconfig <file>` names. A project with no tsconfig is refused, and nothing is written: the client is TypeScript, and JavaScript projects
|
|
2215
|
+
are not supported yet. The service needs no Access version at runtime.
|
|
2216
|
+
|
|
2217
|
+
`--orm` names the translator. For `generate` it is optional and defaults to `none`: a neutral client whose `access`
|
|
2218
|
+
has `can`, `grantsOf`, `scopeOf`, `fieldsOf` and `checkBeacons` only, answering in Aventara's `where`, with the types
|
|
2219
|
+
`ResourceName`, `ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `GrantsQuestionOf`, `ScopeQuestionOf`
|
|
2220
|
+
and `FieldsQuestionOf`, and no ORM's name anywhere. `--orm prisma7` adds the Prisma 7 translator (`prismaWhere`,
|
|
2221
|
+
`readable`, `prismaExists`, `prismaBeacon` and the Prisma types). The generated file's banner always spells `--orm` out.
|
|
2222
|
+
`--out <file>` names the client's file, a `.ts` file inside the project (default `src/generated/avAccess.ts`); its
|
|
2223
|
+
internal file is written beside it, `<name>.internal.ts`. `schema` requires `--orm`: `prisma7` is the first.
|
|
2224
|
+
|
|
2225
|
+
```sh
|
|
2226
|
+
avaccess schema --orm prisma7 # prisma/schema.prisma → src/generated/avAccess.schema.ts
|
|
2227
|
+
avaccess push src/access.rules.ts # with the namespace's push key
|
|
2228
|
+
avaccess pull # with its pull key
|
|
2229
|
+
avaccess generate --orm prisma7 # the pulled files → src/generated/avAccess.ts and avAccess.internal.ts
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
`avaccess schema` reads the service's Prisma schema (`--schema`, default `prisma/schema.prisma`; a folder of `.prisma`
|
|
2233
|
+
files reads as one) into a module that exports `model`. It leaves out, and lists, what it cannot model: an
|
|
2234
|
+
`Unsupported(...)` column, a composite `type`, a `view`, an `@ignore`d field or model. `--out <file>` writes it to
|
|
2235
|
+
another `.ts` file inside the project (default `src/generated/avAccess.schema.ts`). Author the rules against it:
|
|
2236
|
+
|
|
2237
|
+
```ts
|
|
2238
|
+
// src/access.rules.ts
|
|
2239
|
+
import { definePolicies } from "@aventara/access";
|
|
2240
|
+
import { model } from "./generated/avAccess.schema.js";
|
|
2241
|
+
|
|
2242
|
+
export const rules = definePolicies(model, { namespace: "chat" })
|
|
2243
|
+
.principal<{ profileId: string }>()
|
|
2244
|
+
.govern("Message", ["body"])
|
|
2245
|
+
.grants(({ grant, me }) => [
|
|
2246
|
+
grant("anyone", "read", "Room", { where: { kind: "PUBLIC" } }),
|
|
2247
|
+
grant("signedIn", "read", "Message", {
|
|
2248
|
+
where: { authorId: me.profileId },
|
|
2249
|
+
fields: ["Message.body"],
|
|
2250
|
+
}),
|
|
2251
|
+
]);
|
|
2252
|
+
```
|
|
2253
|
+
|
|
2254
|
+
`avaccess generate` runs the same check as `avaccess verify` first: a pulled file edited by hand is refused and nothing
|
|
2255
|
+
is written. Regenerate after each pull, for example in the service's `postinstall`:
|
|
2256
|
+
`avaccess verify && avaccess generate --orm prisma7`. The same pulled files, translator and Access version give the
|
|
2257
|
+
same bytes. Never edit the generated files.
|
|
2258
|
+
|
|
2259
|
+
The client's `access` answers as the engine does, plus the Prisma 7 translator:
|
|
2260
|
+
|
|
2261
|
+
```ts
|
|
2262
|
+
// src/messages.read.ts
|
|
2263
|
+
import { access } from "./generated/avAccess.js";
|
|
2264
|
+
import type { PrismaClient } from "./generated/prisma/client.js";
|
|
2265
|
+
|
|
2266
|
+
/** The service's principal: the profile its token maps to, and the token's claims. */
|
|
2267
|
+
export type Principal = { profileId: string; claims: { role?: string } };
|
|
2268
|
+
|
|
2269
|
+
/** A room's messages this principal may read, with the fields it sees on every row. */
|
|
2270
|
+
export async function readMessages(
|
|
2271
|
+
prisma: PrismaClient,
|
|
2272
|
+
principal: Principal | null,
|
|
2273
|
+
roomId: string,
|
|
2274
|
+
) {
|
|
2275
|
+
const question = {
|
|
2276
|
+
principal,
|
|
2277
|
+
origin: "client",
|
|
2278
|
+
resource: "Message",
|
|
2279
|
+
} as const;
|
|
2280
|
+
const scope = access.scopeOf({ ...question, action: "read" });
|
|
2281
|
+
if (scope === false) {
|
|
2282
|
+
return null; // refused
|
|
2283
|
+
}
|
|
2284
|
+
return prisma.message.findMany({
|
|
2285
|
+
where: { AND: [{ roomId }, access.prismaWhere("Message", scope)] },
|
|
2286
|
+
select: access.readable(question),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** Whether this principal may post `body` in a room, asking Prisma what the row does not carry. */
|
|
2291
|
+
export async function mayPost(
|
|
2292
|
+
prisma: PrismaClient,
|
|
2293
|
+
principal: Principal,
|
|
2294
|
+
roomId: string,
|
|
2295
|
+
body: string,
|
|
2296
|
+
) {
|
|
2297
|
+
return access.can({
|
|
2298
|
+
principal,
|
|
2299
|
+
origin: "client",
|
|
2300
|
+
action: "create",
|
|
2301
|
+
resource: "Message",
|
|
2302
|
+
row: { roomId, authorId: principal.profileId, body },
|
|
2303
|
+
exists: (resource, where) => access.prismaExists(prisma, resource, where),
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
```
|
|
2307
|
+
|
|
2308
|
+
| Member | Answers |
|
|
2309
|
+
|---|---|
|
|
2310
|
+
| `can`, `grantsOf`, `scopeOf`, `fieldsOf` | As the engine does, in Aventara's `where` |
|
|
2311
|
+
| `checkBeacons(reader, pins, environment)` | The startup check of the beacons of the namespaces the rules read as foreign, as the engine's ([The ownership beacon](#the-ownership-beacon)). Until it passes, `access` answers nothing when the rules read another namespace. |
|
|
2312
|
+
| `prismaWhere(resource, scope)` | A scope as a Prisma `where`; `false` matches no row |
|
|
2313
|
+
| `readable(question)` | The fields a read shows on every row, as a Prisma `select`. A governed field opened on some rows only is left out, and no relation is selected. |
|
|
2314
|
+
| `prismaExists(prisma, resource, where)` | `can`'s `exists`, through your Prisma client |
|
|
2315
|
+
| `prismaBeacon(prisma)` | The ownership beacon's store over your Prisma client's `AventaraBeacon` model: what `checkBeacons` reads, and `avaccess push --beacon` and `pull --beacon` load |
|
|
2316
|
+
|
|
2317
|
+
The guarantees are weaker than inside Aventara. The scope covers only the queries you pass it to, and a Prisma
|
|
2318
|
+
`include` is not scoped. Verify the bearer token against the Access server's key set (`/api/auth/jwks`) before you
|
|
2319
|
+
build the principal.
|
|
2320
|
+
|
|
2321
|
+
For an update, run it on the rows `scopeOf({ action: "update" })` returns, and ask
|
|
2322
|
+
`can({ action: "update", row, before })` about each row it leaves, inside the same transaction, rolling back on
|
|
2323
|
+
`false`. `before` is the row as you read it in that transaction before the update. Hold the update to that row (its
|
|
2324
|
+
values in the update's `where`), so a concurrent change makes the update miss instead of passing on a stale `before`.
|
|
2325
|
+
Without `before`, `can` checks only the row the update leaves. Hand `can` the row's own fields; it never reads a relation on the row, and a client's relation data never
|
|
2326
|
+
belongs there.
|
|
2327
|
+
|
|
2328
|
+
## Running `avaccess serve`
|
|
2329
|
+
|
|
2330
|
+
`avaccess serve [--config <file>] [--port <port>] [--hostname <host>] [--max-body-bytes <bytes>] [--migrate]` runs the
|
|
2331
|
+
standalone host from a configuration file. It finds `access.config.ts` in the working directory (or `.mts`, `.cts`, `.js`, `.mjs`,
|
|
2332
|
+
`.cjs`), loads it without a build step, and serves it until `SIGTERM` or `SIGINT`, on `127.0.0.1` unless `--hostname`
|
|
2333
|
+
says otherwise.
|
|
2334
|
+
|
|
2335
|
+
- Better Auth's migration runs on start only with `--migrate` or `AVACCESS_MIGRATE=true` (or `1`).
|
|
2336
|
+
- Without it, a database Better Auth migrates itself (a `pg` Pool, a MySQL pool, SQLite) is checked before the server
|
|
2337
|
+
listens. One that lacks a table, column or index the options need stops it with exit `1` and the list of what is
|
|
2338
|
+
missing.
|
|
2339
|
+
- A database behind an ORM's adapter (Prisma, Drizzle) is the ORM's to migrate: run `npx auth@~1.7.12 generate`, then
|
|
2340
|
+
the ORM's migration. It is served as it is, and `--migrate` refuses it with exit `2`.
|
|
2341
|
+
- Better Auth's rate limit is on, whatever `NODE_ENV` says (Better Auth turns it on in production only), unless the
|
|
2342
|
+
configuration sets `betterAuth.rateLimit.enabled`.
|
|
2343
|
+
- `--max-body-bytes` sets `serve`'s body cap (default 1 MiB).
|
|
2344
|
+
- `access.config.ts` is the configuration, as in [The server](#the-server): its default export (or `access`) is the
|
|
2345
|
+
`createAccess` instance. It reads its secrets from the environment.
|
|
2346
|
+
- `GET /health` answers `200 {"status":"ok"}`, and `SIGTERM` drains the server and ends it with exit `0`.
|
|
2347
|
+
|
|
2348
|
+
Access ships no container image. To run the server in a container, build one from a project that installs the package,
|
|
2349
|
+
Better Auth and whatever the configuration imports (here `pg`), from its lockfile:
|
|
2350
|
+
|
|
2351
|
+
```dockerfile
|
|
2352
|
+
FROM node:24-slim
|
|
2353
|
+
WORKDIR /srv/access
|
|
2354
|
+
COPY package.json package-lock.json ./
|
|
2355
|
+
RUN npm ci --omit=dev --ignore-scripts
|
|
2356
|
+
COPY access.config.ts ./
|
|
2357
|
+
USER node
|
|
2358
|
+
EXPOSE 3000
|
|
2359
|
+
CMD ["node_modules/.bin/avaccess", "serve", "--hostname", "0.0.0.0"]
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
`--hostname 0.0.0.0` makes it listen on the container's interfaces. The server speaks plain HTTP: put it behind a proxy
|
|
2363
|
+
that terminates TLS, and set Better Auth's `advanced.ipAddress.trustedProxies` to that proxy's address, so the client's
|
|
2364
|
+
address comes from its `X-Forwarded-For`.
|
|
2365
|
+
|
|
2366
|
+
## File formats and hashes
|
|
2367
|
+
|
|
2368
|
+
Every file Access writes is passive JSON with a format string. A member the format does not define is refused, and so
|
|
2369
|
+
is a file of another format.
|
|
2370
|
+
|
|
2371
|
+
| Format | Members | Written by |
|
|
2372
|
+
|---|---|---|
|
|
2373
|
+
| `aventara-access/policies@1` | `format`, `namespace` (optional), `claims`, `schema`, `roles`, `govern`, `grants`, `denies` | `.contract()`, `avaccess pull` |
|
|
2374
|
+
| `aventara-access/guardrails@1` | `format`, `claims`, `denies` | `defineGuardrails(…).contract()`, `avaccess pull` |
|
|
2375
|
+
| `aventara-access/lock@1` | `format`, `namespace`, `policies`, `guardrails`, `foreign` (optional) | `avaccess pull` |
|
|
2376
|
+
| `aventara-access/foreign@1` | `format`, `namespace`, `schema` (the owners' entries, each marked `foreign`) | `avaccess pull --foreign` |
|
|
2377
|
+
| `aventara-access/beacons@1` | `format`, `namespace`, `environment`, `beacons` (owner to beacon hash) | `avaccess pull --env` |
|
|
2378
|
+
|
|
2379
|
+
A policies file's schema entry lists, beside its fields, the nullable ones (`nullable`) and the ones `can` compares by
|
|
2380
|
+
their type (`types`: `bigint`, `datetime` or `decimal`). A rule's literal is written in its field's canonical form: an
|
|
2381
|
+
instant as `toISOString()` writes it, a decimal without exponent or extra zeros. The engine checks every member of a
|
|
2382
|
+
file it is handed (each rule, condition, comparison and claim), and refuses a number JSON cannot carry: `NaN` and
|
|
2383
|
+
`Infinity` would be written as `null`. A Resource or field name is an identifier (never `__proto__`), each identifier,
|
|
2384
|
+
reference field and relation target names something the schema lists, a decimal is at most 1,000 characters, and a
|
|
2385
|
+
deny id never starts with `guardrail:` or `better-auth:`, the prefixes of the denies the engine adds itself.
|
|
2386
|
+
|
|
2387
|
+
`policiesHash(file)` and `guardrailsHash(file)` return a file's canonical hash, `"sha256:<hex>"`. It is the same for
|
|
2388
|
+
the same rules written in any order, and a formatter's re-indent does not change it.
|
|
2389
|
+
|
|
2390
|
+
```ts
|
|
2391
|
+
// scripts/policies.write.ts
|
|
2392
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2393
|
+
import { createAccessEngine, policiesHash } from "@aventara/access";
|
|
2394
|
+
import { policies } from "../src/access.policies.js";
|
|
2395
|
+
|
|
2396
|
+
const file = policies.contract();
|
|
2397
|
+
writeFileSync("policies.json", `${JSON.stringify(file, null, "\t")}\n`);
|
|
2398
|
+
console.log(policiesHash(file)); // "sha256:…"
|
|
2399
|
+
|
|
2400
|
+
// Later, or in another process: the same engine, from the file alone.
|
|
2401
|
+
const engine = createAccessEngine(
|
|
2402
|
+
JSON.parse(readFileSync("policies.json", "utf8")),
|
|
2403
|
+
);
|
|
2404
|
+
console.log(Object.keys(engine.governed)); // ["User"]
|
|
2405
|
+
```
|
|
2406
|
+
|
|
2407
|
+
Run it from the project's root with `npx tsx scripts/policies.write.ts`. It writes `policies.json` there.
|
|
2408
|
+
|
|
2409
|
+
The file's parts are exported as types (`AccessPolicies`, `AccessGrantRule`, `AccessDenyRule`, `AccessGuardrails`,
|
|
2410
|
+
`AccessLock` and the rest), so a project that emits declarations can export any part of a file.
|
|
2411
|
+
|
|
2412
|
+
## Error codes
|
|
2413
|
+
|
|
2414
|
+
`createAccess`, its instance, `aventaraAccess()`, `accessOf(auth)`, the registry, and an engine checking its lock or a
|
|
2415
|
+
question throw `AccessError`,
|
|
2416
|
+
exported from `@aventara/access` (beside the engine, which throws it too). Its `code` says which
|
|
2417
|
+
refusal it is, and its message, which starts `Aventara Access:`, says what to change. The registry answers with
|
|
2418
|
+
Better Auth's `{ code, message }` body.
|
|
2419
|
+
|
|
2420
|
+
| Code | Meaning | Fix |
|
|
2421
|
+
|---|---|---|
|
|
2422
|
+
| `ACCESS_BETTER_AUTH_VERSION` | `better-auth` is missing, or outside `~1.7.12` (1.7.12 or a later 1.7 patch); under `mcp` or `audience`, an `@better-auth/oauth-provider` or `@better-auth/cimd` outside it | `npm i better-auth@~1.7.12`; under `mcp` or `audience`, `npm i @better-auth/oauth-provider@~1.7.12 @better-auth/cimd@~1.7.12` |
|
|
2423
|
+
| `ACCESS_CLAIMS_UNDECLARED` | The rules or the guardrails read a claim, or a token carries one, that `policies.claims` does not declare | Declare the claim in `policies.claims` |
|
|
2424
|
+
| `ACCESS_PLUGIN_DUPLICATE` | `betterAuth.plugins` holds `jwt()` or `aventaraAccess()`, which `createAccess` installs itself; Better Auth's plugins hold `jwt()` or a second Access plugin beside `aventaraAccess()`; one `aventaraAccess()` in two Better Auth instances whose options differ; or a plugin that defines `jwt()`'s endpoints, Access's own `/me` route (under `createAccess`), (kiosk profile) one under `/kiosk/`, or (registry on) a registry route | Remove it from the plugins |
|
|
2425
|
+
| `ACCESS_OPTIONS_INVALID` | An option `createAccess` does not take or cannot read; `passwords` beside your own `emailAndPassword.password`; Node.js 22 without `@node-rs/argon2`; a grant on a Better Auth table `resources` leaves closed; a claim read from a field the user can set; no base URL to fix the tokens' issuer; `sameSite: "none"` without `trustedOrigins`; Better Auth off its Prisma adapter without `resources.authTables`, or an opened table whose secret column is not found; a `principal` object shared by two users; an organization plugin without `requireEmailVerificationOnInvitation: true` under `resources.members` or the bridge (GHSA-fmh4-wcc4-5jm3); under the bridge, one that lets any user create an organization, or maps a role dynamic access control lets admins name; under `mcp` or `audience`, no OAuth provider or two, one listed before `aventaraAccess()`, `disableJwtPlugin`, a resource missing from its `resources`, no `aventara:read` among its `scopes`, a `refreshTokenReuseInterval` other than 0, a `validateRedirectUri`, a `requestUriResolver`, a `postLogin.consentReferenceId`, `allowUnauthenticatedClientRegistration` without `mcp.openRegistration` (never under `audience` alone), a resource that is the provider's UserInfo URL, the `service` profile, (`mcp`) an http issuer off `127.0.0.1` and `[::1]`, or (`audience`) `enforcePerClientResources: false` and, beside `mcp`, the audience in `clientRegistrationDefaultResources` or `clientRegistrationAllowedResources`; `accessOf` given an instance without `aventaraAccess()`; a `profile` of two kinds, or the kiosk's keys on another kind; the kiosk profile with Better Auth's cookie cache, with `secondaryStorage` and sessions kept out of the database, with `bearer()` or `multiSession()`, with `crossSubDomainCookies` or `SameSite=None`, or on a plain http: base URL off a loopback host and off `insecureLan`, or a dynamic `baseURL` serving a host off loopback over http:; under `insecureLan`, a range that is not a canonical private IPv4 CIDR of /16 or narrower, an empty or repeated list, a base URL that is a hostname, IPv6, outside every range or dynamic, or a cookie setting that makes a cookie `Secure` | Follow the message |
|
|
2426
|
+
| `ACCESS_RESOURCE_MISSING` | A table `resources` opens is missing: Better Auth lacks its plugin, or the rules' schema lacks its Resource; the organization bridge without `organization()` | Add the plugin, or run `npx auth@~1.7.12 generate` and regenerate the model |
|
|
2427
|
+
| `ACCESS_LOCK_MISMATCH` | A pulled file no longer hashes to its pin in `avAccess.lock.json`, the engine was given files the lock does not pin, or `requireLock` is set and no lock was given | Run `avaccess pull` again; never edit a pulled file; pass the lock |
|
|
2428
|
+
| `ACCESS_KEY_REFUSED` | A registry key that is wrong, revoked, rotated out or another namespace's (`401`), or a pull key that pushes (`403`) | Use the namespace's current key of the right kind |
|
|
2429
|
+
| `ACCESS_OWNERSHIP` | A pushed file names a Resource another namespace owns, or one the operator has not assigned it (`ownership: "assigned"`), or the org's guardrails file grants (it holds `grants`, or a guardrail with `subjects`) (`403`) | Grant only on your own Resources; ask the owner or the operator, or add a guardrail |
|
|
2430
|
+
| `ACCESS_POLICIES_INVALID` | A pushed file is not a file the engine would run from (`400`), or is over 1 MiB (`413`) | Push the `.contract()` of your rules, or the pulled file unchanged |
|
|
2431
|
+
| `ACCESS_NAMESPACE` | A namespace name that is not one, one the registry does not have, or one it has already | Check the name; use `keys rotate` for an existing namespace |
|
|
2432
|
+
| `ACCESS_REVISION_CHANGED` | A stored revision no longer hashes to its key: it was changed outside the registry (`500`) | Restore the registry's tables; push the file again |
|
|
2433
|
+
| `ACCESS_FOREIGN_CROSS_DATABASE` | The rules read another namespace's Resources as foreign, and this service is on another database: the owner's beacon as its connection reads it is missing or not the one the owner pushed, or the owner pushed none (`403`) | Run the service on the owner's database, or carry the fact in a JWT claim (`policies.claims`) |
|
|
2434
|
+
| `ACCESS_BEACON_MISMATCH` | At startup, an owner's row in `_aventara_beacon` is missing or does not hash to this environment's pin, the pins are another environment's, a check failed before, or `checkBeacons` was not awaited; at push, a beacon the namespace replaced comes back (`409`), or the namespace's row is one the registry does not hold | Point the service at the owner's database; pull this environment's pins; await `checkBeacons` before serving; have an operator verify or delete a row the push did not write |
|
|
2435
|
+
| `ACCESS_QUESTION_INVALID` | A `fieldsOf` question without `access: "read"` or `access: "write"`, or with an `action` that does not fit it | Ask `{ access: "read" }`, or `{ access: "write", action? }` with `create` or `update` |
|
|
2436
|
+
|
|
2437
|
+
## License
|
|
2438
|
+
|
|
2439
|
+
PolyForm Shield 1.0.0 with an additional permission. See [LICENSE](./LICENSE) and
|
|
2440
|
+
[LICENSE-ADDITIONAL-PERMISSION.md](./LICENSE-ADDITIONAL-PERMISSION.md).
|