@aventara/access 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +877 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2536 -2
- package/dist/access/access-options.d.ts +74 -0
- package/dist/access/access-options.js +5 -0
- package/dist/access/access.error.d.ts +35 -0
- package/dist/access/access.error.js +11 -0
- package/dist/access/access.factory.d.ts +275 -0
- package/dist/access/access.factory.js +440 -0
- package/dist/access/better-auth.hardening.d.ts +120 -0
- package/dist/access/better-auth.hardening.js +420 -0
- package/dist/access/better-auth.loader.d.ts +67 -0
- package/dist/access/better-auth.loader.js +65 -0
- package/dist/access/better-auth.range.d.ts +9 -0
- package/dist/access/better-auth.range.js +28 -0
- package/dist/access/better-auth.require.d.ts +90 -0
- package/dist/access/better-auth.require.js +6 -0
- package/dist/access/better-auth.require.node.d.ts +7 -0
- package/dist/access/better-auth.require.node.js +52 -0
- package/dist/access/claims.mapper.d.ts +79 -0
- package/dist/access/claims.mapper.js +152 -0
- package/dist/access/key-set.cache.d.ts +10 -0
- package/dist/access/key-set.cache.js +63 -0
- package/dist/access/policies-block.checker.d.ts +64 -0
- package/dist/access/policies-block.checker.js +138 -0
- package/dist/access/principal.subject.d.ts +22 -0
- package/dist/access/principal.subject.js +89 -0
- package/dist/access/profile.policy.d.ts +74 -0
- package/dist/access/profile.policy.js +110 -0
- package/dist/access/request.state.d.ts +89 -0
- package/dist/access/request.state.js +266 -0
- package/dist/access/session.hooks.d.ts +114 -0
- package/dist/access/session.hooks.js +258 -0
- package/dist/access/token.verifier.d.ts +35 -0
- package/dist/access/token.verifier.js +69 -0
- package/dist/access-schema.d.ts +48 -0
- package/dist/access-schema.js +5 -0
- package/dist/access-warning.emitter.d.ts +4 -0
- package/dist/access-warning.emitter.js +24 -0
- package/dist/access.builder.d.ts +340 -0
- package/dist/access.builder.js +75 -0
- package/dist/access.engine.d.ts +155 -0
- package/dist/access.engine.js +477 -0
- package/dist/audit/audit-event.d.ts +135 -0
- package/dist/audit/audit-event.js +8 -0
- package/dist/audit/audit.log.d.ts +122 -0
- package/dist/audit/audit.log.js +345 -0
- package/dist/audit/audit.schema.d.ts +143 -0
- package/dist/audit/audit.schema.js +50 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +2 -0
- package/dist/aventara-where.translator.d.ts +19 -0
- package/dist/aventara-where.translator.js +87 -0
- package/dist/beacon/beacon.check.d.ts +59 -0
- package/dist/beacon/beacon.check.js +128 -0
- package/dist/beacon/beacon.schema.d.ts +41 -0
- package/dist/beacon/beacon.schema.js +48 -0
- package/dist/beacon/beacon.store.d.ts +29 -0
- package/dist/beacon/beacon.store.js +78 -0
- package/dist/better-auth/access.client-plugin.d.ts +75 -0
- package/dist/better-auth/access.client-plugin.js +133 -0
- package/dist/better-auth/access.plugin.d.ts +164 -0
- package/dist/better-auth/access.plugin.js +348 -0
- package/dist/better-auth/access.provider.d.ts +80 -0
- package/dist/better-auth/access.provider.js +100 -0
- package/dist/better-auth/index.d.ts +7 -0
- package/dist/better-auth/index.js +4 -0
- package/dist/better-auth/organization.bridge.d.ts +66 -0
- package/dist/better-auth/organization.bridge.js +144 -0
- package/dist/canonical-json.writer.d.ts +12 -0
- package/dist/canonical-json.writer.js +30 -0
- package/dist/cli/avaccess.bin.d.ts +2 -0
- package/dist/cli/avaccess.bin.js +26 -0
- package/dist/cli/avaccess.program.d.ts +15 -0
- package/dist/cli/avaccess.program.js +166 -0
- package/dist/cli/beacon-pins.file.d.ts +14 -0
- package/dist/cli/beacon-pins.file.js +62 -0
- package/dist/cli/beacon.loader.d.ts +20 -0
- package/dist/cli/beacon.loader.js +71 -0
- package/dist/cli/cli.context.d.ts +49 -0
- package/dist/cli/cli.context.js +66 -0
- package/dist/cli/diff.command.d.ts +2 -0
- package/dist/cli/diff.command.js +66 -0
- package/dist/cli/generate.command.d.ts +2 -0
- package/dist/cli/generate.command.js +82 -0
- package/dist/cli/keys.command.d.ts +2 -0
- package/dist/cli/keys.command.js +96 -0
- package/dist/cli/lock.file.d.ts +31 -0
- package/dist/cli/lock.file.js +181 -0
- package/dist/cli/module.loader.d.ts +13 -0
- package/dist/cli/module.loader.js +63 -0
- package/dist/cli/policies.diff.d.ts +16 -0
- package/dist/cli/policies.diff.js +101 -0
- package/dist/cli/pull.command.d.ts +2 -0
- package/dist/cli/pull.command.js +234 -0
- package/dist/cli/push.command.d.ts +2 -0
- package/dist/cli/push.command.js +59 -0
- package/dist/cli/registry.client.d.ts +63 -0
- package/dist/cli/registry.client.js +287 -0
- package/dist/cli/schema.command.d.ts +2 -0
- package/dist/cli/schema.command.js +60 -0
- package/dist/cli/serve.command.d.ts +6 -0
- package/dist/cli/serve.command.js +200 -0
- package/dist/cli/status.command.d.ts +2 -0
- package/dist/cli/status.command.js +31 -0
- package/dist/cli/translator.flag.d.ts +6 -0
- package/dist/cli/translator.flag.js +24 -0
- package/dist/cli/verify.command.d.ts +2 -0
- package/dist/cli/verify.command.js +40 -0
- package/dist/client/access-client.factory.d.ts +92 -0
- package/dist/client/access-client.factory.js +269 -0
- package/dist/client/access-verbs.schema.d.ts +66 -0
- package/dist/client/access-verbs.schema.js +159 -0
- package/dist/client/verb-rate.limiter.d.ts +116 -0
- package/dist/client/verb-rate.limiter.js +358 -0
- package/dist/foreign/foreign-facts.d.ts +30 -0
- package/dist/foreign/foreign-facts.js +75 -0
- package/dist/foreign/foreign.merger.d.ts +10 -0
- package/dist/foreign/foreign.merger.js +52 -0
- package/dist/foreign-references.check.d.ts +6 -0
- package/dist/foreign-references.check.js +131 -0
- package/dist/generator/client.emitter.d.ts +28 -0
- package/dist/generator/client.emitter.js +411 -0
- package/dist/generator/copied-modules.reader.d.ts +14 -0
- package/dist/generator/copied-modules.reader.js +80 -0
- package/dist/generator/namespace.facts.d.ts +28 -0
- package/dist/generator/namespace.facts.js +32 -0
- package/dist/generator/orm.translator.d.ts +48 -0
- package/dist/generator/orm.translator.js +29 -0
- package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
- package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
- package/dist/generator/prisma7/prisma-where.translator.d.ts +73 -0
- package/dist/generator/prisma7/prisma-where.translator.js +138 -0
- package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
- package/dist/generator/prisma7/prisma7.translator.js +87 -0
- package/dist/generator/schema.emitter.d.ts +14 -0
- package/dist/generator/schema.emitter.js +23 -0
- package/dist/generator/tsconfig.reader.d.ts +44 -0
- package/dist/generator/tsconfig.reader.js +110 -0
- package/dist/generator/typescript-literal.writer.d.ts +5 -0
- package/dist/generator/typescript-literal.writer.js +42 -0
- package/dist/guardrails/guardrails-contract.d.ts +45 -0
- package/dist/guardrails/guardrails-contract.js +12 -0
- package/dist/guardrails/guardrails.builder.d.ts +48 -0
- package/dist/guardrails/guardrails.builder.js +32 -0
- package/dist/guardrails/guardrails.compiler.d.ts +10 -0
- package/dist/guardrails/guardrails.compiler.js +254 -0
- package/dist/guardrails/guardrails.composer.d.ts +7 -0
- package/dist/guardrails/guardrails.composer.js +95 -0
- package/dist/guardrails/guardrails.hash.d.ts +10 -0
- package/dist/guardrails/guardrails.hash.js +38 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
- package/dist/kiosk/kiosk-verbs.schema.js +61 -0
- package/dist/kiosk/kiosk.plugin.d.ts +217 -0
- package/dist/kiosk/kiosk.plugin.js +877 -0
- package/dist/kiosk/kiosk.schema.d.ts +176 -0
- package/dist/kiosk/kiosk.schema.js +101 -0
- package/dist/kiosk/kiosk.store.d.ts +171 -0
- package/dist/kiosk/kiosk.store.js +478 -0
- package/dist/kiosk/lan-range.policy.d.ts +31 -0
- package/dist/kiosk/lan-range.policy.js +113 -0
- package/dist/kiosk/pin.policy.d.ts +9 -0
- package/dist/kiosk/pin.policy.js +143 -0
- package/dist/kiosk/session-use.marker.d.ts +4 -0
- package/dist/kiosk/session-use.marker.js +16 -0
- package/dist/lock/lock-contract.d.ts +38 -0
- package/dist/lock/lock-contract.js +67 -0
- package/dist/lock/lock.verifier.d.ts +27 -0
- package/dist/lock/lock.verifier.js +62 -0
- package/dist/mcp/agent-scope.gate.d.ts +13 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +40 -0
- package/dist/mcp/agent.grant.js +92 -0
- package/dist/mcp/mcp-options.d.ts +60 -0
- package/dist/mcp/mcp-options.js +72 -0
- package/dist/oauth/access-token.verifier.d.ts +66 -0
- package/dist/oauth/access-token.verifier.js +201 -0
- package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
- package/dist/oauth/oauth-provider.hardening.js +261 -0
- package/dist/passwords/argon2id.hasher.d.ts +97 -0
- package/dist/passwords/argon2id.hasher.js +256 -0
- package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
- package/dist/passwords/node-rs-argon2.adapter.js +42 -0
- package/dist/passwords/passwords.options.d.ts +79 -0
- package/dist/passwords/passwords.options.js +212 -0
- package/dist/policies-contract.d.ts +219 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +38 -0
- package/dist/policies.compiler.js +1433 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +390 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +54 -0
- package/dist/predicate.evaluator.d.ts +62 -0
- package/dist/predicate.evaluator.js +378 -0
- package/dist/random-secret.generator.d.ts +2 -0
- package/dist/random-secret.generator.js +15 -0
- package/dist/read-governed-unique.check.d.ts +3 -0
- package/dist/read-governed-unique.check.js +21 -0
- package/dist/reference-reads.check.d.ts +16 -0
- package/dist/reference-reads.check.js +158 -0
- package/dist/registry/access.registry.d.ts +173 -0
- package/dist/registry/access.registry.js +865 -0
- package/dist/registry/beacon.ledger.d.ts +30 -0
- package/dist/registry/beacon.ledger.js +93 -0
- package/dist/registry/ownership.check.d.ts +54 -0
- package/dist/registry/ownership.check.js +221 -0
- package/dist/registry/registry.endpoints.d.ts +50 -0
- package/dist/registry/registry.endpoints.js +193 -0
- package/dist/registry/registry.key.d.ts +7 -0
- package/dist/registry/registry.key.js +13 -0
- package/dist/registry/registry.options.d.ts +13 -0
- package/dist/registry/registry.options.js +28 -0
- package/dist/registry/registry.refusal.d.ts +6 -0
- package/dist/registry/registry.refusal.js +11 -0
- package/dist/registry/registry.schema.d.ts +238 -0
- package/dist/registry/registry.schema.js +127 -0
- package/dist/resources/auth-tables.catalog.d.ts +96 -0
- package/dist/resources/auth-tables.catalog.js +110 -0
- package/dist/resources/auth-tables.guard.d.ts +106 -0
- package/dist/resources/auth-tables.guard.js +462 -0
- package/dist/scope.resolver.d.ts +144 -0
- package/dist/scope.resolver.js +529 -0
- package/dist/sha256.digest.d.ts +2 -0
- package/dist/sha256.digest.js +81 -0
- package/dist/standalone/access.server.d.ts +41 -0
- package/dist/standalone/access.server.js +301 -0
- package/dist/standalone/index.d.ts +1 -0
- package/dist/standalone/index.js +1 -0
- package/package.json +102 -3
- package/skill/SKILL.md +170 -0
- package/skill/reference/callers.md +99 -0
- package/skill/reference/errors.md +89 -0
- package/skill/reference/recipes.md +281 -0
- package/skill/reference/rules.md +153 -0
- package/skill/reference/setup.md +334 -0
- package/src/aventara-where.translator.ts +116 -0
- package/src/beacon/beacon.check.ts +204 -0
- package/src/canonical-json.writer.ts +51 -0
- package/src/generator/prisma7/prisma-where.translator.ts +243 -0
- package/src/policies-contract.ts +215 -0
- package/src/policies.decider.ts +662 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +819 -0
- package/src/sha256.digest.ts +85 -0
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aventara-access
|
|
3
|
+
description: Set up and write authorization rules with Aventara Access (@aventara/access) in an Aventara app. Use when editing src/access.ts, src/access.policies.ts or any file that calls definePolicies, grant, deny, roles, govern, createAccess, aventaraAccess, accessOf, createAccessEngine or defineGuardrails; when wiring Access into AventaraModule, Aventara() or createFramework (the `access` option, `policy`, ctx.$state.me, a call's `{ as }`); when a read must work for signed-out callers or carries a cache({ for }) stage; and when an Access or policy code appears (A4002, A3000, ACCESS_OPTIONS_INVALID, ACCESS_CLAIMS_UNDECLARED, ACCESS_PLUGIN_DUPLICATE, ACCESS_REFERENCE_UNREADABLE, any ACCESS_* code).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Aventara Access
|
|
7
|
+
|
|
8
|
+
Access is the authorization layer of an Aventara app. Better Auth signs users in and issues every token; Access decides
|
|
9
|
+
what each caller may do with each row and each field. You write role-first rules typed from the app's schema, Access
|
|
10
|
+
compiles them to a passive policies file (`aventara-access/policies@1`), and its engine answers the framework's policy
|
|
11
|
+
questions on every client and agent request. Written for `@aventara/access` 1.0.0-rc.1 with `@aventara/core`
|
|
12
|
+
1.0.0-rc.11 (1.0.0-rc.10 or later works) and `better-auth` `~1.7.12`.
|
|
13
|
+
|
|
14
|
+
The reference files hold the detail. Read only the one the task needs:
|
|
15
|
+
|
|
16
|
+
- `reference/setup.md`: install, `createAccess`, mounting Better Auth's routes, the framework's `access` option, the
|
|
17
|
+
Better Auth plugin, the engine alone, how a request's `me` is read, server code with `{ as }`.
|
|
18
|
+
- `reference/rules.md`: the rule language: principal, roles, grants, denies, `where`, governed fields, updates,
|
|
19
|
+
references, and how the rules combine.
|
|
20
|
+
- `reference/recipes.md`: complete, type-checked rules for the common tasks, and the deny patterns.
|
|
21
|
+
- `reference/callers.md`: anonymous and signed-out callers, cached reads (`cache({ for })`), agents over MCP, `can` in
|
|
22
|
+
the UI.
|
|
23
|
+
- `reference/errors.md`: every `ACCESS_*` code, the framework codes Access causes, the warnings, and what to check on
|
|
24
|
+
a refusal.
|
|
25
|
+
|
|
26
|
+
The package README (`node_modules/@aventara/access/README.md`) is the full manual: the kiosk profile, the registry and
|
|
27
|
+
`avaccess`, guardrails across services, the generated client for services not on Aventara, the standalone server.
|
|
28
|
+
|
|
29
|
+
## What Access does not do
|
|
30
|
+
|
|
31
|
+
- **No authentication.** Better Auth signs users in. Access adds no token endpoint, no OAuth route and no sign-in
|
|
32
|
+
method of its own. Agents get their tokens from Better Auth's OAuth provider (`@better-auth/oauth-provider`).
|
|
33
|
+
- **No check on server code by default.** A `framework.application.*` call is not checked unless it names a principal
|
|
34
|
+
with `{ as }` ([setup](reference/setup.md#server-code-runs-unchecked-unless-it-names-a-principal)).
|
|
35
|
+
- **No revocation of a token.** A token lives until it expires (`policies.tokenTtl`, 900 seconds at most).
|
|
36
|
+
|
|
37
|
+
## Where things are
|
|
38
|
+
|
|
39
|
+
| File | What it is |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `src/access.policies.ts` | The rules: `definePolicies(model)…`, typed from the discovery artifact's `model`. |
|
|
42
|
+
| `src/access.ts` | `createAccess({ betterAuth, policies, principal, … })`: Better Auth with Access beside it. Default export too (Better Auth's CLI reads it). |
|
|
43
|
+
| `src/main.ts` | Mounts `toNodeHandler(access)` at `/api/auth/` before Nest's body parser. |
|
|
44
|
+
| `src/app.module.ts` | Adds `access` to the `config` beside `adapter`. No `policy` key, no host `state`. |
|
|
45
|
+
| `src/generated/aventara/discovery.artifact.ts` | Written by `aventara:prepare`. The rules are typed from its `model`. Never edit it. |
|
|
46
|
+
|
|
47
|
+
Next.js: `access` goes in `src/aventara/config.ts`, and the routes in `src/app/api/auth/[...all]/route.ts`
|
|
48
|
+
([setup](reference/setup.md#nextjs)).
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
1. **Nothing is allowed until a grant allows it.** Any matching grant allows; any matching deny wins over every grant;
|
|
53
|
+
the order of rules never matters. Write grants for what each role needs, and denies for what nobody in a situation
|
|
54
|
+
may do (a ban, an agent deleting).
|
|
55
|
+
2. **Type the principal first.** `.principal<Principal>()` comes right after `definePolicies(model)`, so `me` is typed
|
|
56
|
+
in every callback. Every Resource, field, relation and enum value in a rule is checked against the schema: a type
|
|
57
|
+
error names the wrong key. Fix the key; never cast it away (`as`, `!`, `any`, `@ts-expect-error`).
|
|
58
|
+
3. **Hold every write grant to the caller.** A `create` or `update` grant that writes an owner carries
|
|
59
|
+
`where: { authorId: me.id }`; an update grant's `where` is checked on the row it leaves too, so the owner cannot hand
|
|
60
|
+
the row to someone else. Use `after` for what the new row must also meet, and `write: { except: [...] }` for fields
|
|
61
|
+
the grant may not set.
|
|
62
|
+
4. **Claims come only from fields the user cannot set.** A claim (`role`, `banned`) is read from the user's field of
|
|
63
|
+
the same name, which needs `input: false` in `user.additionalFields`, and no grant may let the user write that
|
|
64
|
+
field through Aventara (rule 8). Declare every claim the rules read in `policies.claims`.
|
|
65
|
+
5. **A missing value fails closed.** A grant that reads a `me` value or claim the caller lacks never matches; a deny
|
|
66
|
+
that reads one matches. The anonymous caller (`me` is `null`) lacks them all. Write a deny so that a missing claim is
|
|
67
|
+
the safe case. Exception: for the anonymous caller, a deny's `if` comparing a claim with `true` never holds, so a
|
|
68
|
+
ban never closes an `anyone` grant to signed-out callers. To refuse them, leave the `anyone` grant out.
|
|
69
|
+
6. **Govern every field your roles, claims or principal read, usually as `"write"`.** `.govern("Member", { role:
|
|
70
|
+
"write" })` keeps the field readable and lets only a grant that names it (`fields: ["Member.role"]`) write it, so an
|
|
71
|
+
update grant on the row cannot hand out a role. `{ nationalId: "read" }` is the other way: written as any field, read
|
|
72
|
+
back only through a grant that names it. The list form, `.govern("User", ["email"])`, governs both: out of every
|
|
73
|
+
default read, and written by no grant that does not name it. Selecting a field governed for reading without such a
|
|
74
|
+
grant is refused, not dropped.
|
|
75
|
+
7. **A written reference needs read on its target.** A role that creates a `Note` with `authorId` must be able to read
|
|
76
|
+
that `User` row, or the write is refused `A4002`. Heed `ACCESS_REFERENCE_UNREADABLE`.
|
|
77
|
+
8. **Better Auth's tables are closed until you open them.** A rule on `User` needs `resources: { users: true }` in
|
|
78
|
+
`createAccess`; organization members need `resources: { members: true }`. Secret columns (password hashes, tokens,
|
|
79
|
+
keys) stay sealed whatever you grant. `role` is the one `User` field Aventara writes, and Access governs it as
|
|
80
|
+
`"write"`, as it does the organization `member` table's `role`: a grant sets either only when it names it, after
|
|
81
|
+
`.govern("User", { role: "write" })` lets the rules name it. Name `User.role` only in an admin role's grant, never in
|
|
82
|
+
one for `signedIn`, `anyone` or a role users reach on their own (users edit their profile through Better Auth's
|
|
83
|
+
`/update-user`), and name a member's role only in an organization admin role's grant, held to
|
|
84
|
+
`where: { organizationId: me.org }`.
|
|
85
|
+
9. **Signed-out callers get only `anyone` grants.** `signedIn` is any non-null principal; `anyone` adds the anonymous
|
|
86
|
+
caller. Grant `anyone` only on rows that are public, and give a public cached read `cache({ for: "anonymous" })`,
|
|
87
|
+
never `"everyone"` unless every caller, an admin included, gets the same answer
|
|
88
|
+
([callers](reference/callers.md)).
|
|
89
|
+
10. **One provider per app.** With `access` in the configuration, leave `policy` out and give the host no `state`
|
|
90
|
+
option. `createAccess` installs `jwt()` and the Access plugin itself: never add either to `betterAuth.plugins`.
|
|
91
|
+
11. **Branch on codes, never on messages.** An `AccessError` carries `code`; a refused request answers `403 A4002`.
|
|
92
|
+
Look up any code in `reference/errors.md`.
|
|
93
|
+
12. **Never edit generated or pulled files.** The discovery artifact, the policies file a pull wrote and
|
|
94
|
+
`avAccess.lock.json` are rebuilt, never patched.
|
|
95
|
+
13. **Write only what changes behaviour.** Leave out what Access already does: no deny for what no grant allows
|
|
96
|
+
(nothing is allowed until a grant allows it), no `.govern` of `User.role` or the `member` table's `role` (Access
|
|
97
|
+
governs both as `"write"` once `resources` opens them; govern one only so an admin's grant can name it in
|
|
98
|
+
`fields`), no `"both"` where the list form says it, no option set to its default value, and no type annotation the
|
|
99
|
+
inference already gives.
|
|
100
|
+
|
|
101
|
+
## Which tool
|
|
102
|
+
|
|
103
|
+
| You want to | Use |
|
|
104
|
+
|---|---|
|
|
105
|
+
| Let everyone signed in do something | `grant("signedIn", action, Resource)` |
|
|
106
|
+
| Let only the owner touch a row | `grant("signedIn", actions, Resource, { where: { ownerId: me.id } })` |
|
|
107
|
+
| A site-wide role (admin, staff) | `.roles(() => ({ admin: { global: { claims: { role: "admin" } } } }))` |
|
|
108
|
+
| A role held on a row (member of a campaign) | `.roles(({ me }) => ({ dm: { on: "Campaign", where: { members: { some: { userId: me.id, role: "DM" } } } } }))`, then `{ in: "campaign" }` on grants of related Resources |
|
|
109
|
+
| Refuse something whatever the grants say | `deny(actions, Resource, { if: { claims, origin } })` or `{ where }` |
|
|
110
|
+
| Keep a field out of default reads and writes | `.govern(Resource, [field])` plus a grant with `fields` |
|
|
111
|
+
| Let everyone read a field and only some write it (a role) | `.govern(Resource, { field: "write" })` plus a write grant with `fields` |
|
|
112
|
+
| Let a field be written and never read back (an id number) | `.govern(Resource, { field: "read" })`, and a read grant with `fields` only where it must be read |
|
|
113
|
+
| Keep a field out of a write | `write: { except: [field] }` on the grant |
|
|
114
|
+
| Hold the row an update leaves | `after: { … }` on the update grant |
|
|
115
|
+
| Public data for signed-out callers | `grant("anyone", "read", Resource, { where })` |
|
|
116
|
+
| Ask from the UI whether a button applies | the framework client's `$access.can`, or `aventaraAccessClient()`'s `can` |
|
|
117
|
+
| See why a request was refused | `engine.explain(question)` in development, the audit log in production |
|
|
118
|
+
|
|
119
|
+
## The shape of a setup
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// src/team.policies.ts
|
|
123
|
+
import { definePolicies } from "@aventara/access";
|
|
124
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
125
|
+
|
|
126
|
+
/** What the app derives for a request: the signed-in user. Anonymous is `null`. */
|
|
127
|
+
export type TeamPrincipal = {
|
|
128
|
+
id: string;
|
|
129
|
+
claims: { role?: "admin"; banned?: boolean };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const teamPolicies = definePolicies(model)
|
|
133
|
+
.principal<TeamPrincipal>()
|
|
134
|
+
.roles(() => ({
|
|
135
|
+
admin: { global: { claims: { role: "admin" } } },
|
|
136
|
+
}))
|
|
137
|
+
.govern("User", ["email"])
|
|
138
|
+
.grants(({ grant, me }) => [
|
|
139
|
+
// Each user reads their own profile, email included.
|
|
140
|
+
grant("signedIn", "read", "User", {
|
|
141
|
+
where: { id: me.id },
|
|
142
|
+
fields: ["User.email"],
|
|
143
|
+
}),
|
|
144
|
+
grant("signedIn", "read", "Note", { where: { authorId: me.id } }),
|
|
145
|
+
grant("signedIn", ["create", "update", "delete"], "Note", {
|
|
146
|
+
where: { authorId: me.id },
|
|
147
|
+
}),
|
|
148
|
+
// "*" also reaches the Better Auth tables `resources` opens (session, account).
|
|
149
|
+
grant("admin", "read", "*", { fields: ["User.email"] }),
|
|
150
|
+
])
|
|
151
|
+
.denies(({ deny }) => [
|
|
152
|
+
deny("*", "*", { if: { claims: { banned: true } } }),
|
|
153
|
+
deny("delete", "*", { if: { origin: "agent" } }),
|
|
154
|
+
]);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Then `createAccess({ betterAuth, policies: { rules, claims }, principal, resources: { users: true } })` in
|
|
158
|
+
`src/access.ts`, and `access` in the framework's configuration ([setup](reference/setup.md)).
|
|
159
|
+
|
|
160
|
+
## Before you finish
|
|
161
|
+
|
|
162
|
+
- `npx tsc --noEmit` passes with no casts added.
|
|
163
|
+
- The server starts. An `AccessError` names its `code` and says what to change; fix that and start again.
|
|
164
|
+
- After a schema change: `npx auth@~1.7.12 generate --config src/access.ts --yes` (when Better Auth's tables changed), the
|
|
165
|
+
migration, then `npm run aventara:prepare`, so the rules see the new model.
|
|
166
|
+
- A request you expect to be refused answers `403 A4002`, and one you expect to work does: try both, signed in and
|
|
167
|
+
signed out.
|
|
168
|
+
|
|
169
|
+
This skill ships in the package at `node_modules/@aventara/access/skill/`. Copy the folder into your agent's skills
|
|
170
|
+
folder (for example `.claude/skills/aventara-access/`), or point your agent at it; copy it again after upgrading Access.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Callers: signed out, signed in, cached, agents, the UI
|
|
2
|
+
|
|
3
|
+
## Who the caller is
|
|
4
|
+
|
|
5
|
+
| The request carries | `ctx.$state.me` | Rules that match |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Nothing (no `Authorization`, no session cookie) | `null` | `anyone` grants only |
|
|
8
|
+
| A valid bearer token, or a live session cookie (from any site on a `GET`, `HEAD` or `OPTIONS` read; on a write, only from a site Better Auth trusts) | `principal(identity)`, or the identity | `signedIn`, `anyone`, and the roles it holds |
|
|
9
|
+
| A bearer token that does not verify (tampered, expired, another issuer, not a token) | `null` | `anyone` grants only; the cookie is not tried |
|
|
10
|
+
| A session cookie on a write (on every request, with `sameSite: "none"`) from a site Better Auth does not trust | `null` | `anyone` grants only |
|
|
11
|
+
| `principal` returned `null` (a user your app does not know) | `null` | `anyone` grants only |
|
|
12
|
+
| An agent's OAuth access token on the MCP endpoint | the user's principal, held to the token's scopes | the user's own, under origin `agent` |
|
|
13
|
+
|
|
14
|
+
The anonymous caller is `null`: it holds no role, has no claims and lacks every `me` value.
|
|
15
|
+
|
|
16
|
+
- A grant that reads `me` (`where: { authorId: me.id }`) never matches for it.
|
|
17
|
+
- A deny that reads `me` in its `where` matches for it: such a deny refuses the anonymous caller on its actions. Put a
|
|
18
|
+
deny that reads `me` only on actions the anonymous caller has no grant for anyway, or accept that it closes them.
|
|
19
|
+
- A deny's `if` comparing a claim with `true` (`banned: true`) never holds for it, so a ban leaves `anyone` grants
|
|
20
|
+
open. Any other comparison (`tier: "trial"`, `banned: false`) holds for it, and refuses it.
|
|
21
|
+
|
|
22
|
+
So: a signed-out caller reads exactly what `anyone` grants show, and nothing else is allowed. A refused request answers
|
|
23
|
+
`403 A4002`. Without any `anyone` grant, every signed-out request is refused.
|
|
24
|
+
|
|
25
|
+
## Cached reads
|
|
26
|
+
|
|
27
|
+
The framework's `cache({ for, maxAge })` stage serves a read as a `GET` a browser or a CDN may keep. Put it on the
|
|
28
|
+
`find` family or one of its variants, in a `defineResource` block or at the root or client layer:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
// src/resources/campaign.resource.ts
|
|
32
|
+
import { cache, defineResource } from "@aventara/core";
|
|
33
|
+
import type { AvModel } from "../generated/aventara/discovery.artifact.js";
|
|
34
|
+
|
|
35
|
+
export const campaign = defineResource<AvModel>()("Campaign", {
|
|
36
|
+
operations: {
|
|
37
|
+
find: { many: { pipelines: { cache: cache({ for: "anonymous", maxAge: 60 }) } } },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Always write `for`. Under Access:
|
|
43
|
+
|
|
44
|
+
| `for` | Signed out (`me` is `null`) | Signed in | Use it when |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `"user"` (the default) | `private` | `private`, per caller | The rows or fields differ by caller. The safe choice. |
|
|
47
|
+
| `"anonymous"` | `public`: one answer every signed-out caller may share | `private` | Signed-out callers all see the same public rows (an `anyone` grant) and signed-in callers may see more |
|
|
48
|
+
| `"everyone"` | `public` | `public`: one answer for every caller | Only when every caller gets byte-identical rows and fields: the Resource has `anyone` read grants only (no `signedIn`, role or `"*"` read grant reaches it, an admin's included), no `where` reads `me`, nothing governed is opened, and no read deny can match some callers. If any other read grant reaches the Resource, use `"anonymous"` |
|
|
49
|
+
|
|
50
|
+
- Under `"anonymous"`, `public` goes only to a caller Access finds no one in: a valid session cookie or bearer token
|
|
51
|
+
gets `private` with `Vary: Authorization, Cookie`, and a token that verifies to no one gets the signed-out answer.
|
|
52
|
+
Under `"everyone"`, every caller's answer is `public` with no `Vary`: a shared cache may hand one caller's answer to
|
|
53
|
+
any other.
|
|
54
|
+
- A refusal (`A4002`) is `no-store`: no one keeps it.
|
|
55
|
+
- A cached answer is served by the browser or CDN without asking Access again until `maxAge` passes. Under
|
|
56
|
+
`"everyone"`, that includes callers a deny would have refused (a banned user) and callers who sign out: use it only for
|
|
57
|
+
data that is public by design, and keep `maxAge` short where a change must show at once.
|
|
58
|
+
- `"anonymous"` on a Resource with no `anyone` grant caches nothing public: every signed-out read is refused, and a
|
|
59
|
+
refusal is never kept.
|
|
60
|
+
|
|
61
|
+
## Agents over MCP
|
|
62
|
+
|
|
63
|
+
Better Auth's OAuth provider issues the agent's token; Access reads it. Configure `mcp: { resource }` in
|
|
64
|
+
`createAccess` (with `oauthProvider()` in `betterAuth.plugins`, its `scopes` including `aventara:read` and
|
|
65
|
+
`aventara:write`, its `resources` including the MCP endpoint) and give the framework
|
|
66
|
+
`mcp: { path: "/mcp", authorization: access.mcp }`.
|
|
67
|
+
|
|
68
|
+
- An agent acts as its user, under origin `agent`: `aventara:read` admits reads, `aventara:write` admits writes, and
|
|
69
|
+
a scope never grants what the user's own grants do not. Refusals are audited as `deny:mcp-scope` or
|
|
70
|
+
`deny:mcp-audience`.
|
|
71
|
+
- The token opens the MCP endpoint only: under any other origin it is refused everything, and Access's `GET /me`
|
|
72
|
+
answers it `null`.
|
|
73
|
+
- The user's claims are read on every agent request, so a ban reaches the agent on its next call.
|
|
74
|
+
- Narrow agents further with origin denies: `deny("delete", "*", { if: { origin: "agent" } })`.
|
|
75
|
+
- `agentOf(me)` (from `@aventara/access/auth` or `/better-auth`) answers `{ clientId, scopes }` for an agent's
|
|
76
|
+
principal and `null` otherwise.
|
|
77
|
+
- Access refuses an OAuth provider setup that would weaken the agent's authorization (opaque tokens, open dynamic
|
|
78
|
+
registration without `mcp: { resource, openRegistration: true }`, a refresh reuse window, the endpoint missing from
|
|
79
|
+
its `resources`, and more) with `ACCESS_OPTIONS_INVALID`. Read the message; do not work around it. An opt-in a
|
|
80
|
+
message names (`openRegistration`, `insecureLan`, `claimsOf`) weakens security: ask the developer before setting it;
|
|
81
|
+
never set it just to get past the error.
|
|
82
|
+
|
|
83
|
+
The README's "Agents over MCP" section has the full provider setup and the per-service `audience` option.
|
|
84
|
+
|
|
85
|
+
## Server code
|
|
86
|
+
|
|
87
|
+
A `framework.application.*` call is not checked unless it names a principal: `{ as: me }` checks it as that caller
|
|
88
|
+
(`null` is the anonymous one). See [setup](setup.md#server-code-runs-unchecked-unless-it-names-a-principal).
|
|
89
|
+
|
|
90
|
+
## Asking from the UI
|
|
91
|
+
|
|
92
|
+
`can` only shapes the page; the real request is still checked.
|
|
93
|
+
|
|
94
|
+
- The framework's generated client has `$access.can(...)` when the app is configured with `access`, answered by
|
|
95
|
+
`POST {api}/_access/can` with the engine that decides the real requests. With `createAccess`, `$access` also has
|
|
96
|
+
the verbs `signIn`, `signUp`, `signOut`, `resetPassword` and `me` (a verb the instance does not serve is left out).
|
|
97
|
+
- With the Better Auth plugin, `aventaraAccessClient({ api: "/api" })` adds `authClient.access.can([...])`: one request
|
|
98
|
+
for a list of questions, one boolean each. A question with `where` asks about one row by its unique where; a row the
|
|
99
|
+
user cannot read answers `false`, as a missing one does.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Errors, refusals and warnings
|
|
2
|
+
|
|
3
|
+
## On a refused request
|
|
4
|
+
|
|
5
|
+
A request the policy refuses answers `403` with code `A4002` (the framework's `AvAuthError` class in server code). `cause.message` names
|
|
6
|
+
what was refused. Check, in this order:
|
|
7
|
+
|
|
8
|
+
1. Is there a grant for this caller's role, this action and this Resource? Nothing is allowed until one allows it.
|
|
9
|
+
2. Does a deny match? Any deny wins, an admin's grants included. A deny that reads a claim or `me` value the caller
|
|
10
|
+
lacks matches (it fails closed). Exception: for the anonymous caller, a deny's `if` comparing a claim with `true`
|
|
11
|
+
(`banned: true`) never holds, so it never closes an `anyone` grant.
|
|
12
|
+
3. Is the caller who you think? A present but invalid `Authorization` header gives `me: null` and the cookie is not
|
|
13
|
+
tried. A cookie on a write from an untrusted origin is not read.
|
|
14
|
+
4. Does the request select, filter or sort by a field governed for reading (`"read"`, `"both"` or the list form) that
|
|
15
|
+
no grant opens, or a sealed secret column? Does a create or update set a field governed for writing (`"write"`,
|
|
16
|
+
`"both"`, the list form, or Better Auth's `role`, which Access governs as `"write"`) that the grant does not name?
|
|
17
|
+
That is refused, not dropped.
|
|
18
|
+
5. Is the Resource one of Better Auth's tables? Closed until `resources` opens it, and Access's own tables never open.
|
|
19
|
+
6. Does a create or update send a reference (`authorId`) to a row the caller cannot read? Grant read on the target.
|
|
20
|
+
7. Does an update move the row out of what its grant covers (`where` checked on the new row, `after`)? That is
|
|
21
|
+
refused and rolled back.
|
|
22
|
+
|
|
23
|
+
In development, `await engine.explain(question)` (on `access.policy`) lists each rule's part in a `can` decision; it
|
|
24
|
+
throws when `NODE_ENV` is `production`. In production, turn on `audit`: a decision's `reason` names the rule.
|
|
25
|
+
|
|
26
|
+
## Framework codes Access causes
|
|
27
|
+
|
|
28
|
+
| Code | Status | Meaning |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `A4002` | 403 | The policy refused: no grant, a deny, a governed or sealed field, a closed table, an unreadable reference, or the row an update leaves |
|
|
31
|
+
| `A2003` | 404 | A unique read, update or delete matched no row the caller may read. A row outside the caller's read scope answers as missing, so the answer does not reveal that it exists |
|
|
32
|
+
| `A3000` | 500 | A scope or an `exists` question compares a `datetime`, `decimal` or `bigint` field (not usable through Aventara yet); or the engine refused to run (for example beacons not checked). Read the server log |
|
|
33
|
+
| `A3001` | 500 | The database failed while answering an `exists` question |
|
|
34
|
+
|
|
35
|
+
## `AccessError` codes
|
|
36
|
+
|
|
37
|
+
Thrown by `createAccess`, its instance, `aventaraAccess()`, `accessOf(auth)`, the registry and the engine. Branch on
|
|
38
|
+
`error.code` (`AccessErrorCode`); the message starts `Aventara Access:` and says what to change.
|
|
39
|
+
|
|
40
|
+
| Code | Meaning | Fix |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `ACCESS_BETTER_AUTH_VERSION` | `better-auth` missing or outside `~1.7.12`; under `mcp` or `audience`, the OAuth provider or `cimd()` outside it | `npm i better-auth@~1.7.12` (and the provider packages in the same range) |
|
|
43
|
+
| `ACCESS_CLAIMS_UNDECLARED` | The rules or guardrails read a claim, or a token carries one, that `policies.claims` does not declare | Declare it in `policies.claims` |
|
|
44
|
+
| `ACCESS_PLUGIN_DUPLICATE` | `jwt()` or `aventaraAccess()` in `createAccess`'s `betterAuth.plugins`; a second Access plugin; one `aventaraAccess()` in two different Better Auth instances; a plugin defining `jwt()`'s endpoints or Access's routes | Remove it: Access installs it |
|
|
45
|
+
| `ACCESS_OPTIONS_INVALID` | An option Access does not take or refuses as unsafe: no base URL; a claim read from a field without `input: false`; a grant on a Better Auth table `resources` leaves closed; `passwords` beside your own `emailAndPassword.password`; Node.js 22 without `@node-rs/argon2`; a `principal` object shared by two users; `sameSite: "none"` without `trustedOrigins`; an organization plugin without `requireEmailVerificationOnInvitation: true`; an OAuth provider setup that weakens agents; off Prisma's adapter without `resources.authTables` | Follow the message. Never loosen the setting it names to get past it. An opt-in the message names (`openRegistration`, `insecureLan`, `claimsOf`) weakens security: ask the developer before setting it; never set it just to get past the error |
|
|
46
|
+
| `ACCESS_RESOURCE_MISSING` | A table `resources` opens is missing from Better Auth (plugin not installed) or from the rules' schema; the organization bridge without `organization()` | Add the plugin, or run `npx auth@~1.7.12 generate --yes`, migrate, `aventara:prepare` |
|
|
47
|
+
| `ACCESS_QUESTION_INVALID` | A `fieldsOf` question without `access: "read"` or `"write"`, or with an `action` that does not fit | Ask `{ access: "read" }`, or `{ access: "write", action? }` |
|
|
48
|
+
| `ACCESS_LOCK_MISMATCH` | A pulled file no longer hashes to its pin in `avAccess.lock.json`, files the lock does not pin, or `requireLock` with no lock | `avaccess pull` again; never edit a pulled file |
|
|
49
|
+
| `ACCESS_KEY_REFUSED` | A registry key 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 |
|
|
50
|
+
| `ACCESS_OWNERSHIP` | A pushed file grants on a Resource another namespace owns or the operator has not assigned it, or guardrails that grant (`403`) | Grant only on your own Resources |
|
|
51
|
+
| `ACCESS_POLICIES_INVALID` | A pushed file the engine would not run from (`400`), or over 1 MiB (`413`) | Push the `.contract()` of your rules |
|
|
52
|
+
| `ACCESS_NAMESPACE` | A namespace name that is not one, unknown, or already taken | Check the name |
|
|
53
|
+
| `ACCESS_REVISION_CHANGED` | A stored revision changed outside the registry (`500`) | Restore the registry's tables; push again |
|
|
54
|
+
| `ACCESS_FOREIGN_CROSS_DATABASE` | The rules read another namespace's Resources as foreign from another database (`403`) | Use the owner's database, or carry the fact in a claim |
|
|
55
|
+
| `ACCESS_BEACON_MISMATCH` | The ownership beacon check failed or was not awaited at startup (`409` at push) | Point the service at the owner's database; `await access.policy.checkBeacons(...)` before serving |
|
|
56
|
+
|
|
57
|
+
The last six belong to the registry and multi-service setups; the README's "The registry and avaccess" section covers
|
|
58
|
+
them.
|
|
59
|
+
|
|
60
|
+
## Warnings
|
|
61
|
+
|
|
62
|
+
Node.js warnings (type `AventaraAccessWarning`), printed when the framework starts with the engine, once per process and namespace.
|
|
63
|
+
They change no decision, but each points at a write that will be refused or a link that is not checked.
|
|
64
|
+
|
|
65
|
+
| Code | Meaning | Fix |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `ACCESS_READ_GOVERNED_UNIQUE` | A field governed as `"read"` is unique (one of its Resource's identifiers): a write that sends a value another row holds fails on the duplicate, which tells the writer the value exists | Govern it as `"both"`, or accept that writers learn which values exist |
|
|
68
|
+
| `ACCESS_REFERENCE_UNREADABLE` | A role may create or update a reference to a Resource no read grant reaches for it; each such write is refused `A4002`. Names `resources: { users: true }` when the target is a closed Better Auth table | Grant that role read on the target (narrowed by `where`), or lock the reference with `write: { except }` |
|
|
69
|
+
| `ACCESS_FOREIGN_REFERENCE_UNCHECKED` | A create or update grant may link any row of a foreign Resource by its identifier | Constrain the link through the relation: a `where` on the create grant, an `after` on the update grant |
|
|
70
|
+
|
|
71
|
+
## Better Auth's answers on Access's routes
|
|
72
|
+
|
|
73
|
+
| Answer | Meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `403 MISSING_OR_NULL_ORIGIN`, `403 INVALID_ORIGIN` | A request with a cookie needs an `Origin` matching `BETTER_AUTH_URL` or `trustedOrigins` |
|
|
76
|
+
| `400 PASSWORD_TOO_LONG` | Longer than `emailAndPassword.maxPasswordLength` (128 by default) |
|
|
77
|
+
| `429 TOO_MANY_REQUESTS` | Better Auth's rate limit, or Access's per-account limit on server-side sign-in calls |
|
|
78
|
+
| `404` on `/sign-in/*`, `/sign-up/*` | The `service` profile: it hands no caller a session |
|
|
79
|
+
|
|
80
|
+
## Audit reasons
|
|
81
|
+
|
|
82
|
+
| `reason` | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `grant:<id>` | A grant allowed it |
|
|
85
|
+
| `deny:<id>` | A deny matched |
|
|
86
|
+
| `deny:guardrail:<id>` | An org guardrail matched |
|
|
87
|
+
| `deny:better-auth:<Resource>` | A Better Auth table is closed (`…/<actions>`: a write an opened table never takes) |
|
|
88
|
+
| `deny:mcp-scope`, `deny:mcp-audience` | An agent's token lacks the scope, or was used outside the MCP endpoint |
|
|
89
|
+
| `no grant` | Nothing allowed it |
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
Complete rules for the common tasks, type-checked against a schema of `User`, `Campaign`, `Member` (`userId`,
|
|
4
|
+
`campaignId`, `role`: `DM` or `PLAYER`), `Note` (`authorId`, `campaignId`, `body`, `visibility`: `PARTY`, `PRIVATE`
|
|
5
|
+
or `DM_ONLY`) and `Character` (`ownerId`, `campaignId`, `name`). Rename to your own Resources and fields; the compiler
|
|
6
|
+
names every key that does not exist.
|
|
7
|
+
|
|
8
|
+
## A whole rule set
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// src/campaign.policies.ts
|
|
12
|
+
import { definePolicies } from "@aventara/access";
|
|
13
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
14
|
+
|
|
15
|
+
/** The request's principal: the signed-in user and the token's claims. Anonymous is `null`. */
|
|
16
|
+
export type Principal = {
|
|
17
|
+
id: string;
|
|
18
|
+
claims: { role?: "admin"; banned?: boolean; suspended?: boolean };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const campaignPolicies = definePolicies(model)
|
|
22
|
+
.principal<Principal>()
|
|
23
|
+
|
|
24
|
+
.roles(({ me }) => ({
|
|
25
|
+
// A site-wide role, from a claim only the server can set.
|
|
26
|
+
admin: { global: { claims: { role: "admin" } } },
|
|
27
|
+
// Roles held on a row: the caller's membership of a campaign.
|
|
28
|
+
dm: {
|
|
29
|
+
on: "Campaign",
|
|
30
|
+
where: { members: { some: { userId: me.id, role: "DM" } } },
|
|
31
|
+
},
|
|
32
|
+
player: {
|
|
33
|
+
on: "Campaign",
|
|
34
|
+
where: { members: { some: { userId: me.id, role: "PLAYER" } } },
|
|
35
|
+
},
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
// Out of every default read and write: only a grant that names it opens it.
|
|
39
|
+
.govern("User", ["email"])
|
|
40
|
+
// The row roles read a member's role: everyone who reads the row sees it, and
|
|
41
|
+
// only a grant that names it changes it.
|
|
42
|
+
.govern("Member", { role: "write" })
|
|
43
|
+
|
|
44
|
+
.grants(({ grant, me }) => [
|
|
45
|
+
// People: yourself, and the people you share a campaign with. A note's author,
|
|
46
|
+
// a character's owner and a member's user are references to User, so writing
|
|
47
|
+
// them needs read on that User row. Campaign-mates read every User field not
|
|
48
|
+
// governed: govern any they should not see (a role, a ban).
|
|
49
|
+
grant("signedIn", "read", "User", {
|
|
50
|
+
where: {
|
|
51
|
+
OR: [
|
|
52
|
+
{ id: me.id },
|
|
53
|
+
{ members: { some: { campaign: { members: { some: { userId: me.id } } } } } },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
// Your own email, and nobody else's.
|
|
58
|
+
grant("signedIn", "read", "User", { where: { id: me.id }, fields: ["User.email"] }),
|
|
59
|
+
|
|
60
|
+
// Campaigns: members read them, the DM changes them.
|
|
61
|
+
grant(["dm", "player"], "read", "Campaign"),
|
|
62
|
+
grant("dm", ["update", "delete"], "Campaign"),
|
|
63
|
+
|
|
64
|
+
// Membership: the DM seats and removes players, and may change a seat's role,
|
|
65
|
+
// never who sits in it or which campaign it belongs to. The role is governed
|
|
66
|
+
// for writing, so the DM's grants name it.
|
|
67
|
+
grant(["dm", "player"], "read", "Member", { in: "campaign" }),
|
|
68
|
+
grant("dm", ["create", "delete"], "Member", {
|
|
69
|
+
in: "campaign",
|
|
70
|
+
fields: ["Member.role"],
|
|
71
|
+
}),
|
|
72
|
+
grant("dm", "update", "Member", {
|
|
73
|
+
in: "campaign",
|
|
74
|
+
write: { except: ["userId", "campaignId"] },
|
|
75
|
+
fields: ["Member.role"],
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
// Notes: the author keeps their own; a player writes as themselves only and
|
|
79
|
+
// reads the party's; the DM reads, edits and removes every note of their
|
|
80
|
+
// campaign, writes as themselves only, and never changes who wrote a note.
|
|
81
|
+
grant("signedIn", ["read", "update", "delete"], "Note", { where: { authorId: me.id } }),
|
|
82
|
+
grant("player", "read", "Note", { in: "campaign", where: { visibility: "PARTY" } }),
|
|
83
|
+
grant("player", "create", "Note", { in: "campaign", where: { authorId: me.id } }),
|
|
84
|
+
grant("dm", ["read", "delete"], "Note", { in: "campaign" }),
|
|
85
|
+
grant("dm", "create", "Note", { in: "campaign", where: { authorId: me.id } }),
|
|
86
|
+
grant("dm", "update", "Note", { in: "campaign", write: { except: ["authorId"] } }),
|
|
87
|
+
|
|
88
|
+
// Characters: a player keeps their character and moves it only to a campaign
|
|
89
|
+
// they play in (`after` holds the row the update leaves).
|
|
90
|
+
grant(["dm", "player"], "read", "Character", { in: "campaign" }),
|
|
91
|
+
grant("player", "create", "Character", { in: "campaign", where: { ownerId: me.id } }),
|
|
92
|
+
grant("signedIn", "update", "Character", {
|
|
93
|
+
where: { ownerId: me.id },
|
|
94
|
+
after: { ownerId: me.id, campaign: { $role: "player" } },
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
// The admin reads everything, emails included, and changes nothing. "*" also
|
|
98
|
+
// reaches the Better Auth tables `resources` opens (session, account).
|
|
99
|
+
grant("admin", "read", "*", { fields: ["User.email"] }),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
.denies(({ deny }) => [
|
|
103
|
+
// A banned user does nothing, whatever the grants say. A token without the
|
|
104
|
+
// claim is refused too: a missing claim fails closed.
|
|
105
|
+
deny("*", "*", { name: "banned", if: { claims: { banned: true } } }),
|
|
106
|
+
// A suspended user reads but writes nothing.
|
|
107
|
+
deny(["create", "update", "delete"], "*", {
|
|
108
|
+
name: "suspended-read-only",
|
|
109
|
+
if: { claims: { suspended: true } },
|
|
110
|
+
}),
|
|
111
|
+
// An AI agent never deletes, even for a user who may.
|
|
112
|
+
deny("delete", "*", { name: "no-agent-deletes", if: { origin: "agent" } }),
|
|
113
|
+
// DM-only notes stay the DM's, even for their author.
|
|
114
|
+
deny(["read", "create", "update"], "Note", {
|
|
115
|
+
name: "dm-only-notes",
|
|
116
|
+
where: { visibility: "DM_ONLY", NOT: { campaign: { $role: "dm" } } },
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
What each pattern does:
|
|
122
|
+
|
|
123
|
+
| Pattern | Rule |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Owner only | `where: { authorId: me.id }` on every action. On `update` it is checked on the row the update leaves too, so the owner cannot hand the row away. On `create` the caller cannot write as someone else. |
|
|
126
|
+
| Membership role | A row role `on: "Campaign"`, then `{ in: "campaign" }` on grants of the Resources that belong to a campaign |
|
|
127
|
+
| Field lock | `write: { except: [...] }`: a relation and its reference fields are locked together |
|
|
128
|
+
| Moving a row | `after` on an update grant: what the new row must also meet |
|
|
129
|
+
| Sensitive field | `.govern` + a grant with `fields`, narrowed by `where` |
|
|
130
|
+
| A field a role reads | `.govern(Resource, { field: "write" })`: read as any field, written only by a grant naming it |
|
|
131
|
+
| Global role | `global: { claims }`, from a claim declared in `policies.claims` and read from an `input: false` field |
|
|
132
|
+
| Deny by claim | `if: { claims: { banned: true } }`: compare with the value that means "refuse", so a missing claim refuses too |
|
|
133
|
+
| Deny by origin | `if: { origin: "agent" }` (MCP), `"client"` (a user's request) or `"application"` (server code that names a principal) |
|
|
134
|
+
| Deny by row | `where` on the deny, with `NOT` and `$role` for "unless the caller holds this role here" |
|
|
135
|
+
|
|
136
|
+
Campaign creation is left out above: a new campaign has no members, so no row role covers it yet. Grant
|
|
137
|
+
`grant("signedIn", "create", "Campaign")` and seat the creator as its DM from server code in the same transaction, taking the
|
|
138
|
+
creator's id from `ctx.$state.me`, never from the request arguments (the framework's own skill covers hooks and
|
|
139
|
+
`forceTransaction`).
|
|
140
|
+
|
|
141
|
+
## A field written and never read back
|
|
142
|
+
|
|
143
|
+
A government id, a tax number or a stored secret is written by the people who fill in a form and read back by almost
|
|
144
|
+
nobody. Govern it as `"read"`: every grant that may write the row writes it, and only a read grant that names it reads
|
|
145
|
+
it. On a schema with a `Citizen` Resource whose `nationalId` holds the id:
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
.govern("Citizen", { nationalId: "read" })
|
|
149
|
+
.grants(({ grant, me }) => [
|
|
150
|
+
// Each user registers and edits their own record, id number included.
|
|
151
|
+
grant("signedIn", ["read", "create", "update"], "Citizen", { where: { userId: me.id } }),
|
|
152
|
+
// Only the registrar reads the number back.
|
|
153
|
+
grant("registrar", "read", "Citizen", { fields: ["Citizen.nationalId"] }),
|
|
154
|
+
])
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A read that names `nationalId` without the registrar's grant is refused `A4002`, the owner's own included, and a
|
|
158
|
+
default read leaves it out. Keep it out of filters and sorts too: they need read access to the field on every row they
|
|
159
|
+
touch. If the field is unique, a write that sends a number another row holds fails on the duplicate, which tells the
|
|
160
|
+
writer that number is taken; Access warns `ACCESS_READ_GOVERNED_UNIQUE`. Govern a unique field as `"both"` instead, so
|
|
161
|
+
only a grant that names it writes it.
|
|
162
|
+
|
|
163
|
+
## Public reads for signed-out callers
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// src/public.policies.ts
|
|
167
|
+
import { definePolicies } from "@aventara/access";
|
|
168
|
+
import { model } from "./generated/aventara/discovery.artifact.js";
|
|
169
|
+
|
|
170
|
+
export type Visitor = { id: string; claims: { banned?: boolean } };
|
|
171
|
+
|
|
172
|
+
export const publicPolicies = definePolicies(model)
|
|
173
|
+
.principal<Visitor>()
|
|
174
|
+
.govern("User", ["email"])
|
|
175
|
+
.grants(({ grant, me }) => [
|
|
176
|
+
// Wide: anyone, signed out included, reads every campaign and every field not
|
|
177
|
+
// governed (here `id` and `name`). On your schema, narrow it with a `where` on
|
|
178
|
+
// a public flag and govern every field that is not public.
|
|
179
|
+
grant("anyone", "read", "Campaign"),
|
|
180
|
+
// Signed-in users read their own profile.
|
|
181
|
+
grant("signedIn", "read", "User", { where: { id: me.id } }),
|
|
182
|
+
])
|
|
183
|
+
// A ban is a comparison with true, which never holds for the anonymous caller,
|
|
184
|
+
// so it does not close the public grant; it does refuse a banned user.
|
|
185
|
+
.denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Narrow a public grant to the rows that are public with a `where` on a flag the owner sets (`where: { published:
|
|
189
|
+
true }`), and govern every field that is not. Do not compare `me` in an `anyone` grant's `where`: the anonymous caller
|
|
190
|
+
lacks every `me` value, so such a grant never matches for them. Give the read `cache({ for: "anonymous" })` to let a CDN
|
|
191
|
+
keep the signed-out answer ([callers](callers.md#cached-reads)).
|
|
192
|
+
|
|
193
|
+
## Checking the rules before you ship
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// scripts/rules.check.ts
|
|
197
|
+
import assert from "node:assert/strict";
|
|
198
|
+
import type { AccessExists } from "@aventara/access";
|
|
199
|
+
import { campaignPolicies, type Principal } from "../src/campaign.policies.js";
|
|
200
|
+
|
|
201
|
+
const engine = campaignPolicies.engine();
|
|
202
|
+
// A principal carries every boolean claim the denies compare, as Access's tokens do:
|
|
203
|
+
// one left out reads as missing, and the deny that compares it then refuses.
|
|
204
|
+
const ada: Principal = { id: "ada", claims: { banned: false, suspended: false } };
|
|
205
|
+
const banned: Principal = { id: "bo", claims: { banned: true, suspended: false } };
|
|
206
|
+
|
|
207
|
+
// Signed out: no grant names `anyone`, so nothing is allowed.
|
|
208
|
+
assert.equal(
|
|
209
|
+
await engine.can({ principal: null, origin: "client", resource: "Note", action: "read" }),
|
|
210
|
+
false,
|
|
211
|
+
);
|
|
212
|
+
// A banned user is refused what a grant would allow.
|
|
213
|
+
assert.equal(
|
|
214
|
+
await engine.can({
|
|
215
|
+
principal: banned,
|
|
216
|
+
origin: "client",
|
|
217
|
+
resource: "Note",
|
|
218
|
+
action: "update",
|
|
219
|
+
row: { id: "n1", campaignId: "c1", authorId: "bo", body: "", visibility: "PRIVATE" },
|
|
220
|
+
}),
|
|
221
|
+
false,
|
|
222
|
+
);
|
|
223
|
+
// The role grants ask the database whether the caller holds a role on a row
|
|
224
|
+
// (`exists`, through your ORM). Here ada holds none: every lookup answers false.
|
|
225
|
+
// Without `exists`, a rule that needs one is unknown, and the engine refuses.
|
|
226
|
+
const noRoles: AccessExists = async () => false;
|
|
227
|
+
// The author may update their own note, and may not hand it to someone else.
|
|
228
|
+
const note = { id: "n2", campaignId: "c1", authorId: "ada", body: "", visibility: "PRIVATE" };
|
|
229
|
+
assert.equal(
|
|
230
|
+
await engine.can({
|
|
231
|
+
principal: ada,
|
|
232
|
+
origin: "client",
|
|
233
|
+
resource: "Note",
|
|
234
|
+
action: "update",
|
|
235
|
+
row: note,
|
|
236
|
+
exists: noRoles,
|
|
237
|
+
}),
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
assert.equal(
|
|
241
|
+
await engine.can({
|
|
242
|
+
principal: ada,
|
|
243
|
+
origin: "client",
|
|
244
|
+
resource: "Note",
|
|
245
|
+
action: "update",
|
|
246
|
+
row: { ...note, authorId: "olive" },
|
|
247
|
+
before: note,
|
|
248
|
+
exists: noRoles,
|
|
249
|
+
}),
|
|
250
|
+
false,
|
|
251
|
+
);
|
|
252
|
+
// An agent never deletes.
|
|
253
|
+
assert.equal(
|
|
254
|
+
await engine.can({ principal: ada, origin: "agent", resource: "Note", action: "delete", row: note }),
|
|
255
|
+
false,
|
|
256
|
+
);
|
|
257
|
+
// Which fields a signed-in user sees on User: email only on their own row.
|
|
258
|
+
console.log(engine.fieldsOf({ principal: ada, origin: "client", resource: "User", access: "read" }));
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Run it with `npx tsx scripts/rules.check.ts`. A row question that a relation decides (a role held on a row, a
|
|
262
|
+
`where` through a relation) needs `exists`; give it one that answers from your test data, or through your ORM. In
|
|
263
|
+
development, `await engine.explain(question)` lists each rule's part in a `can` decision, with `"unknown"` for a rule
|
|
264
|
+
it could not decide.
|
|
265
|
+
|
|
266
|
+
## The organization bridge (Better Auth's organization plugin)
|
|
267
|
+
|
|
268
|
+
With `aventaraAccess({ organization: { claim: "orgRole", organizationClaim: "org", roles: [["owner", "admin"],
|
|
269
|
+
["admin", "admin"], ["member", "member"]] } })`, declare both claims as `"string"`, copy `org` onto the principal, and
|
|
270
|
+
hold every grant to it:
|
|
271
|
+
|
|
272
|
+
```text
|
|
273
|
+
.roles(() => ({ orgAdmin: { global: { claims: { orgRole: "admin" } } } }))
|
|
274
|
+
.grants(({ grant, me }) => [
|
|
275
|
+
grant("signedIn", "read", "Campaign", { where: { organizationId: me.org } }),
|
|
276
|
+
grant("orgAdmin", ["create", "update", "delete"], "Campaign", { where: { organizationId: me.org } }),
|
|
277
|
+
])
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An organization role is that organization's, but a global role is global: without `organizationId: me.org`, an admin
|
|
281
|
+
of one organization would act on every organization's rows. A principal without `org` matches no such grant.
|