@aventara/access 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/CHANGELOG.md +831 -0
  2. package/LICENSE +91 -0
  3. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  4. package/README.md +2439 -2
  5. package/dist/access/access-options.d.ts +66 -0
  6. package/dist/access/access-options.js +5 -0
  7. package/dist/access/access.error.d.ts +35 -0
  8. package/dist/access/access.error.js +11 -0
  9. package/dist/access/access.factory.d.ts +273 -0
  10. package/dist/access/access.factory.js +440 -0
  11. package/dist/access/better-auth.hardening.d.ts +120 -0
  12. package/dist/access/better-auth.hardening.js +420 -0
  13. package/dist/access/better-auth.loader.d.ts +67 -0
  14. package/dist/access/better-auth.loader.js +65 -0
  15. package/dist/access/better-auth.range.d.ts +9 -0
  16. package/dist/access/better-auth.range.js +28 -0
  17. package/dist/access/better-auth.require.d.ts +90 -0
  18. package/dist/access/better-auth.require.js +6 -0
  19. package/dist/access/better-auth.require.node.d.ts +7 -0
  20. package/dist/access/better-auth.require.node.js +52 -0
  21. package/dist/access/claims.mapper.d.ts +79 -0
  22. package/dist/access/claims.mapper.js +152 -0
  23. package/dist/access/key-set.cache.d.ts +10 -0
  24. package/dist/access/key-set.cache.js +63 -0
  25. package/dist/access/policies-block.checker.d.ts +64 -0
  26. package/dist/access/policies-block.checker.js +138 -0
  27. package/dist/access/principal.subject.d.ts +12 -0
  28. package/dist/access/principal.subject.js +60 -0
  29. package/dist/access/profile.policy.d.ts +74 -0
  30. package/dist/access/profile.policy.js +110 -0
  31. package/dist/access/request.state.d.ts +89 -0
  32. package/dist/access/request.state.js +196 -0
  33. package/dist/access/session.hooks.d.ts +114 -0
  34. package/dist/access/session.hooks.js +258 -0
  35. package/dist/access/token.verifier.d.ts +35 -0
  36. package/dist/access/token.verifier.js +69 -0
  37. package/dist/access-schema.d.ts +48 -0
  38. package/dist/access-schema.js +5 -0
  39. package/dist/access-warning.emitter.d.ts +2 -0
  40. package/dist/access-warning.emitter.js +20 -0
  41. package/dist/access.builder.d.ts +299 -0
  42. package/dist/access.builder.js +66 -0
  43. package/dist/access.engine.d.ts +142 -0
  44. package/dist/access.engine.js +470 -0
  45. package/dist/audit/audit-event.d.ts +135 -0
  46. package/dist/audit/audit-event.js +8 -0
  47. package/dist/audit/audit.log.d.ts +122 -0
  48. package/dist/audit/audit.log.js +345 -0
  49. package/dist/audit/audit.schema.d.ts +143 -0
  50. package/dist/audit/audit.schema.js +50 -0
  51. package/dist/auth/index.d.ts +12 -0
  52. package/dist/auth/index.js +2 -0
  53. package/dist/aventara-where.translator.d.ts +19 -0
  54. package/dist/aventara-where.translator.js +87 -0
  55. package/dist/beacon/beacon.check.d.ts +59 -0
  56. package/dist/beacon/beacon.check.js +128 -0
  57. package/dist/beacon/beacon.schema.d.ts +41 -0
  58. package/dist/beacon/beacon.schema.js +48 -0
  59. package/dist/beacon/beacon.store.d.ts +29 -0
  60. package/dist/beacon/beacon.store.js +78 -0
  61. package/dist/better-auth/access.client-plugin.d.ts +75 -0
  62. package/dist/better-auth/access.client-plugin.js +133 -0
  63. package/dist/better-auth/access.plugin.d.ts +164 -0
  64. package/dist/better-auth/access.plugin.js +348 -0
  65. package/dist/better-auth/access.provider.d.ts +78 -0
  66. package/dist/better-auth/access.provider.js +100 -0
  67. package/dist/better-auth/index.d.ts +7 -0
  68. package/dist/better-auth/index.js +4 -0
  69. package/dist/better-auth/organization.bridge.d.ts +66 -0
  70. package/dist/better-auth/organization.bridge.js +144 -0
  71. package/dist/canonical-json.writer.d.ts +12 -0
  72. package/dist/canonical-json.writer.js +30 -0
  73. package/dist/cli/avaccess.bin.d.ts +2 -0
  74. package/dist/cli/avaccess.bin.js +26 -0
  75. package/dist/cli/avaccess.program.d.ts +15 -0
  76. package/dist/cli/avaccess.program.js +166 -0
  77. package/dist/cli/beacon-pins.file.d.ts +14 -0
  78. package/dist/cli/beacon-pins.file.js +62 -0
  79. package/dist/cli/beacon.loader.d.ts +20 -0
  80. package/dist/cli/beacon.loader.js +71 -0
  81. package/dist/cli/cli.context.d.ts +49 -0
  82. package/dist/cli/cli.context.js +66 -0
  83. package/dist/cli/diff.command.d.ts +2 -0
  84. package/dist/cli/diff.command.js +66 -0
  85. package/dist/cli/generate.command.d.ts +2 -0
  86. package/dist/cli/generate.command.js +82 -0
  87. package/dist/cli/keys.command.d.ts +2 -0
  88. package/dist/cli/keys.command.js +96 -0
  89. package/dist/cli/lock.file.d.ts +31 -0
  90. package/dist/cli/lock.file.js +181 -0
  91. package/dist/cli/module.loader.d.ts +13 -0
  92. package/dist/cli/module.loader.js +63 -0
  93. package/dist/cli/policies.diff.d.ts +16 -0
  94. package/dist/cli/policies.diff.js +83 -0
  95. package/dist/cli/pull.command.d.ts +2 -0
  96. package/dist/cli/pull.command.js +234 -0
  97. package/dist/cli/push.command.d.ts +2 -0
  98. package/dist/cli/push.command.js +59 -0
  99. package/dist/cli/registry.client.d.ts +63 -0
  100. package/dist/cli/registry.client.js +287 -0
  101. package/dist/cli/schema.command.d.ts +2 -0
  102. package/dist/cli/schema.command.js +60 -0
  103. package/dist/cli/serve.command.d.ts +6 -0
  104. package/dist/cli/serve.command.js +200 -0
  105. package/dist/cli/status.command.d.ts +2 -0
  106. package/dist/cli/status.command.js +31 -0
  107. package/dist/cli/translator.flag.d.ts +6 -0
  108. package/dist/cli/translator.flag.js +24 -0
  109. package/dist/cli/verify.command.d.ts +2 -0
  110. package/dist/cli/verify.command.js +40 -0
  111. package/dist/client/access-client.factory.d.ts +92 -0
  112. package/dist/client/access-client.factory.js +269 -0
  113. package/dist/client/access-verbs.schema.d.ts +66 -0
  114. package/dist/client/access-verbs.schema.js +159 -0
  115. package/dist/client/verb-rate.limiter.d.ts +116 -0
  116. package/dist/client/verb-rate.limiter.js +358 -0
  117. package/dist/foreign/foreign-facts.d.ts +30 -0
  118. package/dist/foreign/foreign-facts.js +75 -0
  119. package/dist/foreign/foreign.merger.d.ts +10 -0
  120. package/dist/foreign/foreign.merger.js +52 -0
  121. package/dist/foreign-references.check.d.ts +12 -0
  122. package/dist/foreign-references.check.js +134 -0
  123. package/dist/generator/client.emitter.d.ts +28 -0
  124. package/dist/generator/client.emitter.js +411 -0
  125. package/dist/generator/copied-modules.reader.d.ts +14 -0
  126. package/dist/generator/copied-modules.reader.js +80 -0
  127. package/dist/generator/namespace.facts.d.ts +28 -0
  128. package/dist/generator/namespace.facts.js +32 -0
  129. package/dist/generator/orm.translator.d.ts +48 -0
  130. package/dist/generator/orm.translator.js +29 -0
  131. package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
  132. package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
  133. package/dist/generator/prisma7/prisma-where.translator.d.ts +72 -0
  134. package/dist/generator/prisma7/prisma-where.translator.js +136 -0
  135. package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
  136. package/dist/generator/prisma7/prisma7.translator.js +87 -0
  137. package/dist/generator/schema.emitter.d.ts +14 -0
  138. package/dist/generator/schema.emitter.js +23 -0
  139. package/dist/generator/tsconfig.reader.d.ts +44 -0
  140. package/dist/generator/tsconfig.reader.js +110 -0
  141. package/dist/generator/typescript-literal.writer.d.ts +5 -0
  142. package/dist/generator/typescript-literal.writer.js +42 -0
  143. package/dist/guardrails/guardrails-contract.d.ts +45 -0
  144. package/dist/guardrails/guardrails-contract.js +12 -0
  145. package/dist/guardrails/guardrails.builder.d.ts +48 -0
  146. package/dist/guardrails/guardrails.builder.js +32 -0
  147. package/dist/guardrails/guardrails.compiler.d.ts +10 -0
  148. package/dist/guardrails/guardrails.compiler.js +254 -0
  149. package/dist/guardrails/guardrails.composer.d.ts +7 -0
  150. package/dist/guardrails/guardrails.composer.js +95 -0
  151. package/dist/guardrails/guardrails.hash.d.ts +10 -0
  152. package/dist/guardrails/guardrails.hash.js +38 -0
  153. package/dist/index.d.ts +16 -0
  154. package/dist/index.js +6 -0
  155. package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
  156. package/dist/kiosk/kiosk-verbs.schema.js +61 -0
  157. package/dist/kiosk/kiosk.plugin.d.ts +217 -0
  158. package/dist/kiosk/kiosk.plugin.js +877 -0
  159. package/dist/kiosk/kiosk.schema.d.ts +176 -0
  160. package/dist/kiosk/kiosk.schema.js +101 -0
  161. package/dist/kiosk/kiosk.store.d.ts +171 -0
  162. package/dist/kiosk/kiosk.store.js +478 -0
  163. package/dist/kiosk/lan-range.policy.d.ts +31 -0
  164. package/dist/kiosk/lan-range.policy.js +113 -0
  165. package/dist/kiosk/pin.policy.d.ts +9 -0
  166. package/dist/kiosk/pin.policy.js +143 -0
  167. package/dist/kiosk/session-use.marker.d.ts +4 -0
  168. package/dist/kiosk/session-use.marker.js +16 -0
  169. package/dist/lock/lock-contract.d.ts +38 -0
  170. package/dist/lock/lock-contract.js +67 -0
  171. package/dist/lock/lock.verifier.d.ts +27 -0
  172. package/dist/lock/lock.verifier.js +62 -0
  173. package/dist/mcp/agent-scope.gate.d.ts +11 -0
  174. package/dist/mcp/agent-scope.gate.js +75 -0
  175. package/dist/mcp/agent.grant.d.ts +36 -0
  176. package/dist/mcp/agent.grant.js +92 -0
  177. package/dist/mcp/mcp-options.d.ts +60 -0
  178. package/dist/mcp/mcp-options.js +72 -0
  179. package/dist/oauth/access-token.verifier.d.ts +66 -0
  180. package/dist/oauth/access-token.verifier.js +201 -0
  181. package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
  182. package/dist/oauth/oauth-provider.hardening.js +261 -0
  183. package/dist/passwords/argon2id.hasher.d.ts +97 -0
  184. package/dist/passwords/argon2id.hasher.js +256 -0
  185. package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
  186. package/dist/passwords/node-rs-argon2.adapter.js +42 -0
  187. package/dist/passwords/passwords.options.d.ts +79 -0
  188. package/dist/passwords/passwords.options.js +212 -0
  189. package/dist/policies-contract.d.ts +192 -0
  190. package/dist/policies-contract.js +8 -0
  191. package/dist/policies.compiler.d.ts +36 -0
  192. package/dist/policies.compiler.js +1358 -0
  193. package/dist/policies.decider.d.ts +103 -0
  194. package/dist/policies.decider.js +382 -0
  195. package/dist/policies.hash.d.ts +10 -0
  196. package/dist/policies.hash.js +53 -0
  197. package/dist/predicate.evaluator.d.ts +62 -0
  198. package/dist/predicate.evaluator.js +378 -0
  199. package/dist/random-secret.generator.d.ts +2 -0
  200. package/dist/random-secret.generator.js +15 -0
  201. package/dist/reference-reads.check.d.ts +12 -0
  202. package/dist/reference-reads.check.js +148 -0
  203. package/dist/registry/access.registry.d.ts +173 -0
  204. package/dist/registry/access.registry.js +865 -0
  205. package/dist/registry/beacon.ledger.d.ts +30 -0
  206. package/dist/registry/beacon.ledger.js +93 -0
  207. package/dist/registry/ownership.check.d.ts +54 -0
  208. package/dist/registry/ownership.check.js +215 -0
  209. package/dist/registry/registry.endpoints.d.ts +50 -0
  210. package/dist/registry/registry.endpoints.js +193 -0
  211. package/dist/registry/registry.key.d.ts +7 -0
  212. package/dist/registry/registry.key.js +13 -0
  213. package/dist/registry/registry.options.d.ts +13 -0
  214. package/dist/registry/registry.options.js +28 -0
  215. package/dist/registry/registry.refusal.d.ts +6 -0
  216. package/dist/registry/registry.refusal.js +11 -0
  217. package/dist/registry/registry.schema.d.ts +238 -0
  218. package/dist/registry/registry.schema.js +127 -0
  219. package/dist/resources/auth-tables.catalog.d.ts +91 -0
  220. package/dist/resources/auth-tables.catalog.js +102 -0
  221. package/dist/resources/auth-tables.guard.d.ts +95 -0
  222. package/dist/resources/auth-tables.guard.js +405 -0
  223. package/dist/scope.resolver.d.ts +138 -0
  224. package/dist/scope.resolver.js +512 -0
  225. package/dist/sha256.digest.d.ts +2 -0
  226. package/dist/sha256.digest.js +81 -0
  227. package/dist/standalone/access.server.d.ts +41 -0
  228. package/dist/standalone/access.server.js +301 -0
  229. package/dist/standalone/index.d.ts +1 -0
  230. package/dist/standalone/index.js +1 -0
  231. package/package.json +101 -3
  232. package/src/aventara-where.translator.ts +116 -0
  233. package/src/beacon/beacon.check.ts +204 -0
  234. package/src/canonical-json.writer.ts +51 -0
  235. package/src/generator/prisma7/prisma-where.translator.ts +241 -0
  236. package/src/policies-contract.ts +184 -0
  237. package/src/policies.decider.ts +650 -0
  238. package/src/predicate.evaluator.ts +574 -0
  239. package/src/scope.resolver.ts +791 -0
  240. package/src/sha256.digest.ts +85 -0
