@sanoma-ai/workflows 0.4.2 → 0.6.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 +18 -9
- package/dist/cedar.d.ts +23 -6
- package/dist/cedar.js +151 -44
- package/dist/cedar.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.js +15 -6
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +6 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/describe.d.ts +1 -1
- package/dist/describe.js +38 -4
- package/dist/describe.js.map +1 -1
- package/dist/host.d.ts +2 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +55 -3
- package/dist/op.js +6 -0
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +9 -1
- package/dist/policy.js +18 -7
- package/dist/policy.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +23 -11
- package/dist/resources.js.map +1 -1
- package/dist/scenario.d.ts +1 -1
- package/dist/scenario.js +4 -2
- package/dist/scenario.js.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/shared.d.ts +35 -2
- package/dist/shared.js +60 -3
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +72 -6
- package/dist/sites.js +277 -46
- package/dist/sites.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/src/cedar.ts +152 -41
- package/src/client.ts +17 -4
- package/src/config.ts +8 -2
- package/src/describe.ts +47 -4
- package/src/host.ts +2 -0
- package/src/index.ts +10 -0
- package/src/op.ts +56 -3
- package/src/policy.ts +29 -10
- package/src/resources.ts +26 -10
- package/src/scenario.ts +5 -2
- package/src/schema.ts +1 -1
- package/src/shared.ts +72 -3
- package/src/sites.ts +346 -49
- package/src/version.ts +4 -2
package/src/cedar.ts
CHANGED
|
@@ -18,25 +18,30 @@ import {
|
|
|
18
18
|
type PolicyCall,
|
|
19
19
|
type PreviewAnswer,
|
|
20
20
|
type PreviewRequest,
|
|
21
|
+
VISITOR_DENIED,
|
|
21
22
|
} from "./policy.ts";
|
|
22
|
-
import { readPeople } from "./resources.ts";
|
|
23
|
+
import { admit, readPeople } from "./resources.ts";
|
|
23
24
|
import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf } from "./schema.ts";
|
|
24
25
|
import {
|
|
26
|
+
ANONYMOUS,
|
|
25
27
|
approverLabel,
|
|
26
28
|
approverOf,
|
|
29
|
+
CALL_WORKFLOW,
|
|
27
30
|
DATA_VENDOR,
|
|
28
31
|
groupNamesOf,
|
|
32
|
+
isVisitor,
|
|
29
33
|
type PeopleResource,
|
|
30
34
|
principalIdsOf,
|
|
31
35
|
principalOf,
|
|
32
36
|
problemLine,
|
|
37
|
+
VISITORS,
|
|
33
38
|
WORKFLOW_ACTION,
|
|
34
39
|
} from "./shared.ts";
|
|
35
40
|
|
|
36
41
|
// A company's policies in Cedar (`policies/**/*.cedar`), decided with `@cedar-policy/cedar-wasm`
|
|
37
42
|
// against the schema of its resolved config (`schemaOf`). Allow is the default: the runtime adds
|
|
38
|
-
// the one `permit
|
|
39
|
-
// `@hold(...)` and a deny otherwise.
|
|
43
|
+
// the one `permit` for people and agents, so a company's policies are `forbid`s, each a hold when
|
|
44
|
+
// it carries `@hold(...)` and a deny otherwise, and the `permit`s that let visitors in.
|
|
40
45
|
|
|
41
46
|
/** Where a company's policies are, under its root. */
|
|
42
47
|
export const POLICIES_DIR = "policies";
|
|
@@ -58,6 +63,8 @@ export interface CedarPolicySource {
|
|
|
58
63
|
title?: string;
|
|
59
64
|
/** `@reason(...)`: the message of its deny. */
|
|
60
65
|
reason?: string;
|
|
66
|
+
/** Whom a `permit` is for, when its principal is exactly `in Sanoma::Group::"visitors"` or `== Sanoma::Principal::"anonymous"`. */
|
|
67
|
+
scope?: typeof VISITORS | typeof ANONYMOUS;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/** What is wrong in the policies, at a file, line and column when Cedar says where. */
|
|
@@ -70,7 +77,10 @@ export interface PolicyProblem {
|
|
|
70
77
|
message: string;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The runtime's own policy: allow whatever no `forbid` refuses. A visitor's start or direct call
|
|
82
|
+
* is asked of the company's permits first (`cedarPolicy`): the runtime's permit leaves visitors out there.
|
|
83
|
+
*/
|
|
74
84
|
const ALLOW_ID = "allow-by-default";
|
|
75
85
|
const ALLOW = "permit (principal, action, resource);";
|
|
76
86
|
|
|
@@ -132,7 +142,7 @@ export function loadPolicies(files: readonly { file: string; text: string }[]):
|
|
|
132
142
|
problems.push(...answer.errors.map((e) => problemOf(e, file, part, start)));
|
|
133
143
|
return;
|
|
134
144
|
}
|
|
135
|
-
const { effect, annotations = {} } = answer.json;
|
|
145
|
+
const { effect, principal, annotations = {} } = answer.json;
|
|
136
146
|
const id = annotations.id ?? `${file}#${n + 1}`;
|
|
137
147
|
if (ids.has(id)) {
|
|
138
148
|
const whose = id === ALLOW_ID ? "the runtime's own policy's" : "another policy's";
|
|
@@ -149,16 +159,28 @@ export function loadPolicies(files: readonly { file: string; text: string }[]):
|
|
|
149
159
|
...(annotations.hold !== undefined && { hold: annotations.hold }),
|
|
150
160
|
...(annotations.title !== undefined && { title: annotations.title }),
|
|
151
161
|
...(annotations.reason !== undefined && { reason: annotations.reason }),
|
|
162
|
+
...scopeOf(principal),
|
|
152
163
|
});
|
|
153
164
|
});
|
|
154
165
|
}
|
|
155
166
|
return { policies, problems };
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
/** A permit's `scope`: whom its principal names, when that is exactly the visitors or `anonymous`. */
|
|
170
|
+
function scopeOf(principal: PolicyJson["principal"]): Pick<CedarPolicySource, "scope"> {
|
|
171
|
+
if (!("entity" in principal) || !("id" in principal.entity)) return {};
|
|
172
|
+
const { op, entity } = principal;
|
|
173
|
+
const is = ({ type, id }: { type: string; id: string }) => entity.type === type && entity.id === id;
|
|
174
|
+
if (op === "in" && is(uid("Group", VISITORS))) return { scope: VISITORS };
|
|
175
|
+
if (op === "==" && is(uid("Principal", ANONYMOUS))) return { scope: ANONYMOUS };
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
|
|
158
179
|
/**
|
|
159
180
|
* The policies checked against the schema of the resolved config (`validate`, strict), and what
|
|
160
|
-
* Cedar cannot check: a `permit`
|
|
161
|
-
*
|
|
181
|
+
* Cedar cannot check: a `permit` for anyone but the visitors or `anonymous` (the runtime's is the
|
|
182
|
+
* only one for people and agents), and a `@hold` naming neither a declared group, nor a declared
|
|
183
|
+
* person, nor `on-behalf-of`. In the order written, each at its
|
|
162
184
|
* file, line and column, once per place: a policy scoped to every action fails the same way for each.
|
|
163
185
|
*/
|
|
164
186
|
export function validatePolicies(
|
|
@@ -187,7 +209,16 @@ export function validatePolicies(
|
|
|
187
209
|
.toSorted((a, b) => a.line! - b.line! || (a.column ?? 0) - (b.column ?? 0));
|
|
188
210
|
const ours: PolicyProblem[] = [];
|
|
189
211
|
if (p.effect === "permit") {
|
|
190
|
-
|
|
212
|
+
if (p.scope === undefined) {
|
|
213
|
+
ours.push({
|
|
214
|
+
...place,
|
|
215
|
+
message:
|
|
216
|
+
'a permit is allowed only for visitors: its principal must be `in Sanoma::Group::"visitors"` or ' +
|
|
217
|
+
'`== Sanoma::Principal::"anonymous"`; for people and agents every call no forbid refuses is allowed already',
|
|
218
|
+
});
|
|
219
|
+
} else if (p.hold !== undefined) {
|
|
220
|
+
ours.push({ ...place, message: "a permit holds nothing: write the @hold on a forbid" });
|
|
221
|
+
}
|
|
191
222
|
} else if (p.hold !== undefined && p.hold !== ON_BEHALF_OF && approverOf(p.hold, people) === undefined) {
|
|
192
223
|
ours.push({ ...place, message: `@hold("${p.hold}") names no declared group or person, nor "${ON_BEHALF_OF}"` });
|
|
193
224
|
}
|
|
@@ -197,9 +228,14 @@ export function validatePolicies(
|
|
|
197
228
|
|
|
198
229
|
/**
|
|
199
230
|
* The policy a repo's Cedar files make, the runtime's `permit` added; its `version` a hash of the
|
|
200
|
-
* policies' ids and text.
|
|
201
|
-
*
|
|
231
|
+
* policies' ids and text. The runtime's permit leaves visitors out (`isVisitor`) of a run's start,
|
|
232
|
+
* a direct call (a call of the `call` workflow) and a preview not `inRun`: those are asked of the
|
|
233
|
+
* company's permits alone first. Any other call in a run is decided as anyone's, so a visitor let
|
|
234
|
+
* start a run makes its calls, forbids holding still. It decides each call with
|
|
235
|
+
* `statefulIsAuthorized` over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
|
|
202
236
|
*
|
|
237
|
+
* - a visitor's start or direct call no company `permit` allows: `deny`, `VISITOR_DENIED`, never
|
|
238
|
+
* held, so a visitor cannot ask anyone for what no policy lets it do;
|
|
203
239
|
* - allow: `allow`, with the policies that allowed as `reasons`;
|
|
204
240
|
* - deny, a determining `forbid` without `@hold`: `deny` with its `@reason`, or "Refused by <id>";
|
|
205
241
|
* - deny by `@hold` forbids only: each lifted once an approval addressed to its approver and
|
|
@@ -221,14 +257,22 @@ export function cedarPolicy(
|
|
|
221
257
|
const cedar = cedarWasm();
|
|
222
258
|
const schema = schemaOf(resolved, people);
|
|
223
259
|
const staticPolicies = { ...Object.fromEntries(policies.map((p) => [p.id, p.text])), [ALLOW_ID]: ALLOW };
|
|
260
|
+
// What lets a visitor in at a start or a direct call: the company's permits alone, the forbids aside.
|
|
261
|
+
const permits = Object.fromEntries(policies.filter((p) => p.effect === "permit").map((p) => [p.id, p.text]));
|
|
224
262
|
const version = hash(staticPolicies);
|
|
225
|
-
// Cedar caches both per thread, by name: one for these policies over this schema.
|
|
263
|
+
// Cedar caches both per thread, by name: one for these policies over this schema, and its permits.
|
|
226
264
|
const set = `sanoma-${hash({ staticPolicies, schema })}`;
|
|
227
|
-
|
|
265
|
+
const permitsSet = `${set}-permits`;
|
|
266
|
+
for (const answer of [
|
|
267
|
+
cedar.preparsePolicySet(set, { staticPolicies }),
|
|
268
|
+
cedar.preparsePolicySet(permitsSet, { staticPolicies: permits }),
|
|
269
|
+
cedar.preparseSchema(set, schema),
|
|
270
|
+
]) {
|
|
228
271
|
if (answer.type === "failure") {
|
|
229
272
|
throw new Error(`The Cedar policies do not parse: ${answer.errors.map(messageOf).join("; ")}`);
|
|
230
273
|
}
|
|
231
274
|
}
|
|
275
|
+
const hasPermits = Object.keys(permits).length > 0;
|
|
232
276
|
const directory = directoryOf(people);
|
|
233
277
|
|
|
234
278
|
/**
|
|
@@ -270,33 +314,50 @@ export function cedarPolicy(
|
|
|
270
314
|
};
|
|
271
315
|
};
|
|
272
316
|
|
|
273
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* May `actor` take `action` on `target` with `context`, given the run's `approvals` so far; at a
|
|
319
|
+
* start or a direct call (`atStart`), a visitor only where a company permit lets it in.
|
|
320
|
+
*/
|
|
274
321
|
const authorize = (
|
|
275
322
|
actor: Principal,
|
|
276
323
|
action: string,
|
|
277
324
|
target: string | undefined,
|
|
278
325
|
context: Record<string, unknown>,
|
|
279
326
|
approvals: PolicyCall["run"]["approvals"],
|
|
327
|
+
atStart: boolean,
|
|
280
328
|
): Decision => {
|
|
281
329
|
const principal = directory.principal(actor);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
/** The answer of the policy set `id`. */
|
|
331
|
+
const ask = (id: string) => {
|
|
332
|
+
const check = (asked: Record<string, unknown>) =>
|
|
333
|
+
cedar.statefulIsAuthorized({
|
|
334
|
+
...requestOf(principal, action, target, () => asked),
|
|
335
|
+
preparsedSchemaName: set,
|
|
336
|
+
validateRequest: true,
|
|
337
|
+
preparsedPolicySetId: id,
|
|
338
|
+
});
|
|
339
|
+
const answer = check(context);
|
|
340
|
+
// A record saved under fields since changed does not fit the schema as `before`. When the rest
|
|
341
|
+
// of the call does, it is asked again without the schema (the context is plain values), the
|
|
342
|
+
// action's groups given as entities: a policy reading a field the record lacks fails, so denies.
|
|
343
|
+
const { before, ...rest } = context;
|
|
344
|
+
if (answer.type === "success" || before === undefined || check(rest).type === "failure") return answer;
|
|
295
345
|
const request = requestOf(principal, action, target, () => context);
|
|
296
346
|
const groups = schema[NAMESPACE]?.actions[action]?.memberOf ?? [];
|
|
297
347
|
request.entities.push({ uid: request.action, attrs: {}, parents: groups.map((g) => uid("Action", g.id)) });
|
|
298
|
-
|
|
348
|
+
return cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: id });
|
|
349
|
+
};
|
|
350
|
+
// Refused, never held: a visitor cannot ask anyone for what no policy lets it do.
|
|
351
|
+
if (atStart && isVisitor(principal)) {
|
|
352
|
+
const permitted = hasPermits ? ask(permitsSet) : undefined;
|
|
353
|
+
// A request the schema refuses is told below, as anyone's; a permit that fails, by its id.
|
|
354
|
+
if (permitted?.type !== "failure" && permitted?.response.decision !== "allow") {
|
|
355
|
+
const failed = permitted?.response.diagnostics.errors[0];
|
|
356
|
+
if (failed) return deny(`${failed.policyId} failed: ${messageOf(failed.error)}`, [failed.policyId]);
|
|
357
|
+
return deny(VISITOR_DENIED);
|
|
358
|
+
}
|
|
299
359
|
}
|
|
360
|
+
const answer = ask(set);
|
|
300
361
|
// A request the schema refuses (an input Cedar cannot hold) is denied, not an error of the call.
|
|
301
362
|
if (answer.type === "failure")
|
|
302
363
|
return deny(`The call does not fit the Cedar schema: ${answer.errors.map(messageOf).join("; ")}`);
|
|
@@ -326,22 +387,34 @@ export function cedarPolicy(
|
|
|
326
387
|
call.target,
|
|
327
388
|
{ input: call.input, ...(call.before !== undefined && { before: call.before }) },
|
|
328
389
|
call.run.approvals,
|
|
390
|
+
call.run.workflow === CALL_WORKFLOW,
|
|
329
391
|
);
|
|
330
392
|
|
|
331
393
|
const start = (actor: Principal, workflow: string, input: unknown, approvals: readonly ApprovalState[] = []) =>
|
|
332
|
-
authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals);
|
|
394
|
+
authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals, true);
|
|
333
395
|
|
|
334
396
|
const preview = (requests: readonly PreviewRequest[]): PreviewAnswer[] =>
|
|
335
|
-
requests.map(({ principal: actor, action, resource }) => {
|
|
397
|
+
requests.map(({ principal: actor, action, resource, inRun }) => {
|
|
336
398
|
const principal = directory.principal(actor);
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
399
|
+
const partial = (asked: Record<string, string>) => {
|
|
400
|
+
const answer = cedar.isAuthorizedPartial({
|
|
401
|
+
...requestOf(principal, action.id, resource, unknown),
|
|
402
|
+
schema,
|
|
403
|
+
validateRequest: false,
|
|
404
|
+
policies: { staticPolicies: asked },
|
|
405
|
+
});
|
|
406
|
+
if (answer.type === "failure") throw new Error(answer.errors.map(messageOf).join("; "));
|
|
407
|
+
return answer.response;
|
|
408
|
+
};
|
|
409
|
+
if (!inRun && isVisitor(principal)) {
|
|
410
|
+
const gate = hasPermits ? partial(permits) : undefined;
|
|
411
|
+
if (gate?.decision !== "allow") {
|
|
412
|
+
// A permit the input decides: the answer depends on it, whatever the rest says.
|
|
413
|
+
const open = policies.find((p) => gate?.nontrivialResiduals.includes(p.id));
|
|
414
|
+
return open ? { answer: "depends", rule: ruleOf(open) } : { answer: "deny" };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const { decision, mustBeDetermining, nontrivialResiduals, residuals } = partial(staticPolicies);
|
|
345
418
|
if (decision === "allow") return { answer: "allow" };
|
|
346
419
|
if (decision === "deny") {
|
|
347
420
|
const { refusing, holds } = forbidsOf(mustBeDetermining, principal);
|
|
@@ -357,23 +430,59 @@ export function cedarPolicy(
|
|
|
357
430
|
return Object.freeze(Object.assign(decide, { version, preview, start }));
|
|
358
431
|
}
|
|
359
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Whether a company `permit` could let a visitor take `action`, in its action group `group`
|
|
435
|
+
* (`start`, or its operation's `effect:<effect>`): asked of the permits whose `scope` is in
|
|
436
|
+
* `scopes`, each with its principal taken as its scope says and its `when` and `unless` left out,
|
|
437
|
+
* on the workflow a start names, or on any target. False means none can apply, so a visitor's
|
|
438
|
+
* start or direct call of it is refused whatever its input (`VISITOR_DENIED`).
|
|
439
|
+
*/
|
|
440
|
+
export function visitorPermits(
|
|
441
|
+
policies: readonly CedarPolicySource[],
|
|
442
|
+
): (scopes: ReadonlySet<string>, action: string, group: string) => boolean {
|
|
443
|
+
const cedar = cedarWasm();
|
|
444
|
+
const permits = policies.flatMap(({ id, effect, scope, text }) => {
|
|
445
|
+
if (effect !== "permit" || scope === undefined) return [];
|
|
446
|
+
const answer = cedar.policyToJson(text);
|
|
447
|
+
if (answer.type !== "success") return [];
|
|
448
|
+
const json: PolicyJson = { ...answer.json, principal: { op: "All" }, conditions: [] };
|
|
449
|
+
return [{ id, scope, json }];
|
|
450
|
+
});
|
|
451
|
+
return (scopes, action, group) => {
|
|
452
|
+
const asked = permits.filter((p) => scopes.has(p.scope));
|
|
453
|
+
if (!asked.length) return false;
|
|
454
|
+
const uidOf = uid("Action", action);
|
|
455
|
+
const answer = cedar.isAuthorizedPartial({
|
|
456
|
+
principal: uid("Principal", ANONYMOUS),
|
|
457
|
+
action: uidOf,
|
|
458
|
+
// An operation's target is the call's: unknown, so a permit naming one may apply.
|
|
459
|
+
resource: action.startsWith(WORKFLOW_ACTION) ? uid("Workflow", action.slice(WORKFLOW_ACTION.length)) : null,
|
|
460
|
+
context: {},
|
|
461
|
+
entities: [{ uid: uidOf, attrs: {}, parents: [uid("Action", group)] }],
|
|
462
|
+
policies: { staticPolicies: Object.fromEntries(asked.map((p) => [p.id, p.json])) },
|
|
463
|
+
});
|
|
464
|
+
// A request Cedar cannot evaluate is not a refusal this can be sure of.
|
|
465
|
+
return answer.type === "failure" || answer.response.decision !== "deny";
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
360
469
|
/**
|
|
361
470
|
* The policy of the repo at `root`: its `policies/**\/*.cedar` over `resolved` and the people its
|
|
362
471
|
* data files declare (`readPeople`, `cedarPolicy`), or `allowAll` when it has none. With problems
|
|
363
472
|
* (`loadPolicies`, `validatePolicies`), each a printable line (`file:line:col: <policy id>:
|
|
364
473
|
* <message>`), a policy that refuses every call, start and preview naming the first: a repo never
|
|
365
|
-
* runs on part of its rules.
|
|
474
|
+
* runs on part of its rules. `policies` are what it is made from: undefined while they have problems.
|
|
366
475
|
*/
|
|
367
476
|
export function policyAt(
|
|
368
477
|
root: string | undefined,
|
|
369
478
|
resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
|
|
370
|
-
): { policy: Policy; problems: string[] } {
|
|
479
|
+
): { policy: Policy; policies?: CedarPolicySource[]; problems: string[] } {
|
|
371
480
|
const files = root === undefined ? [] : readPolicyFiles(root);
|
|
372
|
-
if (!files.length) return { policy: allowAll, problems: [] };
|
|
481
|
+
if (!files.length) return { policy: allowAll, policies: [], problems: [] };
|
|
373
482
|
const { people } = readPeople(root);
|
|
374
483
|
const { policies, problems } = loadPolicies(files);
|
|
375
484
|
problems.push(...validatePolicies(resolved, policies, people));
|
|
376
|
-
if (!problems.length) return { policy: cedarPolicy(resolved, policies, people), problems: [] };
|
|
485
|
+
if (!problems.length) return { policy: cedarPolicy(resolved, policies, people), policies, problems: [] };
|
|
377
486
|
const lines = problems.map(({ policy, ...p }) =>
|
|
378
487
|
problemLine({ ...p, message: policy ? `${policy}: ${p.message}` : p.message }),
|
|
379
488
|
);
|
|
@@ -411,6 +520,8 @@ interface Directory {
|
|
|
411
520
|
function directoryOf(people: readonly PeopleResource[]): Directory {
|
|
412
521
|
const groups = new Set(groupNamesOf(people));
|
|
413
522
|
const principal = (actor: Principal): Principal => {
|
|
523
|
+
// A visitor is told by its id, as `admit` takes it: never in a declared group.
|
|
524
|
+
if (isVisitor(actor)) return admit(actor, { people, problems: [] });
|
|
414
525
|
// An admitted principal (`admit`) carries its groups as the files say now: those are taken as
|
|
415
526
|
// they are. One without groups is given those declared when the policy was made.
|
|
416
527
|
const declared = actor.groups === undefined ? principalOf(actor.id, people) : undefined;
|
package/src/client.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ErrorInfo,
|
|
20
20
|
errorCode,
|
|
21
21
|
errorInfo,
|
|
22
|
+
NotAdmittedError,
|
|
22
23
|
parseOrThrow,
|
|
23
24
|
PolicyDeniedError,
|
|
24
25
|
SanomaError,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
DATA_VENDOR,
|
|
36
37
|
errorMessage,
|
|
37
38
|
isEnded,
|
|
39
|
+
isVisitor,
|
|
38
40
|
refusalOf,
|
|
39
41
|
type RunStatus,
|
|
40
42
|
WORKFLOW_ACTION,
|
|
@@ -263,7 +265,8 @@ export class SanomaClient {
|
|
|
263
265
|
/**
|
|
264
266
|
* The policy's answer to starting a run of `workflow` as `startedBy` (`decideStart`), for a
|
|
265
267
|
* workflow of the config's own: a deny throws `policy_denied`; a hold is what the run waits on
|
|
266
|
-
* before its body. A run `runId` names already is returned as it is, whatever the policy says now.
|
|
268
|
+
* before its body. A run `runId` names already is returned as it is, whatever the policy says now, but to a visitor.
|
|
269
|
+
* A visitor (`isVisitor`) starts no built-in but `call`, whose calls the policy decides as direct ones.
|
|
267
270
|
*/
|
|
268
271
|
private async startHold(
|
|
269
272
|
workflow: string,
|
|
@@ -272,8 +275,17 @@ export class SanomaClient {
|
|
|
272
275
|
runId: string | undefined,
|
|
273
276
|
): Promise<StartHold | undefined> {
|
|
274
277
|
const { policy } = this.config;
|
|
275
|
-
|
|
276
|
-
|
|
278
|
+
const visitor = isVisitor(startedBy);
|
|
279
|
+
const builtIn = BUILT_IN_WORKFLOWS.has(workflow);
|
|
280
|
+
if (visitor && builtIn && workflow !== CALL_WORKFLOW) {
|
|
281
|
+
throw new PolicyDeniedError(
|
|
282
|
+
`${WORKFLOW_ACTION}${workflow}`,
|
|
283
|
+
"A visitor may not start the runtime's own workflows",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (builtIn || (policy === allowAll && !visitor)) return undefined;
|
|
287
|
+
// A visitor's start is decided whatever run its id names: a visitor shares an id (`anonymous`) with others.
|
|
288
|
+
if (!visitor && runId !== undefined && (await this.row(runId))) return undefined;
|
|
277
289
|
const decision = decideStart(policy, workflow, input, startedBy);
|
|
278
290
|
if (decision.kind === "allow") return undefined;
|
|
279
291
|
if (decision.kind === "deny") throw new PolicyDeniedError(`${WORKFLOW_ACTION}${workflow}`, decision.reason);
|
|
@@ -805,9 +817,10 @@ export class SanomaClient {
|
|
|
805
817
|
return admit(principal, readPeople(this.config.root));
|
|
806
818
|
}
|
|
807
819
|
|
|
808
|
-
/** `options.by`, who sets or clears a credential, admitted (`admitted`): their id. */
|
|
820
|
+
/** `options.by`, who sets or clears a credential, admitted (`admitted`): their id; never a visitor's. */
|
|
809
821
|
private async admittedBy(options: { by: string }): Promise<string> {
|
|
810
822
|
const by = parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
|
|
823
|
+
if (isVisitor({ id: by })) throw new NotAdmittedError(by);
|
|
811
824
|
return (await this.admitted({ id: by })).id;
|
|
812
825
|
}
|
|
813
826
|
|
package/src/config.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
setupOf,
|
|
33
33
|
VENDOR,
|
|
34
34
|
} from "./op.ts";
|
|
35
|
-
import { policyAt } from "./cedar.ts";
|
|
35
|
+
import { type CedarPolicySource, policyAt } from "./cedar.ts";
|
|
36
36
|
import type { Policy } from "./policy.ts";
|
|
37
37
|
import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
38
38
|
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
|
|
@@ -120,6 +120,8 @@ export interface ResolvedConfig {
|
|
|
120
120
|
driversByVendor: Map<string, Driver>;
|
|
121
121
|
/** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
|
|
122
122
|
fakes: Map<string, () => Fake<any, any>>;
|
|
123
|
+
/** The makers of the login halves the connectors attach, by vendor id in `connectors` order: the app makes one per public site that names it. */
|
|
124
|
+
logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
|
|
123
125
|
/** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
|
|
124
126
|
scenarios?: URL;
|
|
125
127
|
/** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
|
|
@@ -145,6 +147,8 @@ export interface ResolvedConfig {
|
|
|
145
147
|
* (`policyAt`), `allowAll` without any, and one that refuses everything while they have problems.
|
|
146
148
|
*/
|
|
147
149
|
policy: Policy;
|
|
150
|
+
/** The policies `policy` is made from (`loadPolicies`), none without any; undefined while they have problems. */
|
|
151
|
+
policies?: readonly CedarPolicySource[];
|
|
148
152
|
/** What is wrong in the policies, one printable line each (`file:line:col: <policy id>: <message>`). */
|
|
149
153
|
policyProblems: string[];
|
|
150
154
|
/** The config's `ledger`, when it gives one; the worker and the client otherwise make `postgresLedger` on `databaseUrl` (`ownedLedger`). */
|
|
@@ -278,7 +282,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
278
282
|
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
279
283
|
const call = callWorkflow(ops, drivers, types);
|
|
280
284
|
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
281
|
-
const { policy, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
285
|
+
const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
282
286
|
return {
|
|
283
287
|
appName,
|
|
284
288
|
databaseUrl,
|
|
@@ -289,6 +293,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
289
293
|
drivers,
|
|
290
294
|
driversByVendor,
|
|
291
295
|
fakes,
|
|
296
|
+
logins: makersOf(config.connectors, "login"),
|
|
292
297
|
...(scenarios && { scenarios }),
|
|
293
298
|
types,
|
|
294
299
|
steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
@@ -296,6 +301,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
296
301
|
...(dataStore && { dataStore }),
|
|
297
302
|
workflows: names,
|
|
298
303
|
policy,
|
|
304
|
+
...(policies && { policies }),
|
|
299
305
|
policyProblems,
|
|
300
306
|
...(ledger !== undefined && { ledger }),
|
|
301
307
|
...(root !== undefined && { root }),
|
package/src/describe.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import type { z } from "zod";
|
|
2
3
|
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
3
4
|
import { type Field, recordSchemaOf } from "./collection.ts";
|
|
@@ -8,7 +9,7 @@ import { lintWorkflow } from "./lint.ts";
|
|
|
8
9
|
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
9
10
|
import { type Outline, outlineWithSource } from "./outline.ts";
|
|
10
11
|
import { allowAll, policyOpOf } from "./policy.ts";
|
|
11
|
-
import { type ResourceFields, resourceTypesOf } from "./resource.ts";
|
|
12
|
+
import { type Resource, type ResourceFields, resourceTypesOf, valueAt } from "./resource.ts";
|
|
12
13
|
import {
|
|
13
14
|
type DataFiles,
|
|
14
15
|
type DeclaredCollection,
|
|
@@ -62,7 +63,7 @@ export {
|
|
|
62
63
|
type Rebind,
|
|
63
64
|
} from "./lockfile.ts";
|
|
64
65
|
// What the sites call, read from their source: a workflow's Used by.
|
|
65
|
-
export { readSites, type SiteCall, type SiteEntry } from "./sites.ts";
|
|
66
|
+
export { readSites, type SiteCall, type SiteEntry, type SitePage, siteContentPages, siteLocales } from "./sites.ts";
|
|
66
67
|
// The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
|
|
67
68
|
export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
|
|
68
69
|
// The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
|
|
@@ -303,6 +304,12 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
303
304
|
});
|
|
304
305
|
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
305
306
|
addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
|
|
307
|
+
const sites = readSites(
|
|
308
|
+
resolved.root,
|
|
309
|
+
{ workflows, collections, ops },
|
|
310
|
+
{ vendors: new Set(Object.keys(vendors)), logins: new Set(resolved.logins.keys()) },
|
|
311
|
+
resolved.policies,
|
|
312
|
+
);
|
|
306
313
|
|
|
307
314
|
return {
|
|
308
315
|
appName: resolved.appName,
|
|
@@ -314,16 +321,52 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
314
321
|
collections,
|
|
315
322
|
resources: dataFiles.resources,
|
|
316
323
|
orphans: dataFiles.orphans,
|
|
317
|
-
problems: dataFiles.problems,
|
|
324
|
+
problems: [...dataFiles.problems, ...domainProblems(resolved.root, dataFiles.resources, resources, sites, read)],
|
|
318
325
|
policy: {
|
|
319
326
|
defined: resolved.policy !== allowAll,
|
|
320
327
|
...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
|
|
321
328
|
...(resolved.policyProblems.length ? { problems: resolved.policyProblems } : {}),
|
|
322
329
|
},
|
|
323
|
-
sites
|
|
330
|
+
sites,
|
|
324
331
|
};
|
|
325
332
|
}
|
|
326
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
|
|
336
|
+
* site, and each domain it gives more than one site, at the host's declaration: the app serves
|
|
337
|
+
* only a public site at a domain, and one site at each.
|
|
338
|
+
*/
|
|
339
|
+
function domainProblems(
|
|
340
|
+
root: string | undefined,
|
|
341
|
+
declared: readonly DeclaredResource[],
|
|
342
|
+
types: ReadonlyMap<string, Resource>,
|
|
343
|
+
sites: readonly SiteEntry[],
|
|
344
|
+
read: (path: string) => SourceFile | string,
|
|
345
|
+
): ResourceProblem[] {
|
|
346
|
+
const isPublic = new Set(sites.filter((site) => site.public).map((site) => site.name));
|
|
347
|
+
const problems: ResourceProblem[] = [];
|
|
348
|
+
for (const resource of declared) {
|
|
349
|
+
const path = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs.domains;
|
|
350
|
+
const domains = (path === undefined ? {} : (valueAt(resource.desired, path) ?? {})) as Record<string, unknown>;
|
|
351
|
+
const messages = Object.keys(domains)
|
|
352
|
+
.filter((name) => !isPublic.has(name))
|
|
353
|
+
.map((name) => `domains names "${name}", which is not a public site (defineSite({ public: true }))`);
|
|
354
|
+
const named = Object.entries(domains).filter(([, value]) => typeof value === "string");
|
|
355
|
+
// A host name's case is not its own: `publicHosts` compares them lowercased.
|
|
356
|
+
for (const [domain, entries] of Map.groupBy(named, ([, value]) => (value as string).toLowerCase())) {
|
|
357
|
+
const names = entries.map(([name]) => name);
|
|
358
|
+
if (names.length < 2) continue;
|
|
359
|
+
const others = `${names.length === 2 ? "both " : ""}${names.slice(0, -1).join(", ")}`;
|
|
360
|
+
messages.push(`${domain} is the domain of ${others} and ${names.at(-1)}`);
|
|
361
|
+
}
|
|
362
|
+
if (!messages.length || root === undefined) continue;
|
|
363
|
+
const file = read(join(root, resource.file));
|
|
364
|
+
const at = typeof file === "string" ? {} : file.at(resource.span[0]);
|
|
365
|
+
for (const message of messages) problems.push({ file: resource.file, ...at, message });
|
|
366
|
+
}
|
|
367
|
+
return problems;
|
|
368
|
+
}
|
|
369
|
+
|
|
327
370
|
/**
|
|
328
371
|
* A site's `api` names each workflow `apiNameOf(name)` beside the vendors' operations and the
|
|
329
372
|
* collections (`api.data`), so a workflow whose name comes out as another's, a vendor's id or
|
package/src/host.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface HostSpec {
|
|
|
42
42
|
secrets: string;
|
|
43
43
|
/** The port the app listens on. */
|
|
44
44
|
port: string;
|
|
45
|
+
/** A public site's domain, by site name: the app serves each site at the root of its domain (`SANOMA_DOMAINS`). */
|
|
46
|
+
domains?: string;
|
|
45
47
|
};
|
|
46
48
|
/**
|
|
47
49
|
* The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export {
|
|
2
2
|
type CallContext,
|
|
3
|
+
CLIENT_IP_HEADER,
|
|
3
4
|
type Connector,
|
|
4
5
|
type ConnectorSetup,
|
|
5
6
|
type ConnectorVendor,
|
|
@@ -11,6 +12,8 @@ export {
|
|
|
11
12
|
type Effect,
|
|
12
13
|
isOp,
|
|
13
14
|
isSvgElement,
|
|
15
|
+
type LoginContext,
|
|
16
|
+
type LoginHalf,
|
|
14
17
|
type Op,
|
|
15
18
|
type OpSpec,
|
|
16
19
|
type ResourceGroup,
|
|
@@ -88,6 +91,7 @@ export {
|
|
|
88
91
|
type PreviewAnswer,
|
|
89
92
|
type PreviewRequest,
|
|
90
93
|
type RecordedDecision,
|
|
94
|
+
VISITOR_DENIED,
|
|
91
95
|
} from "./policy.ts";
|
|
92
96
|
export {
|
|
93
97
|
cedarPolicy,
|
|
@@ -148,6 +152,8 @@ export { dbosClient } from "./db.ts";
|
|
|
148
152
|
// Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
|
|
149
153
|
export {
|
|
150
154
|
AGENTS,
|
|
155
|
+
ANONYMOUS,
|
|
156
|
+
ANONYMOUS_PRINCIPAL,
|
|
151
157
|
approverLabel,
|
|
152
158
|
approverOf,
|
|
153
159
|
declaresPeople,
|
|
@@ -161,11 +167,15 @@ export {
|
|
|
161
167
|
errorMessage,
|
|
162
168
|
isEnded,
|
|
163
169
|
isLoopback,
|
|
170
|
+
isVisitor,
|
|
164
171
|
mayDecide,
|
|
165
172
|
problemAt,
|
|
166
173
|
problemLine,
|
|
167
174
|
RUN_STATUSES,
|
|
168
175
|
type RunStatus,
|
|
176
|
+
VISITORS,
|
|
177
|
+
visitorPrincipal,
|
|
178
|
+
warningLine,
|
|
169
179
|
} from "./shared.ts";
|
|
170
180
|
// describeConfig, with the outline and every problem lintWorkflow finds, lives at
|
|
171
181
|
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from what runs. The worker reads
|