@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,650 @@
1
+ // The decisions a policies file makes: `can`, `grantsOf`, `scopeOf` and `fieldsOf`,
2
+ // answered from the file alone. Default deny, any grant allows, any deny wins, order
3
+ // never matters. The engine (`access.engine.ts`) runs this with its lock, guardrails,
4
+ // decision observer and `explain` around it; `avaccess generate` copies this module,
5
+ // with the modules it imports, into a service's generated client.
6
+ //
7
+ // Like those modules, it imports nothing but them: no Node.js, no package.
8
+
9
+ import {
10
+ type AccessWhere,
11
+ translateAnswer,
12
+ translateWhere,
13
+ } from "./aventara-where.translator.js";
14
+ import { type CanonicalJson, canonicalJson } from "./canonical-json.writer.js";
15
+ import type {
16
+ AccessAction,
17
+ AccessGrantRule,
18
+ AccessOrigin,
19
+ AccessPolicies,
20
+ } from "./policies-contract.js";
21
+ import {
22
+ type AccessExists,
23
+ type AccessLookup,
24
+ type AccessRow,
25
+ type AccessTruth,
26
+ decideOnRow,
27
+ type Evaluated,
28
+ evaluateOnRow,
29
+ } from "./predicate.evaluator.js";
30
+ import {
31
+ FALSE,
32
+ type FoldedDeny,
33
+ type FoldedGrant,
34
+ foldDenies,
35
+ foldGrants,
36
+ nullSafe,
37
+ orOf,
38
+ ownOf,
39
+ principalFactsOf,
40
+ type Resolved,
41
+ rulesFor,
42
+ scopeCondition,
43
+ TRUE,
44
+ } from "./scope.resolver.js";
45
+ import { sha256Hex } from "./sha256.digest.js";
46
+
47
+ /** What every question states. */
48
+ export type AccessQuestion = {
49
+ /** What the host derived for the request: an object, or `null` (anonymous). */
50
+ readonly principal: unknown;
51
+ readonly origin: AccessOrigin;
52
+ readonly resource: string;
53
+ };
54
+
55
+ /** `can`: without a row, admission; with one, the row a create inserts or an update leaves. */
56
+ export type AccessCanQuestion = AccessQuestion & {
57
+ readonly action: AccessAction;
58
+ readonly row?: AccessRow;
59
+ /**
60
+ * On an update with a `row` only: the row as it was before the update. One update
61
+ * grant must then cover both rows, its `where` this one and its `where` and
62
+ * `after` the row the update leaves. Without it, any update grant that covers the
63
+ * row the update leaves lets it in. Read nowhere else.
64
+ */
65
+ readonly before?: AccessRow;
66
+ /**
67
+ * On an update with a `row` only: what `grantsOf` answered about the row before the
68
+ * update, asked before the write. The grant that covers `row` (its `where` and
69
+ * `after`) must then be one of these. With `before` too, each such grant's `where`
70
+ * is checked on `before` in memory, so a list from another row or principal fails
71
+ * on a scalar condition; a relation condition on it is not asked again: it was
72
+ * decided before anything the update wrote. Read nowhere else.
73
+ */
74
+ readonly beforeGrants?: readonly string[];
75
+ readonly exists?: AccessExists;
76
+ };
77
+
78
+ /**
79
+ * `grantsOf`: the update grants that cover `row`, the row an update will change, as it
80
+ * is now. Ask it before the write, and hand its answer back to `can` as `beforeGrants`.
81
+ */
82
+ export type AccessGrantsQuestion = AccessQuestion & {
83
+ readonly action: "update";
84
+ readonly row: AccessRow;
85
+ readonly exists?: AccessExists;
86
+ };
87
+
88
+ /** `scopeOf`: the rows the principal may read, update or delete. */
89
+ export type AccessScopeQuestion = AccessQuestion & {
90
+ readonly action: "read" | "update" | "delete";
91
+ };
92
+
93
+ /** `fieldsOf`: reading, or writing by one action. */
94
+ export type AccessFieldsQuestion = AccessQuestion &
95
+ (
96
+ | { readonly access: "read" }
97
+ | { readonly access: "write"; readonly action?: "create" | "update" }
98
+ );
99
+
100
+ /** `fieldsOf`'s answer: fields usable on every reachable row, and fields usable on some rows. */
101
+ export type AccessFields = {
102
+ readonly always: "all" | readonly string[];
103
+ readonly perRow: { readonly [field: string]: AccessWhere };
104
+ };
105
+
106
+ /** How one rule took part in a decision. */
107
+ export type AccessRuleOutcome =
108
+ | "matched"
109
+ | "not matched"
110
+ | "unknown"
111
+ | "not asked";
112
+
113
+ /** How each rule took part in one `can` decision, and the `exists` lookups it made. */
114
+ export type DecisionTrace = {
115
+ readonly grants: Map<FoldedGrant, AccessRuleOutcome>;
116
+ readonly denies: Map<FoldedDeny, AccessRuleOutcome>;
117
+ readonly lookups: AccessLookup[];
118
+ };
119
+
120
+ /** A trace with nothing in it yet. */
121
+ export const newTrace = (): DecisionTrace => ({
122
+ grants: new Map(),
123
+ denies: new Map(),
124
+ lookups: [],
125
+ });
126
+
127
+ /**
128
+ * The Resources where the row an update leaves can fail what allowed the update:
129
+ * an update grant whose condition reads the row (a `where`, an `after`, a role held
130
+ * on a row), or an update deny with a `where`. Core asks `can` about each row such
131
+ * an update changed, and refuses these updates where it has no interactive
132
+ * transaction to roll one back in.
133
+ */
134
+ export const afterChecksOf = (
135
+ policies: AccessPolicies,
136
+ ): Record<string, "update"[]> => {
137
+ const afterChecks: Record<string, "update"[]> = {};
138
+ const reads = (grant: AccessGrantRule): boolean =>
139
+ grant.where !== undefined ||
140
+ grant.after !== undefined ||
141
+ grant.in !== undefined ||
142
+ grant.subjects.some((subject) => {
143
+ const role = ownOf(policies.roles, subject);
144
+ return role !== undefined && "on" in role;
145
+ });
146
+ for (const grant of policies.grants) {
147
+ if (
148
+ grant.resource !== "*" &&
149
+ grant.actions.includes("update") &&
150
+ reads(grant)
151
+ ) {
152
+ afterChecks[grant.resource] = ["update"];
153
+ }
154
+ }
155
+ for (const deny of policies.denies) {
156
+ if (
157
+ deny.resource !== "*" &&
158
+ deny.where !== undefined &&
159
+ (deny.actions === "*" || deny.actions.includes("update"))
160
+ ) {
161
+ afterChecks[deny.resource] = ["update"];
162
+ }
163
+ }
164
+ return afterChecks;
165
+ };
166
+
167
+ /**
168
+ * The fields each grant's `write.except` locks. A relation and its reference fields
169
+ * are locked together: writing the relation (`$connect`, `$set`) writes those
170
+ * fields, and writing a reference field moves the relation, so naming either one
171
+ * locks both.
172
+ */
173
+ const lockedFieldsOf = (
174
+ policies: AccessPolicies,
175
+ ): Map<AccessGrantRule, ReadonlySet<string>> => {
176
+ const locks = new Map<AccessGrantRule, ReadonlySet<string>>();
177
+ for (const grant of policies.grants) {
178
+ if (grant.write === undefined) {
179
+ continue;
180
+ }
181
+ const named = new Set(grant.write.except);
182
+ const locked = new Set(named);
183
+ const relations = ownOf(policies.schema, grant.resource)?.relations ?? {};
184
+ for (const [relation, fact] of Object.entries(relations)) {
185
+ const references = fact.referenceFields ?? [];
186
+ if (named.has(relation) || references.some((field) => named.has(field))) {
187
+ locked.add(relation);
188
+ for (const field of references) {
189
+ locked.add(field);
190
+ }
191
+ }
192
+ }
193
+ locks.set(grant, locked);
194
+ }
195
+ return locks;
196
+ };
197
+
198
+ /**
199
+ * What is wrong with a `fieldsOf` question the types rule out but a caller can still
200
+ * send, or `undefined`: answered as asked, it would read as a read, or as no field.
201
+ */
202
+ export const fieldsQuestionProblem = (
203
+ question: AccessFieldsQuestion,
204
+ ): string | undefined => {
205
+ const { access, action } = question as {
206
+ readonly access?: unknown;
207
+ readonly action?: unknown;
208
+ };
209
+ const fits =
210
+ access === "read"
211
+ ? action === undefined
212
+ : access === "write" &&
213
+ (action === undefined || action === "create" || action === "update");
214
+ return fits
215
+ ? undefined
216
+ : `fieldsOf takes \`access: "read"\`, or \`access: "write"\` with an optional \`action\` ("create" or "update"); it was asked ${JSON.stringify({ access, action })}`;
217
+ };
218
+
219
+ const outcomeOf = (truth: AccessTruth): AccessRuleOutcome =>
220
+ truth === "unknown" ? "unknown" : truth ? "matched" : "not matched";
221
+
222
+ const isDecided = (
223
+ node: Evaluated,
224
+ ): node is { readonly k: "true" } | { readonly k: "false" } =>
225
+ node.k === "true" || node.k === "false";
226
+
227
+ /**
228
+ * What is wrong with a `grantsOf` question the types rule out but a caller can still
229
+ * send, or `undefined`: it asks about an update, and nothing else.
230
+ */
231
+ export const grantsQuestionProblem = (
232
+ question: AccessGrantsQuestion,
233
+ ): string | undefined => {
234
+ const { action } = question as { readonly action?: unknown };
235
+ return action === "update"
236
+ ? undefined
237
+ : `grantsOf takes \`action: "update"\`; it was asked ${JSON.stringify({ action })}`;
238
+ };
239
+
240
+ /** The questions a policies file answers. */
241
+ export type PoliciesDecider = {
242
+ /** `can`, recording each rule's part in the answer in `trace`. */
243
+ can(
244
+ question: AccessCanQuestion,
245
+ trace: DecisionTrace,
246
+ ): boolean | Promise<boolean>;
247
+ /**
248
+ * The keys of the update grants whose `where`, and their subject's row condition,
249
+ * hold on `row` as it is now: opaque and stable for this file, never rule data.
250
+ * None when an update deny is decided on `row` in memory.
251
+ */
252
+ grantsOf(
253
+ question: AccessGrantsQuestion,
254
+ ): readonly string[] | Promise<readonly string[]>;
255
+ scopeOf(question: AccessScopeQuestion): boolean | AccessWhere;
256
+ fieldsOf(question: AccessFieldsQuestion): AccessFields;
257
+ };
258
+
259
+ /** The decisions of one policies file, already checked. */
260
+ export const createDecider = (file: AccessPolicies): PoliciesDecider => {
261
+ const locks = lockedFieldsOf(file);
262
+ // A grant's key: a short hash of the file's grants, in their order, then its place
263
+ // in the file and its subject's place in the grant. It names a grant for one
264
+ // subject without carrying any of its data, a list from another file names no grant
265
+ // of this one, and it is the same for the folded grant `grantsOf` reads (its
266
+ // `where`) and the one `can` reads (its `where` and `after`).
267
+ const mark = sha256Hex(
268
+ canonicalJson(file.grants as unknown as CanonicalJson),
269
+ ).slice(0, 8);
270
+ const places = new Map(file.grants.map((grant, index) => [grant, index]));
271
+ const keyOf = (grant: FoldedGrant): string =>
272
+ `${mark}:${places.get(grant.grant) ?? -1}.${grant.grant.subjects.indexOf(grant.subject)}`;
273
+
274
+ const can = (
275
+ question: AccessCanQuestion,
276
+ trace: DecisionTrace,
277
+ ): boolean | Promise<boolean> => {
278
+ const facts = principalFactsOf(question.principal, question.origin);
279
+ const { grants, denies } = rulesFor(
280
+ file,
281
+ question.resource,
282
+ question.action,
283
+ );
284
+ const { row } = question;
285
+
286
+ if (row === undefined) {
287
+ // Admission: could this principal ever do this? Row conditions are the scope's.
288
+ const folded = foldGrants(file, grants, facts);
289
+ const foldedDenies = foldDenies(file, denies, facts);
290
+ for (const grant of folded) {
291
+ trace.grants.set(
292
+ grant,
293
+ grant.condition.k === "false" ? "not matched" : "matched",
294
+ );
295
+ }
296
+ for (const deny of foldedDenies) {
297
+ trace.denies.set(
298
+ deny,
299
+ deny.condition.k === "true" ? "matched" : "not matched",
300
+ );
301
+ }
302
+ return (
303
+ folded.some((grant) => grant.condition.k !== "false") &&
304
+ !foldedDenies.some((deny) => deny.condition.k === "true")
305
+ );
306
+ }
307
+
308
+ if (typeof row !== "object" || row === null) {
309
+ return false;
310
+ }
311
+ // The row a create inserts, or the row an update leaves (core's after check, or
312
+ // any caller's): an update's row must still meet the `where` and the `after` of
313
+ // one update grant, and no update deny may match it.
314
+ const isUpdate = question.action === "update";
315
+ const kind = question.action === "create" ? "new" : "existing";
316
+ // With the row before the update: the grant that covers the row
317
+ // the update leaves must be one whose `where` covered the row before it, so no
318
+ // update moves a row from the rows one grant reaches to another's. With
319
+ // `beforeGrants`, what covered it was decided before the write (`grantsOf`):
320
+ // `before` is still held to each listed grant's `where` in memory, since the list
321
+ // is the caller's to hand back, but a relation condition on it is not asked again.
322
+ const beforeGrants = isUpdate ? question.beforeGrants : undefined;
323
+ if (
324
+ beforeGrants !== undefined &&
325
+ (!Array.isArray(beforeGrants) ||
326
+ beforeGrants.some((key) => typeof key !== "string"))
327
+ ) {
328
+ return false;
329
+ }
330
+ const coveredBefore =
331
+ beforeGrants === undefined ? undefined : new Set(beforeGrants);
332
+ const before = isUpdate ? question.before : undefined;
333
+ if (
334
+ before !== undefined &&
335
+ (typeof before !== "object" || before === null)
336
+ ) {
337
+ return false;
338
+ }
339
+ const folded = foldGrants(file, grants, facts, isUpdate);
340
+ const reached =
341
+ before === undefined ? undefined : foldGrants(file, grants, facts);
342
+ const foldedDenies = foldDenies(file, denies, facts);
343
+
344
+ const grantResiduals: {
345
+ readonly grant: FoldedGrant;
346
+ readonly residual: Resolved;
347
+ /** What the row before the update leaves of the grant's `where`; `undefined` when decided true. */
348
+ readonly prior: Resolved | undefined;
349
+ }[] = [];
350
+ let granted = false;
351
+ for (const [index, grant] of folded.entries()) {
352
+ if (coveredBefore !== undefined && !coveredBefore.has(keyOf(grant))) {
353
+ // It did not cover the row before the update.
354
+ trace.grants.set(grant, "not matched");
355
+ continue;
356
+ }
357
+ const residual = evaluateOnRow(
358
+ file,
359
+ question.resource,
360
+ grant.condition,
361
+ row,
362
+ );
363
+ // Folded alike, the two lists pair grant for grant; a missing pair covers nothing.
364
+ const evaluated =
365
+ reached === undefined || before === undefined
366
+ ? undefined
367
+ : evaluateOnRow(
368
+ file,
369
+ question.resource,
370
+ reached?.[index]?.condition ?? FALSE,
371
+ before,
372
+ );
373
+ // Listed in `beforeGrants`: what is left of its `where` on `before` is a relation
374
+ // condition `grantsOf` decided before the write; only a decided false, or a
375
+ // value of the wrong type, refuses.
376
+ const prior =
377
+ coveredBefore !== undefined &&
378
+ evaluated?.k !== "false" &&
379
+ evaluated?.k !== "unknown"
380
+ ? undefined
381
+ : evaluated;
382
+ if (residual.k === "unknown" || prior?.k === "unknown") {
383
+ // A value of the wrong type: no grant.
384
+ trace.grants.set(grant, "unknown");
385
+ } else if (residual.k === "false" || prior?.k === "false") {
386
+ trace.grants.set(grant, "not matched");
387
+ } else if (
388
+ residual.k === "true" &&
389
+ (prior === undefined || prior.k === "true")
390
+ ) {
391
+ trace.grants.set(grant, "matched");
392
+ granted = true;
393
+ } else {
394
+ trace.grants.set(grant, "not asked");
395
+ grantResiduals.push({
396
+ grant,
397
+ residual,
398
+ prior: prior === undefined || prior.k === "true" ? undefined : prior,
399
+ });
400
+ }
401
+ }
402
+ const denyResiduals: {
403
+ readonly deny: FoldedDeny;
404
+ readonly residual: Resolved;
405
+ }[] = [];
406
+ for (const deny of foldedDenies) {
407
+ const residual = evaluateOnRow(
408
+ file,
409
+ question.resource,
410
+ deny.condition,
411
+ row,
412
+ );
413
+ if (residual.k === "unknown") {
414
+ // A value of the wrong type: the deny counts (fail closed).
415
+ trace.denies.set(deny, "unknown");
416
+ return false;
417
+ }
418
+ if (isDecided(residual)) {
419
+ trace.denies.set(
420
+ deny,
421
+ residual.k === "true" ? "matched" : "not matched",
422
+ );
423
+ if (residual.k === "true") {
424
+ return false;
425
+ }
426
+ } else {
427
+ trace.denies.set(deny, "not asked");
428
+ denyResiduals.push({ deny, residual });
429
+ }
430
+ }
431
+ if (!granted && grantResiduals.length === 0) {
432
+ return false;
433
+ }
434
+ if (denyResiduals.length === 0 && granted) {
435
+ return true;
436
+ }
437
+
438
+ const ask = (
439
+ condition: Resolved,
440
+ on: AccessRow = row,
441
+ ): Promise<AccessTruth> =>
442
+ decideOnRow({
443
+ policies: file,
444
+ resource: question.resource,
445
+ condition,
446
+ row: on,
447
+ kind,
448
+ exists: question.exists,
449
+ lookups: trace.lookups,
450
+ });
451
+ return (async () => {
452
+ for (const { deny, residual } of denyResiduals) {
453
+ const truth = await ask(residual);
454
+ trace.denies.set(deny, outcomeOf(truth));
455
+ // A deny that cannot be decided counts: fail closed.
456
+ if (truth !== false) {
457
+ return false;
458
+ }
459
+ }
460
+ if (granted) {
461
+ return true;
462
+ }
463
+ for (const { grant, residual, prior } of grantResiduals) {
464
+ let truth = await ask(residual);
465
+ if (truth === true && prior !== undefined && before !== undefined) {
466
+ truth = await ask(prior, before);
467
+ }
468
+ trace.grants.set(grant, outcomeOf(truth));
469
+ if (truth === true) {
470
+ return true;
471
+ }
472
+ }
473
+ return false;
474
+ })();
475
+ };
476
+
477
+ const fieldsOf = (question: AccessFieldsQuestion): AccessFields => {
478
+ const problem = fieldsQuestionProblem(question);
479
+ if (problem !== undefined) {
480
+ throw new Error(`Aventara Access: ${problem}`);
481
+ }
482
+ const facts = principalFactsOf(question.principal, question.origin);
483
+ const governed = ownOf(file.govern, question.resource) ?? [];
484
+ const actions: AccessAction[] =
485
+ question.access === "read"
486
+ ? ["read"]
487
+ : question.action !== undefined
488
+ ? [question.action]
489
+ : ["create", "update"];
490
+ const grants = [
491
+ ...new Set(
492
+ actions.flatMap(
493
+ (action) => rulesFor(file, question.resource, action).grants,
494
+ ),
495
+ ),
496
+ ];
497
+ const live = foldGrants(file, grants, facts).filter(
498
+ (grant) => grant.condition.k !== "false",
499
+ );
500
+ if (live.length === 0) {
501
+ return { always: [], perRow: {} };
502
+ }
503
+ const excludes = (grant: FoldedGrant, field: string): boolean =>
504
+ question.access === "write" &&
505
+ (locks.get(grant.grant)?.has(field) ?? false);
506
+ const names = (grant: FoldedGrant, field: string): boolean =>
507
+ grant.grant.fields?.includes(`${question.resource}.${field}`) ?? false;
508
+ const answer = (condition: Resolved): AccessWhere =>
509
+ condition.k === "true"
510
+ ? {}
511
+ : translateWhere(nullSafe(file, question.resource, condition));
512
+
513
+ const perRow: Record<string, AccessWhere> = {};
514
+ for (const field of governed) {
515
+ const condition = orOf(
516
+ live
517
+ .filter((grant) => names(grant, field) && !excludes(grant, field))
518
+ .map((grant) => grant.condition),
519
+ );
520
+ if (condition.k !== "false") {
521
+ perRow[field] = answer(condition);
522
+ }
523
+ }
524
+ const excluded = new Set(
525
+ live.flatMap((grant) =>
526
+ question.access === "write" ? [...(locks.get(grant.grant) ?? [])] : [],
527
+ ),
528
+ );
529
+ if (excluded.size === 0) {
530
+ return { always: "all", perRow };
531
+ }
532
+ const always: string[] = [];
533
+ for (const field of ownOf(file.schema, question.resource)?.fields ?? []) {
534
+ if (governed.includes(field)) {
535
+ continue;
536
+ }
537
+ if (!excluded.has(field)) {
538
+ always.push(field);
539
+ continue;
540
+ }
541
+ const condition = orOf(
542
+ live
543
+ .filter((grant) => !excludes(grant, field))
544
+ .map((grant) => grant.condition),
545
+ );
546
+ if (condition.k === "true") {
547
+ always.push(field);
548
+ } else if (condition.k !== "false") {
549
+ perRow[field] = answer(condition);
550
+ }
551
+ }
552
+ return { always, perRow };
553
+ };
554
+
555
+ const scopeOf = (question: AccessScopeQuestion): boolean | AccessWhere => {
556
+ const facts = principalFactsOf(question.principal, question.origin);
557
+ const { grants, denies } = rulesFor(
558
+ file,
559
+ question.resource,
560
+ question.action,
561
+ );
562
+ return translateAnswer(
563
+ nullSafe(
564
+ file,
565
+ question.resource,
566
+ scopeCondition(
567
+ foldGrants(file, grants, facts),
568
+ foldDenies(file, denies, facts),
569
+ ),
570
+ ),
571
+ );
572
+ };
573
+
574
+ const grantsOf = (
575
+ question: AccessGrantsQuestion,
576
+ ): readonly string[] | Promise<readonly string[]> => {
577
+ const problem = grantsQuestionProblem(question);
578
+ if (problem !== undefined) {
579
+ throw new Error(`Aventara Access: ${problem}`);
580
+ }
581
+ const { row } = question;
582
+ if (typeof row !== "object" || row === null) {
583
+ // No row, no grant: a `can` handed this answer refuses.
584
+ return [];
585
+ }
586
+ const facts = principalFactsOf(question.principal, question.origin);
587
+ const { grants, denies } = rulesFor(file, question.resource, "update");
588
+ // An update deny decided on the row as it is (or on a value of the wrong type,
589
+ // failing closed): no grant lets the update in. A deny on a relation is the row
590
+ // after's to answer.
591
+ if (
592
+ foldDenies(file, denies, facts).some((deny) => {
593
+ const truth = evaluateOnRow(
594
+ file,
595
+ question.resource,
596
+ deny.condition,
597
+ row,
598
+ ).k;
599
+ return truth === "true" || truth === "unknown";
600
+ })
601
+ ) {
602
+ return [];
603
+ }
604
+ // Each grant's `where` and its subject's row condition, without its `after`: the
605
+ // condition `scopeOf` reads, asked about the row as it is. The answer lists the
606
+ // keys in the file's order.
607
+ const decided: { readonly key: string; residual: Evaluated }[] = foldGrants(
608
+ file,
609
+ grants,
610
+ facts,
611
+ ).map((grant) => ({
612
+ key: keyOf(grant),
613
+ residual: evaluateOnRow(file, question.resource, grant.condition, row),
614
+ }));
615
+ const covering = (): string[] =>
616
+ decided.flatMap(({ key, residual }) =>
617
+ residual.k === "true" ? [key] : [],
618
+ );
619
+ if (
620
+ decided.every(
621
+ ({ residual }) => isDecided(residual) || residual.k === "unknown",
622
+ )
623
+ ) {
624
+ return covering();
625
+ }
626
+ return (async () => {
627
+ const lookups: AccessLookup[] = [];
628
+ for (const entry of decided) {
629
+ const { residual } = entry;
630
+ if (isDecided(residual) || residual.k === "unknown") {
631
+ continue;
632
+ }
633
+ const truth = await decideOnRow({
634
+ policies: file,
635
+ resource: question.resource,
636
+ condition: residual,
637
+ row,
638
+ kind: "existing",
639
+ exists: question.exists,
640
+ lookups,
641
+ });
642
+ // A grant that cannot be decided does not cover the row.
643
+ entry.residual = truth === true ? TRUE : FALSE;
644
+ }
645
+ return covering();
646
+ })();
647
+ };
648
+
649
+ return { can, grantsOf, scopeOf, fieldsOf };
650
+ };