@@ -0,0 +1,470 @@
1
+ // The engine: a plain object that answers core's `AvPolicyEngine` questions by shape
2
+ // (`can`, `grantsOf`, `scopeOf`, `fieldsOf`, the startup facts `governed`,
3
+ // `paths`, `afterChecks`, `unheld` (the Resources the rules read as foreign), and
4
+ // `hosted`, where the framework says which Resources it holds a written reference
5
+ // to and the rules are checked against them, reference-reads.check.ts and
6
+ // foreign-references.check.ts), run from a `@1` policies file alone. Default deny, any grant
7
+ // allows, any deny wins, order never matters. Neither package imports the other.
8
+ // The decisions themselves are `policies.decider.ts`'s; the engine adds the lock,
9
+ // the guardrails, the decision observer, `explain`, the startup facts and, when it
10
+ // runs from a lock and its rules read other namespaces' Resources as foreign, the
11
+ // startup check that the service reaches their database (`checkBeacons`): until it
12
+ // has passed, the engine decides nothing.
13
+ import { AccessError } from "./access/access.error.js";
14
+ import { operatorOf } from "./aventara-where.translator.js";
15
+ import { beaconProblem, } from "./beacon/beacon.check.js";
16
+ import { warnForeignReferences } from "./foreign-references.check.js";
17
+ import { composeGuardrails } from "./guardrails/guardrails.composer.js";
18
+ import { foreignOwnersOf, verifyLocked } from "./lock/lock.verifier.js";
19
+ import { checkLock } from "./lock/lock-contract.js";
20
+ import { checkPolicies, fail, isRecord } from "./policies.compiler.js";
21
+ import { afterChecksOf, createDecider, fieldsQuestionProblem, grantsQuestionProblem, newTrace, } from "./policies.decider.js";
22
+ import { warnReferenceReads } from "./reference-reads.check.js";
23
+ import { guardedFields, } from "./resources/auth-tables.guard.js";
24
+ import { nullSafe, ownOf } from "./scope.resolver.js";
25
+ const ENGINE_OPTIONS = new Set(["guardrails", "lock", "requireLock"]);
26
+ /**
27
+ * The engine's own copy of a file handed to it: what is checked and hashed is what
28
+ * runs, whatever the caller does to its object afterwards.
29
+ */
30
+ const copyOf = (what, file) => {
31
+ try {
32
+ return structuredClone(file);
33
+ }
34
+ catch {
35
+ return fail(what, "is JSON data: it holds a value that cannot be copied");
36
+ }
37
+ };
38
+ /** Freezes a copied file all the way down, once it is checked. */
39
+ const deepFreeze = (value) => {
40
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
41
+ Object.freeze(value);
42
+ for (const member of Object.values(value)) {
43
+ deepFreeze(member);
44
+ }
45
+ }
46
+ return value;
47
+ };
48
+ // ---------------------------------------------------------------------------
49
+ // Startup facts
50
+ // ---------------------------------------------------------------------------
51
+ const pathCollector = (policies) => {
52
+ const collect = (predicate, prefix, into, roles = new Set()) => {
53
+ switch (predicate.op) {
54
+ case "and":
55
+ case "or":
56
+ for (const arg of predicate.args) {
57
+ collect(arg, prefix, into, roles);
58
+ }
59
+ return;
60
+ case "not":
61
+ collect(predicate.arg, prefix, into, roles);
62
+ return;
63
+ case "cmp":
64
+ into.push([...prefix, predicate.field, operatorOf(predicate.cmp)]);
65
+ return;
66
+ case "rel":
67
+ collect(predicate.where, [...prefix, predicate.relation], into, roles);
68
+ return;
69
+ case "some":
70
+ case "every":
71
+ case "none":
72
+ collect(predicate.where, [...prefix, predicate.relation, predicate.op], into, roles);
73
+ return;
74
+ case "role": {
75
+ const role = ownOf(policies.roles, predicate.role);
76
+ if (role !== undefined && "on" in role && !roles.has(predicate.role)) {
77
+ collect(role.where, prefix, into, new Set([...roles, predicate.role]));
78
+ }
79
+ return;
80
+ }
81
+ default:
82
+ return;
83
+ }
84
+ };
85
+ return collect;
86
+ };
87
+ /**
88
+ * A rule's condition as the null-safe rewrite reads it: roles followed, a `me` value
89
+ * stood in for by a string (never `null`: a missing one folds the comparison away).
90
+ * Nothing is folded, so every comparison stays to be counted.
91
+ */
92
+ const resolvedOf = (policies, predicate, roles = new Set()) => {
93
+ switch (predicate.op) {
94
+ case "true":
95
+ case "false":
96
+ return { k: predicate.op };
97
+ case "and":
98
+ case "or":
99
+ return {
100
+ k: predicate.op,
101
+ args: predicate.args.map((arg) => resolvedOf(policies, arg, roles)),
102
+ };
103
+ case "not":
104
+ return { k: "not", arg: resolvedOf(policies, predicate.arg, roles) };
105
+ case "cmp": {
106
+ const literal = (operand) => operand !== null && typeof operand === "object"
107
+ ? ""
108
+ : operand;
109
+ return {
110
+ k: "cmp",
111
+ field: predicate.field,
112
+ cmp: predicate.cmp,
113
+ value: Array.isArray(predicate.value)
114
+ ? predicate.value.map(literal)
115
+ : literal(predicate.value),
116
+ };
117
+ }
118
+ case "rel":
119
+ case "some":
120
+ case "every":
121
+ case "none":
122
+ return {
123
+ k: predicate.op,
124
+ relation: predicate.relation,
125
+ target: predicate.target,
126
+ where: resolvedOf(policies, predicate.where, roles),
127
+ };
128
+ case "role": {
129
+ const role = ownOf(policies.roles, predicate.role);
130
+ return role !== undefined && "on" in role && !roles.has(predicate.role)
131
+ ? resolvedOf(policies, role.where, new Set([...roles, predicate.role]))
132
+ : { k: "false" };
133
+ }
134
+ }
135
+ };
136
+ /** The filter paths a condition the null-safe rewrite wrote uses. */
137
+ const resolvedPaths = (node, prefix, into) => {
138
+ switch (node.k) {
139
+ case "true":
140
+ case "false":
141
+ return;
142
+ case "cmp":
143
+ into.push([...prefix, node.field, operatorOf(node.cmp)]);
144
+ return;
145
+ case "and":
146
+ case "all":
147
+ case "or":
148
+ for (const arg of node.args) {
149
+ resolvedPaths(arg, prefix, into);
150
+ }
151
+ return;
152
+ case "not":
153
+ resolvedPaths(node.arg, prefix, into);
154
+ return;
155
+ case "rel":
156
+ resolvedPaths(node.where, [...prefix, node.relation], into);
157
+ return;
158
+ case "some":
159
+ case "every":
160
+ case "none":
161
+ resolvedPaths(node.where, [...prefix, node.relation, node.k], into);
162
+ }
163
+ };
164
+ /**
165
+ * The paths the null-safe rewrite adds to a condition on `resource`, read as it is
166
+ * (a grant, an `exists` question) and, for a deny, negated too (its scope): the
167
+ * `IS NULL` guards (`equals`), a reference's `IS NULL`, `not` for a `notIn` left
168
+ * empty, and the `none` and `some` an `every` becomes.
169
+ */
170
+ const nullSafePaths = (policies, resource, predicate, negated, into) => {
171
+ const node = resolvedOf(policies, predicate);
172
+ resolvedPaths(nullSafe(policies, resource, node), [resource], into);
173
+ if (negated) {
174
+ resolvedPaths(nullSafe(policies, resource, { k: "not", arg: node }), [resource], into);
175
+ }
176
+ };
177
+ /** The to-one steps at a created row's own level, roles followed: each is one `exists` on its target. */
178
+ const createSteps = (policies, predicate, roles = new Set()) => {
179
+ switch (predicate.op) {
180
+ case "and":
181
+ case "or":
182
+ return predicate.args.flatMap((arg) => createSteps(policies, arg, roles));
183
+ case "not":
184
+ return createSteps(policies, predicate.arg, roles);
185
+ case "rel":
186
+ return [predicate];
187
+ case "role": {
188
+ const role = ownOf(policies.roles, predicate.role);
189
+ return role !== undefined && "on" in role && !roles.has(predicate.role)
190
+ ? createSteps(policies, role.where, new Set([...roles, predicate.role]))
191
+ : [];
192
+ }
193
+ default:
194
+ return [];
195
+ }
196
+ };
197
+ const subjectPredicate = (policies, grant, subject) => {
198
+ const role = ownOf(policies.roles, subject);
199
+ if (role === undefined || !("on" in role)) {
200
+ return undefined;
201
+ }
202
+ const held = { op: "role", role: subject };
203
+ return grant.in === undefined
204
+ ? held
205
+ : { op: "rel", relation: grant.in, target: role.on, where: held };
206
+ };
207
+ const pathsOf = (policies) => {
208
+ const collect = pathCollector(policies);
209
+ const paths = {};
210
+ const existsPaths = (resource, predicate, into) => {
211
+ for (const step of createSteps(policies, predicate)) {
212
+ const relation = ownOf(ownOf(policies.schema, resource)?.relations, step.relation);
213
+ for (const reference of relation?.references ?? []) {
214
+ into.push([step.target, reference, "equals"]);
215
+ }
216
+ collect(step.where, [step.target], into);
217
+ nullSafePaths(policies, step.target, step.where, false, into);
218
+ }
219
+ };
220
+ const keep = (rule, found) => {
221
+ const unique = new Map(found.map((path) => [JSON.stringify(path), path]));
222
+ if (unique.size > 0) {
223
+ paths[rule] = [...unique.values()];
224
+ }
225
+ };
226
+ for (const grant of policies.grants) {
227
+ if (grant.resource === "*") {
228
+ continue;
229
+ }
230
+ const found = [];
231
+ const parts = [
232
+ grant.where,
233
+ ...grant.subjects.map((subject) => subjectPredicate(policies, grant, subject)),
234
+ ].filter((part) => part !== undefined);
235
+ for (const part of parts) {
236
+ collect(part, [grant.resource], found);
237
+ nullSafePaths(policies, grant.resource, part, false, found);
238
+ }
239
+ if (grant.after !== undefined) {
240
+ collect(grant.after, [grant.resource], found);
241
+ nullSafePaths(policies, grant.resource, grant.after, false, found);
242
+ const identifier = ownOf(policies.schema, grant.resource)?.identifiers[0] ?? [];
243
+ for (const field of identifier) {
244
+ found.push([grant.resource, field, "equals"]);
245
+ }
246
+ }
247
+ if (grant.actions.includes("create")) {
248
+ for (const part of parts) {
249
+ existsPaths(grant.resource, part, found);
250
+ }
251
+ }
252
+ keep(grant.id, found);
253
+ }
254
+ for (const deny of policies.denies) {
255
+ if (deny.where === undefined) {
256
+ continue;
257
+ }
258
+ const found = [];
259
+ collect(deny.where, [deny.resource], found);
260
+ // A scope (read, update, delete) negates the deny; a create only asks it as it is.
261
+ const scoped = deny.actions === "*" ||
262
+ deny.actions.some((action) => action !== "create");
263
+ nullSafePaths(policies, deny.resource, deny.where, scoped, found);
264
+ if (deny.actions === "*" || deny.actions.includes("create")) {
265
+ existsPaths(deny.resource, deny.where, found);
266
+ }
267
+ keep(deny.id, found);
268
+ }
269
+ return paths;
270
+ };
271
+ // ---------------------------------------------------------------------------
272
+ // Decisions
273
+ // ---------------------------------------------------------------------------
274
+ /** The rule a trace shows deciding: the first grant matched, or the first deny that counted (fail closed). */
275
+ const decidingRule = (trace, allowed) => {
276
+ if (allowed) {
277
+ for (const [grant, outcome] of trace.grants) {
278
+ if (outcome === "matched") {
279
+ return { kind: "grant", id: grant.grant.id };
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+ for (const [deny, outcome] of trace.denies) {
285
+ if (outcome === "matched" || outcome === "unknown") {
286
+ return { kind: "deny", id: deny.deny.id };
287
+ }
288
+ }
289
+ return null;
290
+ };
291
+ /** Refuses a `fieldsOf` question the types rule out but a caller can still send (the decider refuses it too). */
292
+ const checkFieldsQuestion = (question) => {
293
+ const problem = fieldsQuestionProblem(question);
294
+ if (problem !== undefined) {
295
+ throw new AccessError("ACCESS_QUESTION_INVALID", problem);
296
+ }
297
+ };
298
+ /**
299
+ * Builds the engine from a policies file and, optionally, the org's guardrails. Both
300
+ * are checked first; a bad one throws, naming the rule. With `lock`, both are held
301
+ * to the hashes `avAccess.lock.json` pins (`ACCESS_LOCK_MISMATCH`).
302
+ */
303
+ export const createAccessEngine = (policies, options) => engineOf(policies, options, "file");
304
+ /**
305
+ * The engine, from a policies file that came from `source`: with a `lock`, a file
306
+ * handed over is held to the lock's pin, and typed rules (the project's own
307
+ * source) to its namespace only. `createAccess` adds its built-in rules over Better
308
+ * Auth's tables (`guard`): their denies join the file's, and `fieldsOf` never lists a
309
+ * secret column or a field a write may not set.
310
+ */
311
+ export const engineOf = (policies, options, source, observe, guard) => {
312
+ // The engine's own copy, checked, held to the lock, then frozen: nothing the
313
+ // caller does to its objects afterwards reaches what the engine runs from.
314
+ const own = copyOf("policies", policies);
315
+ checkPolicies(own);
316
+ if (options !== undefined &&
317
+ (!isRecord(options) ||
318
+ Object.keys(options).some((key) => !ENGINE_OPTIONS.has(key)) ||
319
+ (options.requireLock !== undefined &&
320
+ typeof options.requireLock !== "boolean"))) {
321
+ fail("engine", "the engine's options are `{ guardrails?, lock?, requireLock?: boolean }`");
322
+ }
323
+ // A guardrails file imported as JSON is typed by its outline only; it is checked
324
+ // here (`composeGuardrails`), as a typed one is.
325
+ const guardrails = options?.guardrails === undefined
326
+ ? undefined
327
+ : copyOf("guardrails", options.guardrails);
328
+ if (options?.lock === undefined) {
329
+ if (options?.requireLock === true) {
330
+ throw new AccessError("ACCESS_LOCK_MISMATCH", "`requireLock` is set and no `lock` was given: pass the project's avAccess.lock.json (`avaccess pull` writes it), so the engine runs only from the files it pins");
331
+ }
332
+ }
333
+ else {
334
+ verifyLocked(checkLock(options.lock, "engine"), {
335
+ namespace: own.namespace,
336
+ // Typed rules are the project's own source; with `requireLock` they are held to
337
+ // the lock's pin too, so a lock that pins other rules is refused.
338
+ ...(source === "file" || options.requireLock === true
339
+ ? { policies: own }
340
+ : {}),
341
+ guardrails,
342
+ });
343
+ }
344
+ // The namespaces the rules read as foreign, from the file's own markers: with a
345
+ // lock or without, they are read only once the beacons have passed.
346
+ const owners = foreignOwnersOf(own);
347
+ const guarded = guardrails === undefined ? own : composeGuardrails(own, guardrails);
348
+ const file = deepFreeze(guard === undefined || guard.denies.length === 0
349
+ ? guarded
350
+ : { ...guarded, denies: [...guarded.denies, ...guard.denies] });
351
+ if (file !== own) {
352
+ checkPolicies(file, true);
353
+ }
354
+ deepFreeze(own);
355
+ const decider = createDecider(file);
356
+ // The beacons' state: `undefined` once they have passed (or when the rules read
357
+ // no other namespace), otherwise why the engine answers nothing yet.
358
+ let refusal = owners.length === 0
359
+ ? undefined
360
+ : `the rules read ${owners.map((owner) => `namespace "${owner}"'s`).join(" and ")} Resources as foreign, and the beacons were not checked: \`await engine.checkBeacons(reader, pins, environment)\` when the service starts, before it takes requests, with this environment's avAccess.beacons.<environment>.json`;
361
+ // A failed check latches: no later check opens the engine again.
362
+ let failed;
363
+ // The checks, one after another.
364
+ let checking = Promise.resolve();
365
+ const open = () => {
366
+ if (refusal !== undefined) {
367
+ throw new AccessError("ACCESS_BEACON_MISMATCH", refusal);
368
+ }
369
+ };
370
+ const fieldsOf = (question) => {
371
+ open();
372
+ checkFieldsQuestion(question);
373
+ return guard === undefined
374
+ ? decider.fieldsOf(question)
375
+ : guardedFields(guard, question, decider.fieldsOf(question), {
376
+ fields: ownOf(file.schema, question.resource)?.fields ?? [],
377
+ governed: ownOf(file.govern, question.resource) ?? [],
378
+ });
379
+ };
380
+ return {
381
+ governed: own.govern,
382
+ paths: deepFreeze(pathsOf(file)),
383
+ afterChecks: deepFreeze(afterChecksOf(file)),
384
+ unheld: Object.freeze(Object.entries(own.schema)
385
+ .filter(([, facts]) => facts.foreign !== undefined)
386
+ .map(([resource]) => resource)),
387
+ can: observe === undefined
388
+ ? (question) => {
389
+ open();
390
+ return decider.can(question, newTrace());
391
+ }
392
+ : (question) => {
393
+ open();
394
+ const trace = newTrace();
395
+ const report = async (allowed) => {
396
+ await observe(question, allowed, decidingRule(trace, allowed));
397
+ return allowed;
398
+ };
399
+ const answer = decider.can(question, trace);
400
+ return typeof answer === "boolean"
401
+ ? report(answer)
402
+ : answer.then(report);
403
+ },
404
+ grantsOf: (question) => {
405
+ open();
406
+ const problem = grantsQuestionProblem(question);
407
+ if (problem !== undefined) {
408
+ throw new AccessError("ACCESS_QUESTION_INVALID", problem);
409
+ }
410
+ return decider.grantsOf(question);
411
+ },
412
+ scopeOf: (question) => {
413
+ open();
414
+ return decider.scopeOf(question);
415
+ },
416
+ fieldsOf,
417
+ hosted: (host) => {
418
+ const held = isRecord(host) ? host.held : undefined;
419
+ if (!Array.isArray(held) ||
420
+ !held.every((resource) => typeof resource === "string")) {
421
+ throw new AccessError("ACCESS_QUESTION_INVALID", "`hosted` takes `{ held }`, the list of Resource names the framework holds a written reference to");
422
+ }
423
+ const served = new Set(held);
424
+ warnReferenceReads(own, served);
425
+ warnForeignReferences(own, served);
426
+ },
427
+ explain: async (question) => {
428
+ open();
429
+ if (globalThis.process
430
+ ?.env?.NODE_ENV === "production") {
431
+ throw new Error("Aventara Access: explain() is for development only");
432
+ }
433
+ const trace = newTrace();
434
+ const allowed = await decider.can(question, trace);
435
+ return {
436
+ allowed,
437
+ grants: [...trace.grants].map(([grant, outcome]) => ({
438
+ id: grant.grant.id,
439
+ subject: grant.subject,
440
+ outcome,
441
+ })),
442
+ denies: [...trace.denies].map(([deny, outcome]) => ({
443
+ id: deny.deny.id,
444
+ outcome,
445
+ })),
446
+ lookups: trace.lookups,
447
+ };
448
+ },
449
+ checkBeacons: (reader, pins, environment) => {
450
+ const run = async () => {
451
+ if (owners.length === 0) {
452
+ return;
453
+ }
454
+ if (failed !== undefined) {
455
+ throw new AccessError("ACCESS_BEACON_MISMATCH", `a beacon check failed before, and the engine stays shut: ${failed}`);
456
+ }
457
+ const problem = await beaconProblem(own.namespace ?? "", owners, pins, environment, reader);
458
+ if (problem !== undefined) {
459
+ failed = problem;
460
+ refusal = problem;
461
+ throw new AccessError("ACCESS_BEACON_MISMATCH", problem);
462
+ }
463
+ refusal = undefined;
464
+ };
465
+ const result = checking.then(run);
466
+ checking = result.catch(() => undefined);
467
+ return result;
468
+ },
469
+ };
470
+ };
@@ -0,0 +1,135 @@
1
+ import type { AccessAction, AccessOrigin } from "../policies-contract.js";
2
+ /**
3
+ * One audit event.
4
+ *
5
+ * - `sign-in`: a session began (a sign-in, or a sign-up that signs the user in).
6
+ * `origin` is `client` when it came through Access's routes, `application` when the
7
+ * server called Better Auth's API itself. `impersonatedBy` is the admin's user id
8
+ * when the session is an impersonation (Better Auth's `admin()` plugin).
9
+ * - `sign-in-failed`: a route that starts a session refused the caller, or the rate
10
+ * limit refused a server call of `signIn`, `signUp` or `resetPassword`
11
+ * (`access.client`, reason `rate-limited`). `route` is
12
+ * the Better Auth endpoint (`/sign-in/email`), `reason` the code it refused with
13
+ * (`INVALID_EMAIL_OR_PASSWORD`), `target` an HMAC-SHA-256 of the identifier the
14
+ * request named (its email, username or phone number), keyed by Better Auth's
15
+ * secret: the same for every attempt on one identifier, and no way back to it. The
16
+ * identifier itself, and anything else the caller sent, is never recorded.
17
+ * - `sign-out`: a session was ended through `/sign-out`.
18
+ * - `token`: a JWT was issued for a session by `GET /token`, with `impersonatedBy` as
19
+ * for `sign-in`.
20
+ * - `kiosk-switch` (the kiosk profile): a PIN switched the device to `userId`, ending
21
+ * the session of `previousUserId` on it (`null` when the device was locked).
22
+ * `deviceId` is the enrolled device's id. A failed PIN is a `sign-in-failed` on
23
+ * `/kiosk/switch-user`, with its `deviceId`.
24
+ * - `kiosk-lock`: a session on a device ended without a switch: `lock` (asked),
25
+ * `idle` (unused for the profile's `idleTimeout`), or `device-mismatch` (it was
26
+ * brought by a request without its device's credential, so it is ended for both).
27
+ * `userId` is the session's user.
28
+ * - `kiosk-device`: a device `enrolled` or `revoked` (by `userId`, `null` when the
29
+ * app's server did it), or revoked for `token-reuse`: its replaced credential was
30
+ * brought again, so a copy of it is in use somewhere (`userId` is `null`); or a
31
+ * request refused `stale-in-grace` (409): it brought the credential the device's
32
+ * last rotation retired, inside the grace, and nothing changed (`userId` is
33
+ * `null`).
34
+ * - `kiosk-pin`: `userId`'s PIN was set.
35
+ * - `decision`: the engine answered a `can` question. A request's admission (may
36
+ * this caller ever do `action` on `resource`?) is one event, allowed or refused,
37
+ * however many rows the request then reads or writes; a row question (a row a
38
+ * create inserts, or an update leaves) adds an event only when the rules refuse
39
+ * that row. `scopeOf` and `fieldsOf` shape an answer and are not decisions.
40
+ * `reason` names the rule that decided: `grant:<id>`, `deny:<id>`, or `no grant`.
41
+ * `userId` is the `sub` of the token or session the principal came from (through
42
+ * `access.state`); `null` for an anonymous caller or a principal Access did not derive.
43
+ *
44
+ * Anonymous refusals (a `decision` refused with `userId: null`, and every
45
+ * `sign-in-failed`) are logged once a minute for each origin and reason (a refused
46
+ * sign-in, for each route and target too): the first is logged, the repeats in the
47
+ * minute are counted, and when the minute closes the count is logged as a summary
48
+ * event, the first one's fields with `suppressed` (the count), `windowStart` and
49
+ * `windowEnd`. The summary is logged on the next event of any kind after the minute,
50
+ * or by a timer at its end; a sign-in on a target logs its target's count first. A
51
+ * flood of anonymous requests makes two events a minute, not one per request.
52
+ */
53
+ export type AccessAuditEvent = {
54
+ readonly at: Date;
55
+ readonly kind: "sign-in";
56
+ readonly userId: string;
57
+ readonly origin: AccessOrigin;
58
+ readonly impersonatedBy?: string;
59
+ } | {
60
+ readonly at: Date;
61
+ readonly kind: "sign-in-failed";
62
+ readonly userId: null;
63
+ readonly route: string;
64
+ readonly reason: string;
65
+ readonly target?: string;
66
+ /** The kiosk profile's device, for a failed PIN. */
67
+ readonly deviceId?: string;
68
+ readonly origin: AccessOrigin;
69
+ readonly suppressed?: number;
70
+ readonly windowStart?: Date;
71
+ readonly windowEnd?: Date;
72
+ } | {
73
+ readonly at: Date;
74
+ readonly kind: "sign-out";
75
+ readonly userId: string;
76
+ readonly origin: AccessOrigin;
77
+ readonly impersonatedBy?: string;
78
+ } | {
79
+ readonly at: Date;
80
+ readonly kind: "token";
81
+ readonly userId: string;
82
+ readonly origin: AccessOrigin;
83
+ readonly impersonatedBy?: string;
84
+ } | {
85
+ readonly at: Date;
86
+ readonly kind: "kiosk-switch";
87
+ readonly userId: string;
88
+ readonly previousUserId: string | null;
89
+ readonly deviceId: string;
90
+ readonly origin: AccessOrigin;
91
+ } | {
92
+ readonly at: Date;
93
+ readonly kind: "kiosk-lock";
94
+ readonly userId: string;
95
+ readonly deviceId: string;
96
+ readonly reason: "lock" | "idle" | "device-mismatch";
97
+ readonly origin: AccessOrigin;
98
+ } | {
99
+ readonly at: Date;
100
+ readonly kind: "kiosk-device";
101
+ readonly userId: string | null;
102
+ readonly deviceId: string;
103
+ readonly reason: "enrolled" | "revoked" | "token-reuse" | "stale-in-grace";
104
+ readonly origin: AccessOrigin;
105
+ } | {
106
+ readonly at: Date;
107
+ readonly kind: "kiosk-pin";
108
+ readonly userId: string;
109
+ readonly origin: AccessOrigin;
110
+ } | {
111
+ readonly at: Date;
112
+ readonly kind: "decision";
113
+ readonly userId: string | null;
114
+ readonly resource: string;
115
+ readonly action: AccessAction;
116
+ readonly allowed: boolean;
117
+ readonly reason: string;
118
+ readonly origin: AccessOrigin;
119
+ readonly suppressed?: number;
120
+ readonly windowStart?: Date;
121
+ readonly windowEnd?: Date;
122
+ };
123
+ /**
124
+ * The `audit` block: `true` writes each event to the `accessAudit` table, through
125
+ * Better Auth's database adapter (Better Auth's own `generate`/`migrate` create it);
126
+ * `{ sink }` hands each event to the app instead, and the table is not declared.
127
+ * `false`, the default, logs nothing.
128
+ *
129
+ * The request waits for the write or the sink. A sink that throws (or a write that
130
+ * fails) does not fail the request: Better Auth's logger warns, and the request goes on.
131
+ */
132
+ export type AccessAuditOptions = boolean | {
133
+ /** Receives each event; the request waits for it, so queue slow work. */
134
+ readonly sink: (event: AccessAuditEvent) => void | Promise<void>;
135
+ };
@@ -0,0 +1,8 @@
1
+ // The audit log's event and the `audit` option. One shape for both destinations: a
2
+ // row of the `accessAudit` table (`audit: true`) or the argument of the app's sink
3
+ // (`audit: { sink }`).
4
+ //
5
+ // An event holds who, what and the answer, never a credential: no token, password,
6
+ // cookie, key or header reaches it, because it is built field by field from the
7
+ // session's user id and the engine's question, never copied from a request.
8
+ export {};