@aventara/access 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +877 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +2536 -2
- package/dist/access/access-options.d.ts +74 -0
- package/dist/access/access-options.js +5 -0
- package/dist/access/access.error.d.ts +35 -0
- package/dist/access/access.error.js +11 -0
- package/dist/access/access.factory.d.ts +275 -0
- package/dist/access/access.factory.js +440 -0
- package/dist/access/better-auth.hardening.d.ts +120 -0
- package/dist/access/better-auth.hardening.js +420 -0
- package/dist/access/better-auth.loader.d.ts +67 -0
- package/dist/access/better-auth.loader.js +65 -0
- package/dist/access/better-auth.range.d.ts +9 -0
- package/dist/access/better-auth.range.js +28 -0
- package/dist/access/better-auth.require.d.ts +90 -0
- package/dist/access/better-auth.require.js +6 -0
- package/dist/access/better-auth.require.node.d.ts +7 -0
- package/dist/access/better-auth.require.node.js +52 -0
- package/dist/access/claims.mapper.d.ts +79 -0
- package/dist/access/claims.mapper.js +152 -0
- package/dist/access/key-set.cache.d.ts +10 -0
- package/dist/access/key-set.cache.js +63 -0
- package/dist/access/policies-block.checker.d.ts +64 -0
- package/dist/access/policies-block.checker.js +138 -0
- package/dist/access/principal.subject.d.ts +22 -0
- package/dist/access/principal.subject.js +89 -0
- package/dist/access/profile.policy.d.ts +74 -0
- package/dist/access/profile.policy.js +110 -0
- package/dist/access/request.state.d.ts +89 -0
- package/dist/access/request.state.js +266 -0
- package/dist/access/session.hooks.d.ts +114 -0
- package/dist/access/session.hooks.js +258 -0
- package/dist/access/token.verifier.d.ts +35 -0
- package/dist/access/token.verifier.js +69 -0
- package/dist/access-schema.d.ts +48 -0
- package/dist/access-schema.js +5 -0
- package/dist/access-warning.emitter.d.ts +4 -0
- package/dist/access-warning.emitter.js +24 -0
- package/dist/access.builder.d.ts +340 -0
- package/dist/access.builder.js +75 -0
- package/dist/access.engine.d.ts +155 -0
- package/dist/access.engine.js +477 -0
- package/dist/audit/audit-event.d.ts +135 -0
- package/dist/audit/audit-event.js +8 -0
- package/dist/audit/audit.log.d.ts +122 -0
- package/dist/audit/audit.log.js +345 -0
- package/dist/audit/audit.schema.d.ts +143 -0
- package/dist/audit/audit.schema.js +50 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +2 -0
- package/dist/aventara-where.translator.d.ts +19 -0
- package/dist/aventara-where.translator.js +87 -0
- package/dist/beacon/beacon.check.d.ts +59 -0
- package/dist/beacon/beacon.check.js +128 -0
- package/dist/beacon/beacon.schema.d.ts +41 -0
- package/dist/beacon/beacon.schema.js +48 -0
- package/dist/beacon/beacon.store.d.ts +29 -0
- package/dist/beacon/beacon.store.js +78 -0
- package/dist/better-auth/access.client-plugin.d.ts +75 -0
- package/dist/better-auth/access.client-plugin.js +133 -0
- package/dist/better-auth/access.plugin.d.ts +164 -0
- package/dist/better-auth/access.plugin.js +348 -0
- package/dist/better-auth/access.provider.d.ts +80 -0
- package/dist/better-auth/access.provider.js +100 -0
- package/dist/better-auth/index.d.ts +7 -0
- package/dist/better-auth/index.js +4 -0
- package/dist/better-auth/organization.bridge.d.ts +66 -0
- package/dist/better-auth/organization.bridge.js +144 -0
- package/dist/canonical-json.writer.d.ts +12 -0
- package/dist/canonical-json.writer.js +30 -0
- package/dist/cli/avaccess.bin.d.ts +2 -0
- package/dist/cli/avaccess.bin.js +26 -0
- package/dist/cli/avaccess.program.d.ts +15 -0
- package/dist/cli/avaccess.program.js +166 -0
- package/dist/cli/beacon-pins.file.d.ts +14 -0
- package/dist/cli/beacon-pins.file.js +62 -0
- package/dist/cli/beacon.loader.d.ts +20 -0
- package/dist/cli/beacon.loader.js +71 -0
- package/dist/cli/cli.context.d.ts +49 -0
- package/dist/cli/cli.context.js +66 -0
- package/dist/cli/diff.command.d.ts +2 -0
- package/dist/cli/diff.command.js +66 -0
- package/dist/cli/generate.command.d.ts +2 -0
- package/dist/cli/generate.command.js +82 -0
- package/dist/cli/keys.command.d.ts +2 -0
- package/dist/cli/keys.command.js +96 -0
- package/dist/cli/lock.file.d.ts +31 -0
- package/dist/cli/lock.file.js +181 -0
- package/dist/cli/module.loader.d.ts +13 -0
- package/dist/cli/module.loader.js +63 -0
- package/dist/cli/policies.diff.d.ts +16 -0
- package/dist/cli/policies.diff.js +101 -0
- package/dist/cli/pull.command.d.ts +2 -0
- package/dist/cli/pull.command.js +234 -0
- package/dist/cli/push.command.d.ts +2 -0
- package/dist/cli/push.command.js +59 -0
- package/dist/cli/registry.client.d.ts +63 -0
- package/dist/cli/registry.client.js +287 -0
- package/dist/cli/schema.command.d.ts +2 -0
- package/dist/cli/schema.command.js +60 -0
- package/dist/cli/serve.command.d.ts +6 -0
- package/dist/cli/serve.command.js +200 -0
- package/dist/cli/status.command.d.ts +2 -0
- package/dist/cli/status.command.js +31 -0
- package/dist/cli/translator.flag.d.ts +6 -0
- package/dist/cli/translator.flag.js +24 -0
- package/dist/cli/verify.command.d.ts +2 -0
- package/dist/cli/verify.command.js +40 -0
- package/dist/client/access-client.factory.d.ts +92 -0
- package/dist/client/access-client.factory.js +269 -0
- package/dist/client/access-verbs.schema.d.ts +66 -0
- package/dist/client/access-verbs.schema.js +159 -0
- package/dist/client/verb-rate.limiter.d.ts +116 -0
- package/dist/client/verb-rate.limiter.js +358 -0
- package/dist/foreign/foreign-facts.d.ts +30 -0
- package/dist/foreign/foreign-facts.js +75 -0
- package/dist/foreign/foreign.merger.d.ts +10 -0
- package/dist/foreign/foreign.merger.js +52 -0
- package/dist/foreign-references.check.d.ts +6 -0
- package/dist/foreign-references.check.js +131 -0
- package/dist/generator/client.emitter.d.ts +28 -0
- package/dist/generator/client.emitter.js +411 -0
- package/dist/generator/copied-modules.reader.d.ts +14 -0
- package/dist/generator/copied-modules.reader.js +80 -0
- package/dist/generator/namespace.facts.d.ts +28 -0
- package/dist/generator/namespace.facts.js +32 -0
- package/dist/generator/orm.translator.d.ts +48 -0
- package/dist/generator/orm.translator.js +29 -0
- package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
- package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
- package/dist/generator/prisma7/prisma-where.translator.d.ts +73 -0
- package/dist/generator/prisma7/prisma-where.translator.js +138 -0
- package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
- package/dist/generator/prisma7/prisma7.translator.js +87 -0
- package/dist/generator/schema.emitter.d.ts +14 -0
- package/dist/generator/schema.emitter.js +23 -0
- package/dist/generator/tsconfig.reader.d.ts +44 -0
- package/dist/generator/tsconfig.reader.js +110 -0
- package/dist/generator/typescript-literal.writer.d.ts +5 -0
- package/dist/generator/typescript-literal.writer.js +42 -0
- package/dist/guardrails/guardrails-contract.d.ts +45 -0
- package/dist/guardrails/guardrails-contract.js +12 -0
- package/dist/guardrails/guardrails.builder.d.ts +48 -0
- package/dist/guardrails/guardrails.builder.js +32 -0
- package/dist/guardrails/guardrails.compiler.d.ts +10 -0
- package/dist/guardrails/guardrails.compiler.js +254 -0
- package/dist/guardrails/guardrails.composer.d.ts +7 -0
- package/dist/guardrails/guardrails.composer.js +95 -0
- package/dist/guardrails/guardrails.hash.d.ts +10 -0
- package/dist/guardrails/guardrails.hash.js +38 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
- package/dist/kiosk/kiosk-verbs.schema.js +61 -0
- package/dist/kiosk/kiosk.plugin.d.ts +217 -0
- package/dist/kiosk/kiosk.plugin.js +877 -0
- package/dist/kiosk/kiosk.schema.d.ts +176 -0
- package/dist/kiosk/kiosk.schema.js +101 -0
- package/dist/kiosk/kiosk.store.d.ts +171 -0
- package/dist/kiosk/kiosk.store.js +478 -0
- package/dist/kiosk/lan-range.policy.d.ts +31 -0
- package/dist/kiosk/lan-range.policy.js +113 -0
- package/dist/kiosk/pin.policy.d.ts +9 -0
- package/dist/kiosk/pin.policy.js +143 -0
- package/dist/kiosk/session-use.marker.d.ts +4 -0
- package/dist/kiosk/session-use.marker.js +16 -0
- package/dist/lock/lock-contract.d.ts +38 -0
- package/dist/lock/lock-contract.js +67 -0
- package/dist/lock/lock.verifier.d.ts +27 -0
- package/dist/lock/lock.verifier.js +62 -0
- package/dist/mcp/agent-scope.gate.d.ts +13 -0
- package/dist/mcp/agent-scope.gate.js +75 -0
- package/dist/mcp/agent.grant.d.ts +40 -0
- package/dist/mcp/agent.grant.js +92 -0
- package/dist/mcp/mcp-options.d.ts +60 -0
- package/dist/mcp/mcp-options.js +72 -0
- package/dist/oauth/access-token.verifier.d.ts +66 -0
- package/dist/oauth/access-token.verifier.js +201 -0
- package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
- package/dist/oauth/oauth-provider.hardening.js +261 -0
- package/dist/passwords/argon2id.hasher.d.ts +97 -0
- package/dist/passwords/argon2id.hasher.js +256 -0
- package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
- package/dist/passwords/node-rs-argon2.adapter.js +42 -0
- package/dist/passwords/passwords.options.d.ts +79 -0
- package/dist/passwords/passwords.options.js +212 -0
- package/dist/policies-contract.d.ts +219 -0
- package/dist/policies-contract.js +8 -0
- package/dist/policies.compiler.d.ts +38 -0
- package/dist/policies.compiler.js +1433 -0
- package/dist/policies.decider.d.ts +103 -0
- package/dist/policies.decider.js +390 -0
- package/dist/policies.hash.d.ts +10 -0
- package/dist/policies.hash.js +54 -0
- package/dist/predicate.evaluator.d.ts +62 -0
- package/dist/predicate.evaluator.js +378 -0
- package/dist/random-secret.generator.d.ts +2 -0
- package/dist/random-secret.generator.js +15 -0
- package/dist/read-governed-unique.check.d.ts +3 -0
- package/dist/read-governed-unique.check.js +21 -0
- package/dist/reference-reads.check.d.ts +16 -0
- package/dist/reference-reads.check.js +158 -0
- package/dist/registry/access.registry.d.ts +173 -0
- package/dist/registry/access.registry.js +865 -0
- package/dist/registry/beacon.ledger.d.ts +30 -0
- package/dist/registry/beacon.ledger.js +93 -0
- package/dist/registry/ownership.check.d.ts +54 -0
- package/dist/registry/ownership.check.js +221 -0
- package/dist/registry/registry.endpoints.d.ts +50 -0
- package/dist/registry/registry.endpoints.js +193 -0
- package/dist/registry/registry.key.d.ts +7 -0
- package/dist/registry/registry.key.js +13 -0
- package/dist/registry/registry.options.d.ts +13 -0
- package/dist/registry/registry.options.js +28 -0
- package/dist/registry/registry.refusal.d.ts +6 -0
- package/dist/registry/registry.refusal.js +11 -0
- package/dist/registry/registry.schema.d.ts +238 -0
- package/dist/registry/registry.schema.js +127 -0
- package/dist/resources/auth-tables.catalog.d.ts +96 -0
- package/dist/resources/auth-tables.catalog.js +110 -0
- package/dist/resources/auth-tables.guard.d.ts +106 -0
- package/dist/resources/auth-tables.guard.js +462 -0
- package/dist/scope.resolver.d.ts +144 -0
- package/dist/scope.resolver.js +529 -0
- package/dist/sha256.digest.d.ts +2 -0
- package/dist/sha256.digest.js +81 -0
- package/dist/standalone/access.server.d.ts +41 -0
- package/dist/standalone/access.server.js +301 -0
- package/dist/standalone/index.d.ts +1 -0
- package/dist/standalone/index.js +1 -0
- package/package.json +102 -3
- package/skill/SKILL.md +170 -0
- package/skill/reference/callers.md +99 -0
- package/skill/reference/errors.md +89 -0
- package/skill/reference/recipes.md +281 -0
- package/skill/reference/rules.md +153 -0
- package/skill/reference/setup.md +334 -0
- package/src/aventara-where.translator.ts +116 -0
- package/src/beacon/beacon.check.ts +204 -0
- package/src/canonical-json.writer.ts +51 -0
- package/src/generator/prisma7/prisma-where.translator.ts +243 -0
- package/src/policies-contract.ts +215 -0
- package/src/policies.decider.ts +662 -0
- package/src/predicate.evaluator.ts +574 -0
- package/src/scope.resolver.ts +819 -0
- package/src/sha256.digest.ts +85 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# The rule language
|
|
2
|
+
|
|
3
|
+
`definePolicies(model, options?)` takes the schema and returns an immutable builder; each call returns a new builder.
|
|
4
|
+
In an Aventara app the schema is the discovery artifact's `model` (`./generated/aventara/discovery.artifact.js`; in
|
|
5
|
+
Next.js `@/aventara/generated/discovery.artifact`). Every name in a rule is checked against it.
|
|
6
|
+
|
|
7
|
+
| Call | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `.principal<P>()` | Types `me` in every callback. Call it first. Without it, `me.id` and `claims` are type errors. |
|
|
10
|
+
| `.roles(({ me }) => ({ … }))` | Names roles: `global` (from claims) or held `on` a Resource's row (`where`) |
|
|
11
|
+
| `.govern(Resource, [fields])` | Governs fields for reading and writing: only a grant that names one opens it |
|
|
12
|
+
| `.govern(Resource, { field: mode })` | Governs each field for `"write"`, `"read"` or `"both"` |
|
|
13
|
+
| `.grants(({ grant, me }) => [ … ])` | `grant(subjects, actions, resource, options?)`. Calls add up. |
|
|
14
|
+
| `.denies(({ deny, me }) => [ … ])` | `deny(actions, resource, options?)`. Calls add up. |
|
|
15
|
+
| `.contract()` | Compiles to the `aventara-access/policies@1` file (`AccessPolicies`) |
|
|
16
|
+
| `.engine(options?)` | The engine (`AccessEngine`), from the compiled file |
|
|
17
|
+
|
|
18
|
+
Declare roles and governed fields before the grants that name them: the grant's types read them.
|
|
19
|
+
|
|
20
|
+
## Subjects, actions, resources
|
|
21
|
+
|
|
22
|
+
- Subjects: `"signedIn"` (any non-null principal), `"anyone"` (the anonymous caller too), or a role name. One or a
|
|
23
|
+
list: `["dm", "player"]`.
|
|
24
|
+
- Actions: `"read"`, `"create"`, `"update"`, `"delete"`, one or a list. A deny also takes `"*"`.
|
|
25
|
+
- Resource: one of your own Resources, or `"*"` (every Resource the file owns). A foreign Resource (`{ foreign }` in the
|
|
26
|
+
schema) is read in conditions and never granted, denied or governed on.
|
|
27
|
+
|
|
28
|
+
## The principal
|
|
29
|
+
|
|
30
|
+
The principal is whatever your app derives for a request: with `createAccess`, what `principal` returns (or the token's
|
|
31
|
+
identity `{ sub, claims }`, typed `AccessIdentity<typeof claims>`). It is an object; `null`, an array, a function or a
|
|
32
|
+
primitive is anonymous. Its values are read from its own properties only. `me.<path>` in a rule is a reference read at
|
|
33
|
+
question time, never a value baked into the file.
|
|
34
|
+
|
|
35
|
+
`claims` on the principal is what roles and `if` compare: `{ claims: { role?: "admin"; banned?: boolean } }`.
|
|
36
|
+
|
|
37
|
+
## Roles
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
.roles(({ me }) => ({
|
|
41
|
+
// global: held when every listed claim equals its value
|
|
42
|
+
admin: { global: { claims: { role: "admin" } } },
|
|
43
|
+
// on a row: held on each Campaign row the where matches
|
|
44
|
+
dm: { on: "Campaign", where: { members: { some: { userId: me.id, role: "DM" } } } },
|
|
45
|
+
}))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A grant to a row role covers the rows where the caller holds it. On another Resource, `in: "<to-one relation>"` says
|
|
49
|
+
the role is held on that relation's target: `grant("dm", "read", "Note", { in: "campaign" })` covers the notes of the
|
|
50
|
+
campaigns where the caller is DM. In a `where`, `$role: "dm"` means the caller holds that role on this row
|
|
51
|
+
(`campaign: { $role: "player" }` on the related row).
|
|
52
|
+
|
|
53
|
+
## Grant options
|
|
54
|
+
|
|
55
|
+
| Option | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `name` | The rule's id in `explain` and the audit log (default `Resource/actions/subjects#n`) |
|
|
58
|
+
| `where` | The rows the grant covers. On an update grant it is checked on the row the update leaves too. |
|
|
59
|
+
| `in` | The to-one relation whose target holds the subject's row role |
|
|
60
|
+
| `fields` | Governed fields the grant opens, as `"Resource.field"` |
|
|
61
|
+
| `write: { except: [...] }` | Fields a create or update by this grant may not set (create and update grants only) |
|
|
62
|
+
| `after` | What the row an update leaves must also meet (update grants only) |
|
|
63
|
+
|
|
64
|
+
## Deny options
|
|
65
|
+
|
|
66
|
+
| Option | Meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `name` | The rule's id |
|
|
69
|
+
| `where` | The rows the deny covers |
|
|
70
|
+
| `if: { claims, origin }` | A condition on the caller alone: claim values, and the origin (`"client"`, `"agent"`, `"application"`) |
|
|
71
|
+
|
|
72
|
+
A deny with neither `where` nor `if` refuses its actions on its Resource to everyone.
|
|
73
|
+
|
|
74
|
+
## `where`
|
|
75
|
+
|
|
76
|
+
Aventara's `where` vocabulary, typed per Resource:
|
|
77
|
+
|
|
78
|
+
- a field and a value (`visibility: "PARTY"`, `authorId: me.id`), or an operator object: `equals`, `not`, `in`,
|
|
79
|
+
`notIn` (`{ visibility: { in: ["PARTY", "PRIVATE"] } }`);
|
|
80
|
+
- `AND: [ … ]`, `OR: [ … ]`, `NOT: { … }`;
|
|
81
|
+
- a to-one relation by its own `where` (`campaign: { … }`); a to-many by `some`, `every` or `none`;
|
|
82
|
+
- `$role: "<row role on this Resource>"`.
|
|
83
|
+
|
|
84
|
+
Compare values of the field's type. Through Aventara, a scope that compares a `datetime`, `decimal` or `bigint` field
|
|
85
|
+
cannot be used yet (`A3000`, the request is refused): keep those comparisons out of `where` on reads, updates and
|
|
86
|
+
deletes.
|
|
87
|
+
|
|
88
|
+
## How the rules combine
|
|
89
|
+
|
|
90
|
+
- Nothing is allowed until a grant allows it. Any grant that matches allows. Any deny that matches wins over every
|
|
91
|
+
grant, an admin's included. Order never matters.
|
|
92
|
+
- A grant that reads a `me` value the principal lacks never matches. A deny that reads one in its `where` matches,
|
|
93
|
+
whatever `not` or `NOT` surrounds it. A missing or `null` property, an empty string, or a number JSON cannot carry
|
|
94
|
+
counts as missing.
|
|
95
|
+
- A claim the principal lacks fails closed the same way: a global role comparing it is not held; a deny's `if`
|
|
96
|
+
comparing it holds. The anonymous caller has no claims: a comparison with `true` never holds for it (a ban does not
|
|
97
|
+
close an `anyone` grant), and any other comparison holds for a deny.
|
|
98
|
+
- A row value of another type than its field's is unknown: no grant matches it, a deny that compares it matches.
|
|
99
|
+
|
|
100
|
+
## Governed fields
|
|
101
|
+
|
|
102
|
+
A governed field is opened only by a grant that names it in `fields`. Each field has a mode:
|
|
103
|
+
|
|
104
|
+
| Form | Reads | Writes |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `.govern("User", ["email"])` (the list: `"both"`) | Out of every default read; only a read grant naming `User.email` opens it, when the request selects it | Only a create or update grant naming it sets it |
|
|
107
|
+
| `.govern("Member", { role: "write" })` | As any other field | Only a create or update grant naming `Member.role` sets it |
|
|
108
|
+
| `.govern("Citizen", { nationalId: "read" })` | As `"both"` | As any other field |
|
|
109
|
+
|
|
110
|
+
Govern every field your roles, claims or principal read, usually as `"write"`: a role held on `Member` reads
|
|
111
|
+
`Member.role`, so an update grant on `Member` that does not name it must not change it. Selecting a field governed for
|
|
112
|
+
reading without a grant that opens it is refused (`403 A4002`), never dropped quietly. A relation and its reference
|
|
113
|
+
fields are governed together, with one mode (`"author"` with `"authorId"`). A field governed twice with two modes is a
|
|
114
|
+
type error, and refused when the rules compile. `fields` on a Resource with nothing governed is a type error.
|
|
115
|
+
|
|
116
|
+
Access governs Better Auth's `User.role` and the organization `member` table's `role` as `"write"` itself once
|
|
117
|
+
`resources` opens them. To let an admin set one, govern it too (`.govern("User", { role: "write" })`), which lets a
|
|
118
|
+
grant name it, and name it in that admin's grant.
|
|
119
|
+
|
|
120
|
+
## Updates
|
|
121
|
+
|
|
122
|
+
An update is checked twice: its scope (the rows an update grant's `where` covers and no update deny matches), then
|
|
123
|
+
each row it leaves, which must be covered by the same grant (its `where` and `after` together) and matched by no update
|
|
124
|
+
deny. So `where: { authorId: me.id }` on an update grant also stops the author from handing the row to someone else.
|
|
125
|
+
Aventara runs the second check inside the update's transaction and rolls back on failure; on a database without
|
|
126
|
+
interactive transactions it refuses such updates.
|
|
127
|
+
|
|
128
|
+
## References
|
|
129
|
+
|
|
130
|
+
A create or update that sends a reference (`Note.authorId`) to a Resource the app serves is held to the rows the caller
|
|
131
|
+
may read there: no read grant on the target, no write (`A4002`), whatever the write grant allows. When the framework
|
|
132
|
+
starts, Access warns `ACCESS_REFERENCE_UNREADABLE` for each role that may write a reference it cannot read. Fix it with
|
|
133
|
+
a read grant on the target for that role, narrowed as far as the feature allows, or lock the reference with
|
|
134
|
+
`write: { except }`.
|
|
135
|
+
|
|
136
|
+
## Guardrails
|
|
137
|
+
|
|
138
|
+
An org's guardrails (`defineGuardrails({ claims }).denies(…).contract()`) are deny-only rules every service adds on
|
|
139
|
+
top of its own; they never grant. A service gets them with `avaccess pull` and passes them as `policies.guardrails`.
|
|
140
|
+
See the README's Guardrails section.
|
|
141
|
+
|
|
142
|
+
## Asking the engine
|
|
143
|
+
|
|
144
|
+
| Method | Answers |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `can({ principal, origin, resource, action, row?, before?, beforeGrants?, exists? })` | Whether the principal may do it, on `row` when given. On an update, `before` is the row as stored, and `beforeGrants` what `grantsOf` answered for it before the write |
|
|
147
|
+
| `grantsOf({ principal, origin, resource, action: "update", row, exists? })` | The update grants that cover `row` as it is now. Ask it before the write |
|
|
148
|
+
| `scopeOf({ principal, origin, resource, action })` | `true`, `false`, or an Aventara `where` (read, update, delete) |
|
|
149
|
+
| `fieldsOf({ principal, origin, resource, access: "read" })` | The fields usable on every reachable row (`always`) and on some only (`perRow`); `access: "write"` takes an optional `action` |
|
|
150
|
+
| `explain(question)` | In development, which rules took part in a `can` decision. It throws when `NODE_ENV` is `production`. |
|
|
151
|
+
|
|
152
|
+
For an update, always pass `before` (the row as stored). Without it only the new row is checked, and a caller can take
|
|
153
|
+
over another user's row. Prefer routing writes through the framework, which does both checks.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Setup and wiring
|
|
2
|
+
|
|
3
|
+
Three ways in. Pick one:
|
|
4
|
+
|
|
5
|
+
| The app | Use | Section |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| A new or existing Aventara app, Better Auth not set up yet | `createAccess()` from `@aventara/access/auth` | [createAccess](#createaccess) |
|
|
8
|
+
| An app that already calls `betterAuth({...})` itself | `aventaraAccess()` and `accessOf(auth)` from `@aventara/access/better-auth` | [The Better Auth plugin](#the-better-auth-plugin) |
|
|
9
|
+
| Its own authentication, rules only | `definePolicies(…).engine()` on the framework's `policy` option | [The engine alone](#the-engine-alone) |
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install --save-exact @aventara/access@rc better-auth@~1.7.12
|
|
15
|
+
# Node.js 22 only (Node.js 24.7 and later hash with crypto.argon2):
|
|
16
|
+
npm install --save-exact @node-rs/argon2@2.2.2
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Node.js `^22.23.4 || >=24.22.1`, ESM. Another Better Auth version is refused when `createAccess` runs
|
|
20
|
+
(`ACCESS_BETTER_AUTH_VERSION`). Agents over MCP also need `@better-auth/oauth-provider@~1.7.12` (and
|
|
21
|
+
`@better-auth/cimd@~1.7.12` for clients that register by a metadata URL).
|
|
22
|
+
|
|
23
|
+
`.env` needs `DATABASE_URL`, `BETTER_AUTH_SECRET` (a long random string) and `BETTER_AUTH_URL` (the app's origin,
|
|
24
|
+
`http://localhost:3000` in development). Access fixes its tokens' issuer and audience to that URL's origin and refuses
|
|
25
|
+
to start without one (`ACCESS_OPTIONS_INVALID`).
|
|
26
|
+
|
|
27
|
+
## createAccess
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// src/access.ts
|
|
31
|
+
import { createAccess } from "@aventara/access/auth";
|
|
32
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
33
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
34
|
+
import { PrismaClient } from "./generated/prisma/client.js";
|
|
35
|
+
import { teamPolicies } from "./team.policies.js";
|
|
36
|
+
|
|
37
|
+
/** Better Auth's own Prisma client: it reads and writes its tables through it. */
|
|
38
|
+
const prisma = new PrismaClient({
|
|
39
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** What every token carries beside `sub`, by type. */
|
|
43
|
+
const claims = { role: "string", banned: "boolean" } as const;
|
|
44
|
+
|
|
45
|
+
export const access = createAccess({
|
|
46
|
+
// Better Auth's options, exactly as betterAuth({...}) takes them.
|
|
47
|
+
betterAuth: {
|
|
48
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
49
|
+
emailAndPassword: { enabled: true },
|
|
50
|
+
user: {
|
|
51
|
+
additionalFields: {
|
|
52
|
+
// input: false: the user cannot set these at sign-up or with /update-user.
|
|
53
|
+
role: { type: "string", required: false, input: false },
|
|
54
|
+
banned: { type: "boolean", required: false, input: false },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
policies: { rules: teamPolicies, claims },
|
|
59
|
+
// Map the verified token to the rules' principal. Keep the claims as they are.
|
|
60
|
+
principal: ({ sub, claims }) => ({
|
|
61
|
+
id: sub,
|
|
62
|
+
claims: {
|
|
63
|
+
...(claims.role === "admin" ? { role: "admin" as const } : {}),
|
|
64
|
+
...(claims.banned === undefined ? {} : { banned: claims.banned }),
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
// The rules read User, one of Better Auth's tables: open it explicitly.
|
|
68
|
+
resources: { users: true },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export default access;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `betterAuth` is Better Auth's own options, passed through untouched. Access's concerns sit beside it: `policies`,
|
|
75
|
+
`principal`, `resources`, `audit`, `passwords`, `profile`, `registry`, `mcp`, `audience`. Anything else is refused
|
|
76
|
+
(`ACCESS_OPTIONS_INVALID`).
|
|
77
|
+
- `policies.claims` declares every claim the rules (and guardrails) read: `"string"`, `"number"` or `"boolean"`. Each
|
|
78
|
+
is read from the user's field of the same name, which must have `input: false`. A token carries `sub` and the
|
|
79
|
+
declared claims only; every declared boolean claim is in it, `false` when the user's field is unset.
|
|
80
|
+
- `principal` maps `{ sub, claims }` to the rules' principal. Return a new object per user, keep `claims` as given (a
|
|
81
|
+
boolean claim you drop reads as missing, and a `banned: true` deny then refuses the caller), and return `null` to
|
|
82
|
+
leave the caller anonymous. Leave `principal` out when the rules are typed with `AccessIdentity<typeof claims>`
|
|
83
|
+
(`{ sub, claims }`, from `@aventara/access/auth`).
|
|
84
|
+
- `policies.tokenTtl` (seconds, 300 to 900, default 900) is how long a ban or a role change can take to reach a
|
|
85
|
+
token. Keep it short when that matters.
|
|
86
|
+
- Passwords are hashed with argon2id (`"owasp-minimum"` by default). `passwords: { argon2id, legacyScrypt: true }` lets
|
|
87
|
+
users whose hashes Better Auth stored with scrypt sign in and moves them to argon2id. Do not set both `passwords` and
|
|
88
|
+
`betterAuth.emailAndPassword.password`.
|
|
89
|
+
- With `emailAndPassword` on, anyone who reaches the server can sign up. When users are created another way (an admin,
|
|
90
|
+
an invitation, a social provider), set `emailAndPassword: { enabled: true, disableSignUp: true }`.
|
|
91
|
+
- `audit: true` writes every sign-in, token and decision to an `accessAudit` table; `audit: { sink }` hands each
|
|
92
|
+
`AccessAuditEvent` to your function instead.
|
|
93
|
+
|
|
94
|
+
### The scaffold's numeric ids
|
|
95
|
+
|
|
96
|
+
`aventara new` writes `model User { id Int @id @default(autoincrement()) … }`; Better Auth's ids are strings. Before
|
|
97
|
+
generating Better Auth's tables, put `advanced: { database: { generateId: "serial" } }` in the `betterAuth` block, type
|
|
98
|
+
the principal's `id` as `number` and map it:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
principal: ({ sub, claims }) => ({ id: Number(sub), claims: { … } }),
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A rule comparing `User.id` with a string is then a type error, which is what you want.
|
|
105
|
+
|
|
106
|
+
### Better Auth's tables
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npx auth@~1.7.12 generate --config src/access.ts --yes # Better Auth's models into prisma/schema.prisma
|
|
110
|
+
npx prisma validate
|
|
111
|
+
npx prisma db push # or your migration
|
|
112
|
+
npm run aventara:prepare # the rules see the new model
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`generate` reads the module's default export. Access closes every Better Auth table to every request until a
|
|
116
|
+
`resources` key opens it: `users: true` opens `user` (writes: an update of `role` only), `session` and `account` (read
|
|
117
|
+
only); `members: true` opens the organization plugin's `member` and `organization` (and needs
|
|
118
|
+
`requireEmailVerificationOnInvitation: true` on `organization()`). `verification`, `jwks`, `invitation`, `twoFactor`,
|
|
119
|
+
`deviceCode` and Access's own tables never open. Secret columns are sealed on every table: exempt them from your
|
|
120
|
+
Contracts, or govern them, so a read that names no field leaves them out instead of being refused. Off Prisma's
|
|
121
|
+
adapter, `resources.authTables` must map every Better Auth table to its Resource (or `null`).
|
|
122
|
+
|
|
123
|
+
`users: true` lets a write through Aventara set a user's `role`, and `role` is a claim, so Access governs it as
|
|
124
|
+
`"write"`: only an update grant that names `User.role` sets it. To let an admin set roles, add
|
|
125
|
+
`.govern("User", { role: "write" })` to the rules (so a grant may name it) and `fields: ["User.role"]` to that admin's
|
|
126
|
+
update grant, ideally with `where: { NOT: { id: me.id } }` so an admin cannot change their own role. Never name it in a
|
|
127
|
+
grant for `signedIn`, `anyone` or any role a user can reach on their own. Users edit their profile through Better
|
|
128
|
+
Auth's `/update-user`. With `members: true`, the `member` table's `role` is governed the same way: name it only in an
|
|
129
|
+
organization admin role's grant, held to `where: { organizationId: me.org }`.
|
|
130
|
+
|
|
131
|
+
## Mount the routes
|
|
132
|
+
|
|
133
|
+
Access's routes are Better Auth's plus its own, under `/api/auth`. The framework mounts none of them. In NestJS (Express
|
|
134
|
+
or Fastify), mount the Node.js handler before the body parser:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// src/main.ts
|
|
138
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
139
|
+
import { NestFactory } from "@nestjs/core";
|
|
140
|
+
import { toNodeHandler } from "better-auth/node";
|
|
141
|
+
import { access } from "./access.js";
|
|
142
|
+
import { AppModule } from "./app.module.js";
|
|
143
|
+
|
|
144
|
+
async function bootstrap() {
|
|
145
|
+
const app = await NestFactory.create(AppModule);
|
|
146
|
+
// Access's routes, under /api/auth, before Nest's body parser (listen() adds it).
|
|
147
|
+
const auth = toNodeHandler(access);
|
|
148
|
+
app.use(
|
|
149
|
+
(request: IncomingMessage, response: ServerResponse, next: () => void) =>
|
|
150
|
+
request.url?.startsWith("/api/auth/") ? auth(request, response) : next(),
|
|
151
|
+
);
|
|
152
|
+
await app.listen(process.env.PORT ?? 3000);
|
|
153
|
+
}
|
|
154
|
+
await bootstrap();
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Keep the CORS and rate-limit lines the scaffold wrote, before this mount. A request to Better Auth that carries a cookie
|
|
158
|
+
needs an `Origin` header matching `BETTER_AUTH_URL` or one of `trustedOrigins` (else `403 MISSING_OR_NULL_ORIGIN` or
|
|
159
|
+
`INVALID_ORIGIN`).
|
|
160
|
+
|
|
161
|
+
## Wire it into the framework
|
|
162
|
+
|
|
163
|
+
The instance fits the framework's `access` option (`AvAccessProvider`) by shape: `access.policy` is the engine asked
|
|
164
|
+
about every client and agent request, and `access.state({ headers, request })` seeds each request's `ctx.$state` with
|
|
165
|
+
`{ me }`. With `access`, the configuration has no `policy` key and the host has no `state` option.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// src/app.module.ts
|
|
169
|
+
import { Module } from '@nestjs/common';
|
|
170
|
+
import { AppController } from './app.controller.js';
|
|
171
|
+
import { AppService } from './app.service.js';
|
|
172
|
+
import { AventaraModule } from '@aventara/nest';
|
|
173
|
+
import { createPrismaAdapter } from '@aventara/prisma7-adapter';
|
|
174
|
+
import adapter from './aventara.adapter.js';
|
|
175
|
+
import { aventaraConfig } from './aventara.config.js';
|
|
176
|
+
import { PrismaModule, PrismaService } from './prisma.service.js';
|
|
177
|
+
import { access } from './access.js';
|
|
178
|
+
|
|
179
|
+
@Module({
|
|
180
|
+
imports: [
|
|
181
|
+
PrismaModule,
|
|
182
|
+
AventaraModule.forRootAsync({
|
|
183
|
+
imports: [PrismaModule],
|
|
184
|
+
inject: [PrismaService],
|
|
185
|
+
useFactory: async (prisma: PrismaService) => ({
|
|
186
|
+
config: {
|
|
187
|
+
...aventaraConfig,
|
|
188
|
+
adapter: await createPrismaAdapter({
|
|
189
|
+
...adapter,
|
|
190
|
+
client: prisma,
|
|
191
|
+
}),
|
|
192
|
+
access,
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
}),
|
|
196
|
+
],
|
|
197
|
+
controllers: [AppController],
|
|
198
|
+
providers: [AppService],
|
|
199
|
+
})
|
|
200
|
+
export class AppModule {}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Anywhere else: `createFramework({ ...config, access })`. Agents over MCP: add
|
|
204
|
+
`mcp: { path: "/mcp", authorization: access.mcp }` beside `access` ([callers](callers.md#agents-over-mcp)).
|
|
205
|
+
|
|
206
|
+
### Next.js
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
// src/app/api/auth/[...all]/route.ts
|
|
210
|
+
import { toNextJsHandler } from "better-auth/next-js";
|
|
211
|
+
import { access } from "@/access";
|
|
212
|
+
|
|
213
|
+
export const { GET, POST } = toNextJsHandler(access);
|
|
214
|
+
|
|
215
|
+
// src/aventara/config.ts: put access here, not in server.ts
|
|
216
|
+
import { access } from "../access";
|
|
217
|
+
|
|
218
|
+
export const aventaraConfig = {
|
|
219
|
+
api: "/api",
|
|
220
|
+
// the rest of what `aventara new` wrote stays as it is
|
|
221
|
+
access,
|
|
222
|
+
} as const;
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The rules import the model from `@/aventara/generated/discovery.artifact`. With `access` only in `server.ts`, the
|
|
226
|
+
framework warns `AVENTARA_PREBUILT_STALE` and governed fields miss the generated client.
|
|
227
|
+
|
|
228
|
+
## The request's principal
|
|
229
|
+
|
|
230
|
+
`access.state` reads each request once:
|
|
231
|
+
|
|
232
|
+
- An `Authorization: Bearer <token>` header is read first. A token Access accepts is signed by the instance's key set
|
|
233
|
+
(`GET /api/auth/jwks`), has the instance's origin as `iss` and `aud`, is unexpired and no older than `tokenTtl`, and
|
|
234
|
+
carries no undeclared claim. Anything else in that header (another scheme, a tampered or expired token) gives
|
|
235
|
+
`me: null`, and the session cookie is not tried.
|
|
236
|
+
- Without the header, the session cookie (profiles `web` and `kiosk`) is read from Better Auth's store, so a sign-out
|
|
237
|
+
or a revoked session is `null` at once. On a request that may change something, the cookie counts only when the
|
|
238
|
+
request comes from a site Better Auth trusts (`Origin`, or `Sec-Fetch-Site`).
|
|
239
|
+
- `me` is a copy of what `principal` returns, or `null`. The framework puts it at `ctx.$state.me`. Its plain data
|
|
240
|
+
(plain objects and arrays) is frozen and the state holds it read-only: no stage can replace it or change it, so the
|
|
241
|
+
policy decides every part of the request for the same principal. A class instance, `Map`, `Set`, `Date`, typed array
|
|
242
|
+
or getter is kept as returned and not frozen, so return plain data for the fields the rules read. Your own objects
|
|
243
|
+
are never frozen. Under an OAuth access token, return a plain object: a `Map` or `Set` principal is not supported
|
|
244
|
+
there, and a class instance loses its `#private` fields. Return a new object for each user (one cached per user is fine): one `principal` already returned
|
|
245
|
+
for another user is refused (`ACCESS_OPTIONS_INVALID`). Only the returned object is checked, not what it holds.
|
|
246
|
+
|
|
247
|
+
A client gets a token by trading its session: `GET /api/auth/token`. A token is not revoked by a sign-out; it expires.
|
|
248
|
+
|
|
249
|
+
To read `me` typed in your own stages, declare it on the framework's state:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// src/aventara.state.ts
|
|
253
|
+
import type { AvState } from "@aventara/core";
|
|
254
|
+
import type { TeamPrincipal } from "./team.policies.js";
|
|
255
|
+
|
|
256
|
+
declare module "@aventara/core" {
|
|
257
|
+
interface AvState {
|
|
258
|
+
/** Seeded by Access on every request: a frozen copy of the principal, or null when anonymous. */
|
|
259
|
+
readonly me?: TeamPrincipal | null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The caller's user id, or null for an anonymous caller. */
|
|
264
|
+
export const callerIdOf = (state: AvState) => state.me?.id ?? null;
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Never parse the `Authorization` header or the cookie yourself in a stage: `ctx.$state.me` is the verified answer.
|
|
268
|
+
|
|
269
|
+
## Server code runs unchecked unless it names a principal
|
|
270
|
+
|
|
271
|
+
The policy checks every client and agent request. A server-side `framework.application.*` call is server code, and the
|
|
272
|
+
policy does not check it unless the call names a principal with the `as` option. When server code acts for a caller,
|
|
273
|
+
name them:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
// checked exactly as the caller's own request would be; null is the anonymous caller
|
|
277
|
+
await framework.application.Note.find.many({}, { as: ctx.$state.me ?? null });
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
A `framework.client.*` call is checked either way, and `as` replaces the principal its state would give. Leave `as`
|
|
281
|
+
out only for work no caller asked for (a job, a migration, a seed).
|
|
282
|
+
|
|
283
|
+
## The Better Auth plugin
|
|
284
|
+
|
|
285
|
+
For an app that builds `betterAuth({...})` itself: add `aventaraAccess({ policies, principal, resources, audit,
|
|
286
|
+
passwords, registry, mcp, audience, organization })` to its `plugins`, and hand the framework `accessOf(auth)` as
|
|
287
|
+
`access`. `accessOf(auth).mcp` is the framework's `mcp.authorization`.
|
|
288
|
+
|
|
289
|
+
```text
|
|
290
|
+
// src/auth.ts
|
|
291
|
+
export const auth = betterAuth({
|
|
292
|
+
database: …,
|
|
293
|
+
emailAndPassword: { enabled: true },
|
|
294
|
+
user: { additionalFields: { banned: { type: "boolean", required: false, input: false } } },
|
|
295
|
+
plugins: [aventaraAccess({ policies: { rules, claims } })],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
export const access = accessOf(auth);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- The plugin carries Better Auth's `jwt()` inside it: do not add `jwt()` beside it, and call `aventaraAccess()` once
|
|
302
|
+
per Better Auth instance. With `mcp` or `audience`, list `oauthProvider()` after it.
|
|
303
|
+
- You mount `auth.handler` as you already do. `createAccess`'s profiles, `access.handler` and the `$access` verbs are
|
|
304
|
+
not part of the plugin.
|
|
305
|
+
- `organization: { claim, organizationClaim, roles }` puts a member's role in the session's active organization into a
|
|
306
|
+
claim, and the organization into another. An organization role is that organization's: hold every grant on it to
|
|
307
|
+
the organization, `where: { organizationId: me.org }`, with the organization claim copied onto the principal. It
|
|
308
|
+
needs `organization({ requireEmailVerificationOnInvitation: true, allowUserToCreateOrganization: false })` (or a
|
|
309
|
+
function that decides who may create one).
|
|
310
|
+
- `aventaraAccessClient({ api: "/api" })` in Better Auth's client adds `authClient.access.can([...])`: one
|
|
311
|
+
`POST {api}/_access/can` for the page, answered by the engine that decides the real requests.
|
|
312
|
+
|
|
313
|
+
## The engine alone
|
|
314
|
+
|
|
315
|
+
Without Better Auth, hand the engine to the framework's `policy` option, with a function that reads the principal from
|
|
316
|
+
the request's state, which your host's `state` option fills:
|
|
317
|
+
|
|
318
|
+
```text
|
|
319
|
+
// src/aventara.config.ts
|
|
320
|
+
export const aventaraConfig = {
|
|
321
|
+
api: "/api",
|
|
322
|
+
policy: {
|
|
323
|
+
engine: policies.engine(),
|
|
324
|
+
principal: ({ $state }: { readonly $state: AvState }) => $state.user ?? null,
|
|
325
|
+
},
|
|
326
|
+
} as const;
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`createAccessEngine(file)` builds the same engine from a compiled policies file (`policies.contract()`). Any object
|
|
330
|
+
with `policy` (an engine) and `state` (`({ headers }) => ({ me })`) also fits the `access` option.
|
|
331
|
+
|
|
332
|
+
Your `state` function is the whole of authentication here. Set `user` only from a credential your server verified (a
|
|
333
|
+
signature-checked token, a session looked up server-side), never from a header, a cookie value, a query or the body
|
|
334
|
+
as sent. Unverified is `null`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Folded condition → Aventara's `where` grammar, with explicit operators
|
|
2
|
+
// (`{ equals: … }`), as core validates it against the Core Contract.
|
|
3
|
+
|
|
4
|
+
import type { AccessComparison, AccessLiteral } from "./policies-contract.js";
|
|
5
|
+
import type { Resolved } from "./scope.resolver.js";
|
|
6
|
+
|
|
7
|
+
/** A fragment of Aventara's `where` (core's `AvPolicyWhere`, by shape). */
|
|
8
|
+
export type AccessWhere = { readonly [key: string]: unknown };
|
|
9
|
+
|
|
10
|
+
const OPERATOR: Record<AccessComparison, string> = {
|
|
11
|
+
eq: "equals",
|
|
12
|
+
ne: "not",
|
|
13
|
+
in: "in",
|
|
14
|
+
notIn: "notIn",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The Aventara filter operator a comparison is written with. */
|
|
18
|
+
export const operatorOf = (cmp: AccessComparison): string => OPERATOR[cmp];
|
|
19
|
+
|
|
20
|
+
const isField = (
|
|
21
|
+
node: Resolved,
|
|
22
|
+
): node is Extract<Resolved, { field: string } | { relation: string }> =>
|
|
23
|
+
node.k === "cmp" ||
|
|
24
|
+
node.k === "rel" ||
|
|
25
|
+
node.k === "some" ||
|
|
26
|
+
node.k === "every" ||
|
|
27
|
+
node.k === "none";
|
|
28
|
+
|
|
29
|
+
const keyOf = (
|
|
30
|
+
node: Extract<Resolved, { field: string } | { relation: string }>,
|
|
31
|
+
): string => (node.k === "cmp" ? node.field : node.relation);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sets `key` on `target` as its own data member. An assignment would read
|
|
35
|
+
* `__proto__` as the prototype setter and drop the condition.
|
|
36
|
+
*/
|
|
37
|
+
export const defineMember = (
|
|
38
|
+
target: Record<string, unknown>,
|
|
39
|
+
key: string,
|
|
40
|
+
value: unknown,
|
|
41
|
+
): void => {
|
|
42
|
+
Object.defineProperty(target, key, {
|
|
43
|
+
value,
|
|
44
|
+
enumerable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
configurable: true,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** An object with one own member. */
|
|
51
|
+
const one = (key: string, value: unknown): AccessWhere => {
|
|
52
|
+
const where: Record<string, unknown> = {};
|
|
53
|
+
defineMember(where, key, value);
|
|
54
|
+
return where;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Merges fragments into one object when no key repeats; otherwise `undefined`. */
|
|
58
|
+
export const mergeWhere = (
|
|
59
|
+
fragments: readonly AccessWhere[],
|
|
60
|
+
): AccessWhere | undefined => {
|
|
61
|
+
const merged: Record<string, unknown> = {};
|
|
62
|
+
for (const fragment of fragments) {
|
|
63
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
64
|
+
if (Object.hasOwn(merged, key)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
defineMember(merged, key, value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return merged;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Translates a row condition that is neither `true` nor `false` at its root. */
|
|
74
|
+
export const translateWhere = (node: Resolved): AccessWhere => {
|
|
75
|
+
switch (node.k) {
|
|
76
|
+
case "true":
|
|
77
|
+
return {};
|
|
78
|
+
case "false":
|
|
79
|
+
// Aventara's `where` has no `false`: the empty `OR` is its one way to match no
|
|
80
|
+
// row, and an ORM reads it so.
|
|
81
|
+
return { OR: [] };
|
|
82
|
+
case "cmp":
|
|
83
|
+
return one(node.field, {
|
|
84
|
+
[operatorOf(node.cmp)]: Array.isArray(node.value)
|
|
85
|
+
? [...(node.value as readonly AccessLiteral[])]
|
|
86
|
+
: node.value,
|
|
87
|
+
});
|
|
88
|
+
case "rel":
|
|
89
|
+
return one(node.relation, translateWhere(node.where));
|
|
90
|
+
case "some":
|
|
91
|
+
case "every":
|
|
92
|
+
case "none":
|
|
93
|
+
return one(node.relation, { [node.k]: translateWhere(node.where) });
|
|
94
|
+
case "not":
|
|
95
|
+
return { NOT: translateWhere(node.arg) };
|
|
96
|
+
case "or":
|
|
97
|
+
return { OR: node.args.map(translateWhere) };
|
|
98
|
+
case "all":
|
|
99
|
+
return { AND: node.args.map(translateWhere) };
|
|
100
|
+
case "and": {
|
|
101
|
+
// Written by the author as one object: written back as one when every part is
|
|
102
|
+
// a field condition and no field repeats.
|
|
103
|
+
if (
|
|
104
|
+
node.args.every(isField) &&
|
|
105
|
+
new Set(node.args.map(keyOf)).size === node.args.length
|
|
106
|
+
) {
|
|
107
|
+
return mergeWhere(node.args.map(translateWhere)) as AccessWhere;
|
|
108
|
+
}
|
|
109
|
+
return { AND: node.args.map(translateWhere) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** A seam answer: `true` every row, `false` none, or a `where`. */
|
|
115
|
+
export const translateAnswer = (node: Resolved): boolean | AccessWhere =>
|
|
116
|
+
node.k === "true" ? true : node.k === "false" ? false : translateWhere(node);
|