@sanoma-ai/workflows 0.2.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.
- package/README.md +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
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
|
+
}
|