@sanoma-ai/workflows 0.1.0 → 0.4.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 (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. package/src/writer.ts +225 -0
package/src/cedar.ts ADDED
@@ -0,0 +1,493 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+ import type { CedarValueJson, DetailedError, EntityJson, Expr, PolicyJson } from "@cedar-policy/cedar-wasm/nodejs";
5
+ import { locator } from "./ast.ts";
6
+ import type { ResolvedConfig } from "./config.ts";
7
+ import type { ApprovalState, Approver, Principal } from "./define.ts";
8
+ import { filesUnder } from "./paths.ts";
9
+ import {
10
+ allow,
11
+ allowAll,
12
+ approve,
13
+ approvedFor,
14
+ type Decision,
15
+ deny,
16
+ denyAll,
17
+ type Policy,
18
+ type PolicyCall,
19
+ type PreviewAnswer,
20
+ type PreviewRequest,
21
+ } from "./policy.ts";
22
+ import { readPeople } from "./resources.ts";
23
+ import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf } from "./schema.ts";
24
+ import {
25
+ approverLabel,
26
+ approverOf,
27
+ DATA_VENDOR,
28
+ groupNamesOf,
29
+ type PeopleResource,
30
+ principalIdsOf,
31
+ principalOf,
32
+ problemLine,
33
+ WORKFLOW_ACTION,
34
+ } from "./shared.ts";
35
+
36
+ // A company's policies in Cedar (`policies/**/*.cedar`), decided with `@cedar-policy/cedar-wasm`
37
+ // against the schema of its resolved config (`schemaOf`). Allow is the default: the runtime adds
38
+ // the one `permit`, so a company's policies are `forbid`s, each a hold when it carries
39
+ // `@hold(...)` and a deny otherwise.
40
+
41
+ /** Where a company's policies are, under its root. */
42
+ export const POLICIES_DIR = "policies";
43
+
44
+ /** One policy, as loaded: its id, its text and where it is written, and the annotations Sanoma reads. */
45
+ export interface CedarPolicySource {
46
+ /** Its `@id`, else `<file>#<n>`, counting from 1 in the order written. */
47
+ id: string;
48
+ text: string;
49
+ /** Relative to the root, such as `policies/money.cedar`. */
50
+ file: string;
51
+ /** The line and column its text starts at in `file`, from 1. */
52
+ line: number;
53
+ column: number;
54
+ effect: "permit" | "forbid";
55
+ /** `@hold(...)`: a declared group, a declared person's id, or `on-behalf-of`. */
56
+ hold?: string;
57
+ /** `@title(...)`: the Inbox title of its hold. */
58
+ title?: string;
59
+ /** `@reason(...)`: the message of its deny. */
60
+ reason?: string;
61
+ }
62
+
63
+ /** What is wrong in the policies, at a file, line and column when Cedar says where. */
64
+ export interface PolicyProblem {
65
+ file?: string;
66
+ line?: number;
67
+ column?: number;
68
+ /** The policy's id, when the problem is in one. */
69
+ policy?: string;
70
+ message: string;
71
+ }
72
+
73
+ /** The runtime's own policy: allow whatever no `forbid` refuses. */
74
+ const ALLOW_ID = "allow-by-default";
75
+ const ALLOW = "permit (principal, action, resource);";
76
+
77
+ /** The `@hold` that addresses the principal's `onBehalfOf`. */
78
+ const ON_BEHALF_OF = "on-behalf-of";
79
+
80
+ type Place = { line: number; column: number };
81
+
82
+ /** `<root>/policies/**\/*.cedar`, sorted by path, each relative to the root with `/` between its parts. */
83
+ export function readPolicyFiles(root: string): { file: string; text: string }[] {
84
+ return filesUnder(join(root, POLICIES_DIR), (name) => name.endsWith(".cedar")).map((path) => ({
85
+ file: relative(root, path).split(sep).join("/"),
86
+ text: readFileSync(path, "utf8"),
87
+ }));
88
+ }
89
+
90
+ /**
91
+ * Each file's policies, in the order written (`policySetTextToParts`, which sorts them, then
92
+ * their places in the text), each with its id and annotations (`policyToJson`): built once, the
93
+ * map from a policy's id to what decides its answer. A file that does not parse, a template and
94
+ * two policies with one id are problems.
95
+ */
96
+ export function loadPolicies(files: readonly { file: string; text: string }[]): {
97
+ policies: CedarPolicySource[];
98
+ problems: PolicyProblem[];
99
+ } {
100
+ const cedar = cedarWasm();
101
+ const policies: CedarPolicySource[] = [];
102
+ const problems: PolicyProblem[] = [];
103
+ const ids = new Set([ALLOW_ID]);
104
+ for (const { file, text } of files) {
105
+ const locate = locator(text);
106
+ const parts = cedar.policySetTextToParts(text);
107
+ if (parts.type === "failure") {
108
+ problems.push(...parts.errors.map((e) => problemOf(e, file, text)));
109
+ continue;
110
+ }
111
+ for (const template of parts.policy_templates) {
112
+ problems.push({
113
+ file,
114
+ ...locate(text.indexOf(template)),
115
+ message: "a template (`?principal`, `?resource`) is not a policy: name the principal or resource",
116
+ });
117
+ }
118
+ const placed = new Set<number>();
119
+ const inOrder = parts.policies
120
+ .map((part) => {
121
+ let offset = text.indexOf(part);
122
+ while (offset >= 0 && placed.has(offset)) offset = text.indexOf(part, offset + 1);
123
+ placed.add(offset);
124
+ // A part that is not a slice of the text (none is, as Cedar 4.14 cuts them) keeps Cedar's order, last.
125
+ return { part, offset: offset < 0 ? Infinity : offset };
126
+ })
127
+ .toSorted((a, b) => a.offset - b.offset);
128
+ inOrder.forEach(({ part, offset }, n) => {
129
+ const start = locate(Number.isFinite(offset) ? offset : 0);
130
+ const answer = cedar.policyToJson(part);
131
+ if (answer.type === "failure") {
132
+ problems.push(...answer.errors.map((e) => problemOf(e, file, part, start)));
133
+ return;
134
+ }
135
+ const { effect, annotations = {} } = answer.json;
136
+ const id = annotations.id ?? `${file}#${n + 1}`;
137
+ if (ids.has(id)) {
138
+ const whose = id === ALLOW_ID ? "the runtime's own policy's" : "another policy's";
139
+ problems.push({ file, line: start.line, message: `the id "${id}" is ${whose}: give this one another @id` });
140
+ return;
141
+ }
142
+ ids.add(id);
143
+ policies.push({
144
+ id,
145
+ text: part,
146
+ file,
147
+ ...start,
148
+ effect,
149
+ ...(annotations.hold !== undefined && { hold: annotations.hold }),
150
+ ...(annotations.title !== undefined && { title: annotations.title }),
151
+ ...(annotations.reason !== undefined && { reason: annotations.reason }),
152
+ });
153
+ });
154
+ }
155
+ return { policies, problems };
156
+ }
157
+
158
+ /**
159
+ * The policies checked against the schema of the resolved config (`validate`, strict), and what
160
+ * Cedar cannot check: a `permit` (the runtime's is the only one), and a `@hold` naming neither a
161
+ * declared group, nor a declared person, nor `on-behalf-of`. In the order written, each at its
162
+ * file, line and column, once per place: a policy scoped to every action fails the same way for each.
163
+ */
164
+ export function validatePolicies(
165
+ resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
166
+ policies: readonly CedarPolicySource[],
167
+ people: readonly PeopleResource[],
168
+ ): PolicyProblem[] {
169
+ const answer = cedarWasm().validate({
170
+ schema: schemaOf(resolved, people),
171
+ policies: { staticPolicies: Object.fromEntries(policies.map((p) => [p.id, p.text])) },
172
+ validationSettings: { mode: "strict" },
173
+ });
174
+ if (answer.type === "failure") return answer.errors.map((e) => ({ message: messageOf(e) }));
175
+ return policies.flatMap((p) => {
176
+ const place = { file: p.file, line: p.line, policy: p.id };
177
+ const places = new Set<string>();
178
+ const cedar = answer.validationErrors
179
+ .filter(({ policyId }) => policyId === p.id)
180
+ .map(({ error }) => ({ ...problemOf(error, p.file, p.text, p), policy: p.id }))
181
+ .filter(({ line, column }) => {
182
+ // Cedar's errors without a place are each their own.
183
+ if (column === undefined) return true;
184
+ const at = `${line}:${column}`;
185
+ return !places.has(at) && Boolean(places.add(at));
186
+ })
187
+ .toSorted((a, b) => a.line! - b.line! || (a.column ?? 0) - (b.column ?? 0));
188
+ const ours: PolicyProblem[] = [];
189
+ if (p.effect === "permit") {
190
+ ours.push({ ...place, message: "a permit changes nothing: every call no forbid refuses is allowed already" });
191
+ } else if (p.hold !== undefined && p.hold !== ON_BEHALF_OF && approverOf(p.hold, people) === undefined) {
192
+ ours.push({ ...place, message: `@hold("${p.hold}") names no declared group or person, nor "${ON_BEHALF_OF}"` });
193
+ }
194
+ return [...cedar, ...ours];
195
+ });
196
+ }
197
+
198
+ /**
199
+ * The policy a repo's Cedar files make, the runtime's `permit` added; its `version` a hash of the
200
+ * policies' ids and text. It decides each call with `statefulIsAuthorized`
201
+ * over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
202
+ *
203
+ * - allow: `allow`, with the policies that allowed as `reasons`;
204
+ * - deny, a determining `forbid` without `@hold`: `deny` with its `@reason`, or "Refused by <id>";
205
+ * - deny by `@hold` forbids only: each lifted once an approval addressed to its approver and
206
+ * covering the operation is approved (`approvedFor`); allowed when all are, else `approve` for
207
+ * the first, in the order written, with its `@title` and its id as `reasons`.
208
+ *
209
+ * An approver is a declared group (`{ group }`), a declared person's id, or the principal's
210
+ * `onBehalfOf`; a principal acting for no one makes `on-behalf-of` a deny, "cannot resolve
211
+ * approver". A policy that fails to evaluate is a deny naming it (Cedar would skip it), as is one
212
+ * reading a field a `before` lacks: a record saved under fields since changed is asked about
213
+ * without the schema, so it can still be fixed or deleted.
214
+ * `preview` asks the same ahead of a call, its context unknown; `start`, about a run's start.
215
+ */
216
+ export function cedarPolicy(
217
+ resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
218
+ policies: readonly CedarPolicySource[],
219
+ people: readonly PeopleResource[],
220
+ ): Policy {
221
+ const cedar = cedarWasm();
222
+ const schema = schemaOf(resolved, people);
223
+ const staticPolicies = { ...Object.fromEntries(policies.map((p) => [p.id, p.text])), [ALLOW_ID]: ALLOW };
224
+ const version = hash(staticPolicies);
225
+ // Cedar caches both per thread, by name: one for these policies over this schema.
226
+ const set = `sanoma-${hash({ staticPolicies, schema })}`;
227
+ for (const answer of [cedar.preparsePolicySet(set, { staticPolicies }), cedar.preparseSchema(set, schema)]) {
228
+ if (answer.type === "failure") {
229
+ throw new Error(`The Cedar policies do not parse: ${answer.errors.map(messageOf).join("; ")}`);
230
+ }
231
+ }
232
+ const directory = directoryOf(people);
233
+
234
+ /**
235
+ * The determining `forbid`s, in the order written, each with whom it holds for: the first that
236
+ * refuses (no `@hold`, else a hold for no one), or the holds.
237
+ */
238
+ const forbidsOf = (ids: readonly string[], principal: Principal) => {
239
+ const holds = policies
240
+ .filter((p) => p.effect === "forbid" && ids.includes(p.id))
241
+ .map((p) => ({ p, approver: p.hold === undefined ? undefined : holdOf(p.hold, principal, people) }));
242
+ const refusing = holds.find(({ p }) => p.hold === undefined) ?? holds.find(({ approver }) => !approver);
243
+ return { refusing: refusing?.p, holds };
244
+ };
245
+
246
+ /** What `request` is about: a workflow start on its `Workflow`, an operation on its target, in its collection for `data`. */
247
+ const requestOf = (
248
+ principal: Principal,
249
+ action: string,
250
+ target: string | undefined,
251
+ context: (names: string[]) => Record<string, unknown>,
252
+ ) => {
253
+ const entities = directory.entities(principal);
254
+ let resource;
255
+ if (action.startsWith(WORKFLOW_ACTION)) {
256
+ resource = uid("Workflow", action.slice(WORKFLOW_ACTION.length));
257
+ } else {
258
+ resource = uid("Target", target ?? action);
259
+ const op = resolved.ops.get(action);
260
+ const parents = op?.vendor === DATA_VENDOR ? [uid("Collection", op.resource)] : [];
261
+ entities.push({ uid: resource, attrs: {}, parents });
262
+ }
263
+ const type = contextTypeOf(schema, action);
264
+ return {
265
+ principal: uid("Principal", principal.id),
266
+ action: uid("Action", action),
267
+ resource,
268
+ context: (type ? cedarValue(context(Object.keys(type.attributes)), type) : {}) as Record<string, CedarValueJson>,
269
+ entities,
270
+ };
271
+ };
272
+
273
+ /** May `actor` take `action` on `target` with `context`, given the run's `approvals` so far. */
274
+ const authorize = (
275
+ actor: Principal,
276
+ action: string,
277
+ target: string | undefined,
278
+ context: Record<string, unknown>,
279
+ approvals: PolicyCall["run"]["approvals"],
280
+ ): Decision => {
281
+ const principal = directory.principal(actor);
282
+ const check = (asked: Record<string, unknown>) =>
283
+ cedar.statefulIsAuthorized({
284
+ ...requestOf(principal, action, target, () => asked),
285
+ preparsedSchemaName: set,
286
+ validateRequest: true,
287
+ preparsedPolicySetId: set,
288
+ });
289
+ let answer = check(context);
290
+ // A record saved under fields since changed does not fit the schema as `before`. When the rest
291
+ // of the call does, it is asked again without the schema (the context is plain values), the
292
+ // action's groups given as entities: a policy reading a field the record lacks fails, so denies.
293
+ const { before, ...rest } = context;
294
+ if (answer.type === "failure" && before !== undefined && check(rest).type === "success") {
295
+ const request = requestOf(principal, action, target, () => context);
296
+ const groups = schema[NAMESPACE]?.actions[action]?.memberOf ?? [];
297
+ request.entities.push({ uid: request.action, attrs: {}, parents: groups.map((g) => uid("Action", g.id)) });
298
+ answer = cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: set });
299
+ }
300
+ // A request the schema refuses (an input Cedar cannot hold) is denied, not an error of the call.
301
+ if (answer.type === "failure")
302
+ return deny(`The call does not fit the Cedar schema: ${answer.errors.map(messageOf).join("; ")}`);
303
+ const { decision, diagnostics } = answer.response;
304
+ const failed = diagnostics.errors[0];
305
+ if (failed) return deny(`${failed.policyId} failed: ${messageOf(failed.error)}`, [failed.policyId]);
306
+ const reasons = diagnostics.reason;
307
+ if (decision === "allow") return allow(reasons);
308
+ const { refusing, holds } = forbidsOf(reasons, principal);
309
+ if (refusing) {
310
+ return deny(
311
+ refusing.hold === undefined ? (refusing.reason ?? `Refused by ${refusing.id}`) : "cannot resolve approver",
312
+ reasons,
313
+ );
314
+ }
315
+ const waiting = holds.find(({ approver }) => !approvedFor(approvals, action, approver!));
316
+ if (!waiting) return allow(reasons);
317
+ const approver = waiting.approver!;
318
+ const title = waiting.p.title ?? `${action} needs ${approverLabel(approver)}`;
319
+ return approve(approver, { title, reasons: [waiting.p.id] });
320
+ };
321
+
322
+ const decide = (call: PolicyCall): Decision =>
323
+ authorize(
324
+ call.actor,
325
+ call.op.id,
326
+ call.target,
327
+ { input: call.input, ...(call.before !== undefined && { before: call.before }) },
328
+ call.run.approvals,
329
+ );
330
+
331
+ const start = (actor: Principal, workflow: string, input: unknown, approvals: readonly ApprovalState[] = []) =>
332
+ authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals);
333
+
334
+ const preview = (requests: readonly PreviewRequest[]): PreviewAnswer[] =>
335
+ requests.map(({ principal: actor, action, resource }) => {
336
+ const principal = directory.principal(actor);
337
+ const answer = cedar.isAuthorizedPartial({
338
+ ...requestOf(principal, action.id, resource, unknown),
339
+ schema,
340
+ validateRequest: false,
341
+ policies: { staticPolicies },
342
+ });
343
+ if (answer.type === "failure") throw new Error(answer.errors.map(messageOf).join("; "));
344
+ const { decision, mustBeDetermining, nontrivialResiduals, residuals } = answer.response;
345
+ if (decision === "allow") return { answer: "allow" };
346
+ if (decision === "deny") {
347
+ const { refusing, holds } = forbidsOf(mustBeDetermining, principal);
348
+ const first = holds[0];
349
+ if (refusing || !first) return { answer: "deny", ...(refusing && { rule: ruleOf(refusing) }) };
350
+ return { answer: "hold", rule: ruleOf(first.p), approver: first.approver! };
351
+ }
352
+ // Undecided: a residual forbid whose conditions fold to false cannot apply.
353
+ const open = policies.find((p) => nontrivialResiduals.includes(p.id) && mayApply(residuals[p.id]!));
354
+ return open ? { answer: "depends", rule: ruleOf(open) } : { answer: "allow" };
355
+ });
356
+
357
+ return Object.freeze(Object.assign(decide, { version, preview, start }));
358
+ }
359
+
360
+ /**
361
+ * The policy of the repo at `root`: its `policies/**\/*.cedar` over `resolved` and the people its
362
+ * data files declare (`readPeople`, `cedarPolicy`), or `allowAll` when it has none. With problems
363
+ * (`loadPolicies`, `validatePolicies`), each a printable line (`file:line:col: <policy id>:
364
+ * <message>`), a policy that refuses every call, start and preview naming the first: a repo never
365
+ * runs on part of its rules.
366
+ */
367
+ export function policyAt(
368
+ root: string | undefined,
369
+ resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
370
+ ): { policy: Policy; problems: string[] } {
371
+ const files = root === undefined ? [] : readPolicyFiles(root);
372
+ if (!files.length) return { policy: allowAll, problems: [] };
373
+ const { people } = readPeople(root);
374
+ const { policies, problems } = loadPolicies(files);
375
+ problems.push(...validatePolicies(resolved, policies, people));
376
+ if (!problems.length) return { policy: cedarPolicy(resolved, policies, people), problems: [] };
377
+ const lines = problems.map(({ policy, ...p }) =>
378
+ problemLine({ ...p, message: policy ? `${policy}: ${p.message}` : p.message }),
379
+ );
380
+ return { policy: denyAll(`The policies do not hold: ${lines[0]}`), problems: lines };
381
+ }
382
+
383
+ /** A policy as a preview names it. */
384
+ const ruleOf = (p: CedarPolicySource) => ({
385
+ id: p.id,
386
+ file: p.file,
387
+ ...(p.title !== undefined && { title: p.title }),
388
+ ...(p.reason !== undefined && { reason: p.reason }),
389
+ });
390
+
391
+ /** The first 12 hex digits of a SHA-256 of `value` as JSON. */
392
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
393
+
394
+ const uid = (type: string, id: string) => ({ type: `${NAMESPACE}::${type}`, id });
395
+ /** Every field of a context unknown: partial evaluation leaves what depends on it. */
396
+ const unknown = (names: string[]) =>
397
+ Object.fromEntries(names.map((name) => [name, { __extn: { fn: "unknown", arg: name } }]));
398
+
399
+ /** Whom `@hold(hold)` holds the call for, resolved for `principal`: undefined when it names no one. */
400
+ const holdOf = (hold: string, principal: Principal, people: readonly PeopleResource[]): Approver | undefined =>
401
+ hold === ON_BEHALF_OF ? principal.onBehalfOf : approverOf(hold, people);
402
+
403
+ interface Directory {
404
+ /** The principal with the groups and `onBehalfOf` it is declared with added. */
405
+ principal(actor: Principal): Principal;
406
+ /** What a request about `actor` needs: its entity, and that of whom it acts for. */
407
+ entities(actor: Principal): EntityJson[];
408
+ }
409
+
410
+ /** The people as Cedar entities, made once: each a `Principal` in its groups, with `onBehalfOf` when it acts for someone. */
411
+ function directoryOf(people: readonly PeopleResource[]): Directory {
412
+ const groups = new Set(groupNamesOf(people));
413
+ const principal = (actor: Principal): Principal => {
414
+ // An admitted principal (`admit`) carries its groups as the files say now: those are taken as
415
+ // they are. One without groups is given those declared when the policy was made.
416
+ const declared = actor.groups === undefined ? principalOf(actor.id, people) : undefined;
417
+ const onBehalfOf = actor.onBehalfOf ?? declared?.onBehalfOf;
418
+ return {
419
+ ...actor,
420
+ groups: [...new Set([...(actor.groups ?? []), ...(declared?.groups ?? [])])],
421
+ ...(onBehalfOf !== undefined && { onBehalfOf }),
422
+ };
423
+ };
424
+ const entityOf = (p: Principal): EntityJson => ({
425
+ uid: uid("Principal", p.id),
426
+ attrs: p.onBehalfOf === undefined ? {} : { onBehalfOf: { __entity: uid("Principal", p.onBehalfOf) } },
427
+ // Only the groups the schema enumerates: Cedar refuses any other.
428
+ parents: (p.groups ?? []).filter((g) => groups.has(g)).map((g) => uid("Group", g)),
429
+ });
430
+ const declared = new Map(principalIdsOf(people).map((id) => [id, entityOf(principal({ id }))]));
431
+ return {
432
+ principal,
433
+ entities: (actor) => {
434
+ const actingFor =
435
+ actor.onBehalfOf === undefined || actor.onBehalfOf === actor.id ? undefined : declared.get(actor.onBehalfOf);
436
+ return [entityOf(actor), ...(actingFor ? [actingFor] : [])];
437
+ },
438
+ };
439
+ }
440
+
441
+ /** False when one of a residual policy's conditions is already decided against it. */
442
+ const mayApply = ({ conditions }: PolicyJson): boolean =>
443
+ !conditions.some((c) => folded(c.body) === (c.kind === "unless"));
444
+
445
+ type Pair = { left: Expr; right: Expr };
446
+
447
+ /** A residual's value when its constants decide it, else undefined: partial evaluation leaves `unknown && false` as it is. */
448
+ function folded(expr: Expr): boolean | undefined {
449
+ const e = expr as {
450
+ Value?: unknown;
451
+ "!"?: { arg: Expr };
452
+ "&&"?: Pair;
453
+ "||"?: Pair;
454
+ "if-then-else"?: { if: Expr; then: Expr; else: Expr };
455
+ };
456
+ if ("Value" in e) return typeof e.Value === "boolean" ? e.Value : undefined;
457
+ if (e["!"]) {
458
+ const arg = folded(e["!"].arg);
459
+ return arg === undefined ? undefined : !arg;
460
+ }
461
+ const pair = e["&&"] ?? e["||"];
462
+ if (pair) {
463
+ const and = e["&&"] !== undefined;
464
+ const [l, r] = [folded(pair.left), folded(pair.right)];
465
+ // `and`: false decides it, true twice makes it; `or`: the other way round.
466
+ if (l === !and || r === !and) return !and;
467
+ return l === and && r === and ? and : undefined;
468
+ }
469
+ if (e["if-then-else"]) {
470
+ const { if: test, then, else: otherwise } = e["if-then-else"];
471
+ const t = folded(test);
472
+ return t === undefined ? undefined : folded(t ? then : otherwise);
473
+ }
474
+ return undefined;
475
+ }
476
+
477
+ /** A Cedar error in `text`, which starts at `start` in `file` (the file's start when it is the file), as a problem at its place. */
478
+ function problemOf(
479
+ error: DetailedError,
480
+ file: string,
481
+ text: string,
482
+ start: Place = { line: 1, column: 1 },
483
+ ): PolicyProblem {
484
+ const byte = error.sourceLocations?.[0]?.start;
485
+ if (byte === undefined) return { file, line: start.line, message: messageOf(error) };
486
+ // Cedar counts bytes; a line and column count characters.
487
+ const at = locator(text)(Buffer.from(text).subarray(0, byte).toString().length);
488
+ const place =
489
+ at.line === 1
490
+ ? { line: start.line, column: start.column + at.column - 1 }
491
+ : { line: start.line + at.line - 1, column: at.column };
492
+ return { file, ...place, message: messageOf(error) };
493
+ }