@sanoma-ai/workflows 0.10.3 → 0.10.7
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/CLOUD-IDENTITY.md +128 -17
- package/README.md +15 -4
- package/dist/apply.d.ts +11 -4
- package/dist/apply.js +126 -35
- package/dist/apply.js.map +1 -1
- package/dist/call.js +1 -1
- package/dist/call.js.map +1 -1
- package/dist/cloud-identity.d.ts +5 -1
- package/dist/cloud-identity.js +13 -77
- package/dist/cloud-identity.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -2
- package/dist/config.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/dns.d.ts +20 -0
- package/dist/dns.js +26 -0
- package/dist/dns.js.map +1 -0
- package/dist/drift.d.ts +4 -2
- package/dist/drift.js +13 -4
- package/dist/drift.js.map +1 -1
- package/dist/identity-broker.d.ts +14 -0
- package/dist/identity-broker.js +84 -0
- package/dist/identity-broker.js.map +1 -0
- package/dist/identity-provisioning.d.ts +29 -0
- package/dist/identity-provisioning.js +178 -0
- package/dist/identity-provisioning.js.map +1 -0
- package/dist/identity-service.d.ts +25 -0
- package/dist/identity-service.js +148 -0
- package/dist/identity-service.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +15 -8
- package/dist/op.js.map +1 -1
- package/dist/provisioning-identity.d.ts +27 -0
- package/dist/provisioning-identity.js +81 -0
- package/dist/provisioning-identity.js.map +1 -0
- package/dist/resource.d.ts +67 -6
- package/dist/resource.js +95 -9
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +3 -3
- package/dist/resources.js +6 -5
- package/dist/resources.js.map +1 -1
- package/dist/runtime-ops.d.ts +60 -1
- package/dist/runtime-ops.js +73 -1
- package/dist/runtime-ops.js.map +1 -1
- package/dist/scenario.d.ts +6 -3
- package/dist/scenario.js +13 -1
- package/dist/scenario.js.map +1 -1
- package/oxlint.json +1 -0
- package/package.json +5 -1
- package/src/apply.ts +165 -47
- package/src/call.ts +2 -2
- package/src/cloud-identity.ts +16 -76
- package/src/config.ts +6 -2
- package/src/define.ts +10 -2
- package/src/dns.ts +29 -0
- package/src/drift.ts +13 -4
- package/src/identity-broker.ts +94 -0
- package/src/identity-provisioning.ts +208 -0
- package/src/identity-service.ts +179 -0
- package/src/index.ts +2 -0
- package/src/op.ts +13 -3
- package/src/provisioning-identity.ts +106 -0
- package/src/resource.ts +147 -15
- package/src/resources.ts +6 -5
- package/src/runtime-ops.ts +79 -0
- package/src/scenario.ts +24 -6
package/src/resource.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ConnectionRef } from "./connection.ts";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { checkDatabase, type DatabaseSpec } from "./database.ts";
|
|
4
4
|
import { isRunControlError } from "./errors.ts";
|
|
5
|
-
import { checkHost, type HostSpec } from "./host.ts";
|
|
5
|
+
import { checkHost, hasPath, type HostSpec } from "./host.ts";
|
|
6
6
|
import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
|
|
7
7
|
import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
|
|
8
8
|
import { errorMessage } from "./shared.ts";
|
|
@@ -37,6 +37,26 @@ export interface FieldReference {
|
|
|
37
37
|
* declared one. Without it, the field holds the resource's `name`, and is compared as it is.
|
|
38
38
|
*/
|
|
39
39
|
readonly by?: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* A dotted path in that resource's state: apply and drift give this field the named resource's
|
|
42
|
+
* state at this path, as a whole (a list or an object), read in the same run or made by an
|
|
43
|
+
* earlier step of it (`records: mail` takes a `resend.domain`'s `dns_records`). A data file may
|
|
44
|
+
* give a literal value here instead. `desired` holds the resource's `name` until then, which the
|
|
45
|
+
* schema does not judge. Where the same apply changes that resource at the path (creates or
|
|
46
|
+
* replaces it, or updates it with the path `unknown` or another value there in its plan), or,
|
|
47
|
+
* for a create, it has no state yet or nothing at the path, a plan is given `null` there (known
|
|
48
|
+
* after apply: the type's `plan` must accept it and report the path in `unknown`), so a change it
|
|
49
|
+
* makes is planned, and approved, up front. A plan of a bound object leaves the field out while
|
|
50
|
+
* its resource is absent, gone or unreadable and not about to change, as drift does, and its step
|
|
51
|
+
* runs without it. A step given `null` for the field whose value is still not known (its
|
|
52
|
+
* resource's step failed or did not run) fails before its driver is called, and drift leaves the
|
|
53
|
+
* field out. An empty list or object at the path counts as known: a vendor that answers `[]`
|
|
54
|
+
* while it is still filling the list makes the dependent write an empty one. The path must be a
|
|
55
|
+
* field of each type it may name (`checkFromRefs`, when a config is resolved). Not on a
|
|
56
|
+
* write-only field, nor one holding a write-only field (refused). Not yet on a bridge-backed type
|
|
57
|
+
* (`tfConnector`): an OpenTofu provider's plan reads `null` as unset, not as known after apply.
|
|
58
|
+
*/
|
|
59
|
+
readonly from?: string;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
/** A resource type's `fields.references`: by dotted path, the resource each names. */
|
|
@@ -196,9 +216,10 @@ export interface Declared<Id extends string = string, T = State> {
|
|
|
196
216
|
|
|
197
217
|
/**
|
|
198
218
|
* What a resource type's constructor takes: its fields, where `R` (its `fields.references`)
|
|
199
|
-
* names a path, a declared resource of that type instead of a value (`repository_id: site`),
|
|
200
|
-
*
|
|
201
|
-
*
|
|
219
|
+
* names a path, a declared resource of that type instead of a value (`repository_id: site`), in
|
|
220
|
+
* place of the whole field for a `from` reference (`records: mail`) and of each item for a list
|
|
221
|
+
* otherwise, and where a string goes, a secret (`secret("NAME")`), which the constructor allows
|
|
222
|
+
* at a write-only field only.
|
|
202
223
|
*/
|
|
203
224
|
export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
|
|
204
225
|
? WithSecrets<T>
|
|
@@ -219,9 +240,11 @@ type DeclaringAt<T, R extends References, P extends string> = T extends readonly
|
|
|
219
240
|
: WithSecrets<T>;
|
|
220
241
|
|
|
221
242
|
type DeclaringField<V, R extends References, P extends string> = P extends keyof R
|
|
222
|
-
?
|
|
223
|
-
?
|
|
224
|
-
:
|
|
243
|
+
? R[P] extends { readonly from: string }
|
|
244
|
+
? WithSecrets<V> | DeclaredOf<R[P]["type"]>
|
|
245
|
+
: V extends readonly (infer I)[]
|
|
246
|
+
? (WithSecrets<I> | DeclaredOf<R[P]["type"]>)[]
|
|
247
|
+
: WithSecrets<V> | DeclaredOf<R[P]["type"]>
|
|
225
248
|
: [Extract<keyof R, `${P}.${string}`>] extends [never]
|
|
226
249
|
? WithSecrets<V>
|
|
227
250
|
: DeclaringAt<V, R, P>;
|
|
@@ -477,6 +500,65 @@ export function holderOf(value: unknown, path: string): { holder: State; key: st
|
|
|
477
500
|
return isObject(holder) || Array.isArray(holder) ? { holder: holder as State, key } : undefined;
|
|
478
501
|
}
|
|
479
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Each place `refs` (a declaration's, by dotted path to the resource named there) holds a `from`
|
|
505
|
+
* reference (`FieldReference.from`): its path, the resource it names (`named`), and the path in
|
|
506
|
+
* that resource's state its value comes from.
|
|
507
|
+
*/
|
|
508
|
+
const fromRefs = (references: References | undefined, refs: Readonly<Record<string, string>>) =>
|
|
509
|
+
Object.entries(refs).flatMap(([at, named]) => {
|
|
510
|
+
const from = references?.[fieldPathOf(at)]?.from;
|
|
511
|
+
return from === undefined ? [] : [{ at, named, from }];
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/** What `withFrom` makes of a `from` field it cannot fill: `null` (known after apply), or nothing. */
|
|
515
|
+
export type Missing = "unknown" | "drop";
|
|
516
|
+
|
|
517
|
+
/** A `from` reference `withFrom` could not fill: the resource it names has no state, or nothing at its path. */
|
|
518
|
+
export interface Unfilled {
|
|
519
|
+
at: string;
|
|
520
|
+
named: string;
|
|
521
|
+
from: string;
|
|
522
|
+
/** True when the resource has a state, with nothing at the path. */
|
|
523
|
+
stated: boolean;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* `desired` with each field a `from` reference names given the state of the resource it names
|
|
528
|
+
* at that path (a copy), `stateOf` that resource (by what `refs` holds there, and the path read);
|
|
529
|
+
* and the references it could not fill, where that resource has no state or nothing there, each
|
|
530
|
+
* such field `null` (`unknown`, a value known after apply) or left out (`drop`), as `missing` says
|
|
531
|
+
* (for each reference, given a function). A reference with no place in `desired` to fill is
|
|
532
|
+
* unfilled too. `desired` itself when there is no such field.
|
|
533
|
+
*/
|
|
534
|
+
export function withFrom(
|
|
535
|
+
references: References | undefined,
|
|
536
|
+
desired: State,
|
|
537
|
+
refs: Readonly<Record<string, string>>,
|
|
538
|
+
stateOf: (named: string, from: string) => State | undefined,
|
|
539
|
+
missing: Missing | ((named: string, from: string) => Missing),
|
|
540
|
+
): { desired: State; unfilled: Unfilled[] } {
|
|
541
|
+
const taken = fromRefs(references, refs);
|
|
542
|
+
if (!taken.length) return { desired, unfilled: [] };
|
|
543
|
+
const out = structuredClone(desired);
|
|
544
|
+
const unfilled: Unfilled[] = [];
|
|
545
|
+
for (const { at, named, from } of taken) {
|
|
546
|
+
const place = holderOf(out, at);
|
|
547
|
+
const state = stateOf(named, from);
|
|
548
|
+
const value = state === undefined ? undefined : valueAt(state, from);
|
|
549
|
+
if (place && value != null) {
|
|
550
|
+
// A copy: what a driver is given never shares an object with the resource's state.
|
|
551
|
+
place.holder[place.key] = structuredClone(value);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
unfilled.push({ at, named, from, stated: state !== undefined });
|
|
555
|
+
if (!place) continue;
|
|
556
|
+
if ((typeof missing === "function" ? missing(named, from) : missing) === "drop") delete place.holder[place.key];
|
|
557
|
+
else place.holder[place.key] = null;
|
|
558
|
+
}
|
|
559
|
+
return { desired: out, unfilled };
|
|
560
|
+
}
|
|
561
|
+
|
|
480
562
|
/** A field's dotted path as `fields` names it, without a list item's index: `teams.0` is `teams`. */
|
|
481
563
|
export const fieldPathOf = (at: string) => at.replaceAll(/\.\d+(?=\.|$)/g, "");
|
|
482
564
|
|
|
@@ -485,7 +567,7 @@ export const valueAt = (value: unknown, path: string) =>
|
|
|
485
567
|
path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
|
|
486
568
|
|
|
487
569
|
/** True when the two are the same JSON, where a missing value is `null`. */
|
|
488
|
-
function same(a: unknown, b: unknown): boolean {
|
|
570
|
+
export function same(a: unknown, b: unknown): boolean {
|
|
489
571
|
if (a === b || (a == null && b == null)) return true;
|
|
490
572
|
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
|
|
491
573
|
if (isObject(a) && isObject(b)) return Object.keys({ ...a, ...b }).every((key) => same(a[key], b[key]));
|
|
@@ -579,6 +661,12 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
579
661
|
* - `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same; given
|
|
580
662
|
* `expect` (a plan's `action` and `replace`), it refuses a fresh plan that differs
|
|
581
663
|
* (`DriverError`, `vendorCode` `plan_changed`).
|
|
664
|
+
*
|
|
665
|
+
* A type whose fields take a `from` reference (`FieldReference.from`) is given `null` there by
|
|
666
|
+
* apply's `plan` while the value is known only after apply, so its `plan` must accept `null` and
|
|
667
|
+
* report the path in `unknown`; and its `update` must treat a field left out of `desired` as
|
|
668
|
+
* keeping what the vendor holds, since a bound object whose `from` resource is gone is updated
|
|
669
|
+
* with that field left out.
|
|
582
670
|
* - `delete` takes `{ id, state?, handle? }` and returns `{ id, gone: true }`.
|
|
583
671
|
*
|
|
584
672
|
* `desired` may hold secret markers at write-only fields, which the driver resolves; no output
|
|
@@ -591,8 +679,10 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
|
|
|
591
679
|
* schema does not have, fields the vendor owns (unless the identity names them), values the
|
|
592
680
|
* schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
|
|
593
681
|
* another declared resource of that type (`repository_id: site`): `desired` holds, and the
|
|
594
|
-
* schema and `find` read, that resource's `name`, and `refs` says where it was named. A
|
|
595
|
-
*
|
|
682
|
+
* schema and `find` read, that resource's `name`, and `refs` says where it was named. A `from`
|
|
683
|
+
* reference names one in place of the whole field, which apply and drift fill from its state, so
|
|
684
|
+
* the schema does not judge the name there. A type without `find` declares resources without a
|
|
685
|
+
* `name`, found only by their bindings.
|
|
596
686
|
*/
|
|
597
687
|
export function defineResource<
|
|
598
688
|
S extends z.ZodObject,
|
|
@@ -672,6 +762,20 @@ export function defineResource<
|
|
|
672
762
|
const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
|
|
673
763
|
const references: References = fields.references ?? {};
|
|
674
764
|
const writeOnly = new Set(fields.writeOnly);
|
|
765
|
+
for (const [path, ref] of Object.entries(references)) {
|
|
766
|
+
if (ref.from !== undefined && ref.by !== undefined)
|
|
767
|
+
throw new Error(
|
|
768
|
+
`${what}: the reference at ${path} takes its value from its resource's state or names it by a field, not both`,
|
|
769
|
+
);
|
|
770
|
+
// A write-only field holds secret markers only, never a value from another resource's state.
|
|
771
|
+
if (ref.from !== undefined && underAny(path, writeOnly))
|
|
772
|
+
throw new Error(`${what}: ${path} is write-only, so it cannot take its value from another resource's state`);
|
|
773
|
+
const inside = ref.from === undefined ? undefined : fields.writeOnly.find((w) => w.startsWith(`${path}.`));
|
|
774
|
+
if (inside !== undefined)
|
|
775
|
+
throw new Error(
|
|
776
|
+
`${what}: ${inside} is write-only, so ${path}, which holds it, cannot take its value from another resource's state`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
675
779
|
const declare = (
|
|
676
780
|
given: Declaring<z.input<S>, R>,
|
|
677
781
|
options?: { connection?: ConnectionRef },
|
|
@@ -684,8 +788,9 @@ export function defineResource<
|
|
|
684
788
|
throw new Error(`${what}: select a declared connection of this connector`);
|
|
685
789
|
}
|
|
686
790
|
const refs: Record<string, string> = {};
|
|
687
|
-
// Where a secret is given, with list items' indexes:
|
|
688
|
-
|
|
791
|
+
// Where a secret, or a resource a `from` reference takes, is given, with list items' indexes:
|
|
792
|
+
// the schema does not judge the marker, nor the name that holds the place of the state.
|
|
793
|
+
const unjudged = new Set<string>();
|
|
689
794
|
// Each resource `given` names, where `references` allows one of its type, replaced by its name.
|
|
690
795
|
// `path` is the field's, as `references` names it; `at` has list items' indexes too.
|
|
691
796
|
const resolve = (value: unknown, path: string, at: string): unknown => {
|
|
@@ -697,7 +802,7 @@ export function defineResource<
|
|
|
697
802
|
if (isSanomaSecret(value.$secret) && !(hostSecrets !== undefined && underAny(path, new Set([hostSecrets])))) {
|
|
698
803
|
throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
|
|
699
804
|
}
|
|
700
|
-
|
|
805
|
+
unjudged.add(at);
|
|
701
806
|
return value;
|
|
702
807
|
}
|
|
703
808
|
if (isDeclared(value)) {
|
|
@@ -709,10 +814,16 @@ export function defineResource<
|
|
|
709
814
|
throw new Error(`${what}: ${at} names a resource of type ${types.join(" or ")}, not ${id} (${value.name})`);
|
|
710
815
|
if (value.name === undefined)
|
|
711
816
|
throw new Error(`${what}: ${at} names a ${id}, which has no identity to name it by`);
|
|
817
|
+
if (wants.from !== undefined) unjudged.add(at);
|
|
712
818
|
refs[at] = `${id}:${value.name}`;
|
|
713
819
|
return value.name;
|
|
714
820
|
}
|
|
715
|
-
if (Array.isArray(value))
|
|
821
|
+
if (Array.isArray(value)) {
|
|
822
|
+
const from = references[path]?.from;
|
|
823
|
+
const listed = from === undefined ? undefined : value.find(isDeclared);
|
|
824
|
+
if (listed) throw new Error(`${what}: ${path} takes a ${listed[DECLARED]}'s ${from} as a whole, not in a list`);
|
|
825
|
+
return value.map((item, i) => resolve(item, path, `${at}.${i}`));
|
|
826
|
+
}
|
|
716
827
|
if (!isObject(value)) return value;
|
|
717
828
|
return Object.fromEntries(
|
|
718
829
|
Object.entries(value).map(([field, v]) => [field, resolve(v, joinPath(path, field), joinPath(at, field))]),
|
|
@@ -732,7 +843,7 @@ export function defineResource<
|
|
|
732
843
|
);
|
|
733
844
|
}
|
|
734
845
|
const parsed = schema.safeParse(desired);
|
|
735
|
-
const issues = parsed.error?.issues.filter((issue) => !
|
|
846
|
+
const issues = parsed.error?.issues.filter((issue) => !unjudged.has(issue.path.join(".")));
|
|
736
847
|
if (issues?.length) throw new Error(`${what}: ${z.prettifyError(new z.ZodError(issues))}`);
|
|
737
848
|
const name = find?.(desired);
|
|
738
849
|
if (find && (typeof name !== "string" || name === "")) throw new Error(`${what}: its ${identity} is missing`);
|
|
@@ -770,6 +881,27 @@ export function defineResource<
|
|
|
770
881
|
return Object.freeze(resource);
|
|
771
882
|
}
|
|
772
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Throws where a `from` reference (`FieldReference.from`) names a path that a type it may name
|
|
886
|
+
* has no field at, among `types` (a config's): `dns_record` for `dns_records` would otherwise
|
|
887
|
+
* fail every create. A type not among them is left to the data files' check.
|
|
888
|
+
*/
|
|
889
|
+
export function checkFromRefs(types: ReadonlyMap<string, Resource>): void {
|
|
890
|
+
for (const type of types.values()) {
|
|
891
|
+
for (const [path, ref] of Object.entries(type.fields.references ?? {})) {
|
|
892
|
+
const named = ref.from === undefined ? [] : [ref.type].flat();
|
|
893
|
+
for (const held of named.map((id) => types.get(id)).filter((t) => t !== undefined)) {
|
|
894
|
+
if (!hasPath(held.schema, ref.from!)) {
|
|
895
|
+
const shape = (held.schema as Partial<z.ZodObject>).shape ?? {};
|
|
896
|
+
throw new Error(
|
|
897
|
+
`${type.vendor}.${type.type}: the reference at ${path} takes ${ref.from} from a ${held.vendor}.${held.type}'s state, which has no such field (its fields: ${Object.keys(shape).join(", ")})`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
773
905
|
/** Every resource type the connectors declare, by `<vendor>.<type>`. */
|
|
774
906
|
export function resourceTypesOf(connectors: readonly Connector<any, any>[]): Map<string, Resource> {
|
|
775
907
|
const types = new Map<string, Resource>();
|
package/src/resources.ts
CHANGED
|
@@ -140,9 +140,9 @@ export function readPeople(root: string | undefined): DeclaredPeople {
|
|
|
140
140
|
* While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
|
|
141
141
|
* is taken whatever the files say, with the groups stamped here (`visitors`, or none for
|
|
142
142
|
* `anonymous`), never through a sign-in: the staff logins admit no visitor. A visitor keeps the
|
|
143
|
-
* `email` its login verified; a `subject` is never taken from the caller (a token's
|
|
144
|
-
* which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and
|
|
145
|
-
* everyone while the people's files have problems.
|
|
143
|
+
* `email` and `github` its login verified; a `subject` is never taken from the caller (a token's
|
|
144
|
+
* is its row's, which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and
|
|
145
|
+
* for everyone while the people's files have problems.
|
|
146
146
|
*/
|
|
147
147
|
export function admit(
|
|
148
148
|
asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
|
|
@@ -155,7 +155,8 @@ export function admit(
|
|
|
155
155
|
if (asking.id === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
|
|
156
156
|
// A token's groups are its row's (`SanomaClient.tokens`), which only the public front reads; a visitor's are stamped.
|
|
157
157
|
if (isToken({ id: asking.id })) return { id: asking.id, groups: [...(asking.groups ?? [])] };
|
|
158
|
-
|
|
158
|
+
const { email, github } = asking;
|
|
159
|
+
return { id: asking.id, groups: [VISITORS], ...(email && { email }), ...(github && { github }) };
|
|
159
160
|
}
|
|
160
161
|
if (problems.length) {
|
|
161
162
|
// The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
|
|
@@ -167,7 +168,7 @@ export function admit(
|
|
|
167
168
|
if (!declaresPeople(people)) {
|
|
168
169
|
const given = Principal.safeParse(asking).data;
|
|
169
170
|
if (given) {
|
|
170
|
-
const { email: _email, subject: _subject, ...principal } = given;
|
|
171
|
+
const { email: _email, github: _github, subject: _subject, ...principal } = given;
|
|
171
172
|
return principal;
|
|
172
173
|
}
|
|
173
174
|
} else {
|
package/src/runtime-ops.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { sealInvite } from "./seal.ts";
|
|
|
7
7
|
import { errorMessage } from "./shared.ts";
|
|
8
8
|
import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
|
|
9
9
|
import { memoryTokens, type Tokens } from "./tokens.ts";
|
|
10
|
+
import {
|
|
11
|
+
identityProvisioningCode,
|
|
12
|
+
memoryIdentityProvisioning,
|
|
13
|
+
type IdentityProvisioning,
|
|
14
|
+
} from "./identity-provisioning.ts";
|
|
10
15
|
|
|
11
16
|
// What a workflow cannot do itself and the runtime does for it, as operations of the vendor
|
|
12
17
|
// `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
|
|
@@ -23,11 +28,60 @@ const SecretName = z
|
|
|
23
28
|
/** How long an instance gets to answer `POST /auth/invite`. */
|
|
24
29
|
const INVITE_TIMEOUT_MS = 10_000;
|
|
25
30
|
|
|
31
|
+
const DeploymentIdentity = z.object({
|
|
32
|
+
deployment: z.string().min(1).max(256),
|
|
33
|
+
company: z.string().min(1).max(256),
|
|
34
|
+
environment: z.string().min(1).max(256),
|
|
35
|
+
serviceAccount: z.string().regex(/^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/),
|
|
36
|
+
});
|
|
37
|
+
const Generation = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
|
38
|
+
const IdentityTarget = ({ deployment }: { deployment: string }) => deployment;
|
|
39
|
+
|
|
40
|
+
// Enrollment implementations can see credential hashes and database credentials. Neither their
|
|
41
|
+
// errors nor causes may be captured in durable driver history, including on an uncertain commit.
|
|
42
|
+
async function identityWrite(write: () => Promise<void>): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
await write();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const code = identityProvisioningCode(error) ?? "unavailable";
|
|
47
|
+
throw new DriverError(`Identity provisioning: ${code}`, { retryable: code === "unavailable" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
/**
|
|
27
52
|
* The runtime's own operations a workflow calls: `import { sanoma } from "sanoma"`, then
|
|
28
53
|
* `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
|
|
29
54
|
*/
|
|
30
55
|
export const sanoma = defineConnector("sanoma", {
|
|
56
|
+
identity: {
|
|
57
|
+
enroll: {
|
|
58
|
+
effect: "access",
|
|
59
|
+
idempotent: true,
|
|
60
|
+
description:
|
|
61
|
+
"Enroll a deployment identity, keeping its bootstrap credential in the secret store. A higher generation rotates it; mappings are immutable",
|
|
62
|
+
input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime(), store: SecretName }),
|
|
63
|
+
output: z.object({ deployment: z.string(), generation: Generation, store: z.string() }),
|
|
64
|
+
target: IdentityTarget,
|
|
65
|
+
createsSecrets: ({ store }: { store: string }) => [store],
|
|
66
|
+
},
|
|
67
|
+
retire: {
|
|
68
|
+
effect: "access",
|
|
69
|
+
idempotent: true,
|
|
70
|
+
description:
|
|
71
|
+
"Shorten an old identity generation's validity after delivering its replacement; never extend access",
|
|
72
|
+
input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime() }),
|
|
73
|
+
output: z.object({}),
|
|
74
|
+
target: IdentityTarget,
|
|
75
|
+
},
|
|
76
|
+
revoke: {
|
|
77
|
+
effect: "access",
|
|
78
|
+
idempotent: true,
|
|
79
|
+
description: "Disable all token renewal for a deployment. This deployment id cannot be re-enrolled",
|
|
80
|
+
input: DeploymentIdentity,
|
|
81
|
+
output: z.object({}),
|
|
82
|
+
target: IdentityTarget,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
31
85
|
secret: {
|
|
32
86
|
generate: {
|
|
33
87
|
effect: "write",
|
|
@@ -156,12 +210,36 @@ export async function postInvite(host: string, request: string): Promise<{ url:
|
|
|
156
210
|
*/
|
|
157
211
|
export function runtimeDriver({
|
|
158
212
|
tokens,
|
|
213
|
+
identities,
|
|
159
214
|
invite = postInvite,
|
|
160
215
|
}: {
|
|
161
216
|
tokens: Tokens;
|
|
217
|
+
identities?: IdentityProvisioning;
|
|
162
218
|
invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
|
|
163
219
|
}) {
|
|
164
220
|
return defineDriver(sanoma, {
|
|
221
|
+
identity: {
|
|
222
|
+
async enroll({ store, ...input }, call) {
|
|
223
|
+
if (!identities) throw refuse("identity provisioning is not configured");
|
|
224
|
+
// Store before enrolling: a lost DB response retries with the same credential, never a new bearer.
|
|
225
|
+
const credential = await call.secrets.ensure(store, generateSecret);
|
|
226
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(credential)) throw refuse("invalid identity bootstrap credential");
|
|
227
|
+
await identityWrite(() =>
|
|
228
|
+
identities.enroll({ ...input, hash: createHash("sha256").update(credential).digest("hex") }),
|
|
229
|
+
);
|
|
230
|
+
return { deployment: input.deployment, generation: input.generation, store };
|
|
231
|
+
},
|
|
232
|
+
async retire(input) {
|
|
233
|
+
if (!identities) throw refuse("identity provisioning is not configured");
|
|
234
|
+
await identityWrite(() => identities.retire(input));
|
|
235
|
+
return {};
|
|
236
|
+
},
|
|
237
|
+
async revoke(input) {
|
|
238
|
+
if (!identities) throw refuse("identity provisioning is not configured");
|
|
239
|
+
await identityWrite(() => identities.revoke(input));
|
|
240
|
+
return {};
|
|
241
|
+
},
|
|
242
|
+
},
|
|
165
243
|
secret: {
|
|
166
244
|
async generate({ name, kind }, call) {
|
|
167
245
|
if (kind === "random") {
|
|
@@ -230,5 +308,6 @@ export function runtimeDriver({
|
|
|
230
308
|
export const sandboxRuntimeDriver = () =>
|
|
231
309
|
runtimeDriver({
|
|
232
310
|
tokens: memoryTokens(),
|
|
311
|
+
identities: memoryIdentityProvisioning(),
|
|
233
312
|
invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
|
|
234
313
|
});
|
package/src/scenario.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "@cucumber/messages";
|
|
14
14
|
import type { z } from "zod";
|
|
15
15
|
import type { RunSummary, SanomaClient } from "./client.ts";
|
|
16
|
-
import type { Approver } from "./define.ts";
|
|
16
|
+
import type { Approver, Principal } from "./define.ts";
|
|
17
17
|
import type { ResolvedConfig } from "./config.ts";
|
|
18
18
|
import {
|
|
19
19
|
buildLibrary,
|
|
@@ -162,8 +162,11 @@ export interface Scenario {
|
|
|
162
162
|
text: string;
|
|
163
163
|
/** Every step in order, for a list and for marking graph nodes. */
|
|
164
164
|
steps: ScenarioStep[];
|
|
165
|
-
/**
|
|
166
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Who starts the run when a `Given … is acting` says: a declared person or agent or `anonymous`
|
|
167
|
+
* by id, or a visitor, by id or, with the email and GitHub login its login verified, as a principal.
|
|
168
|
+
*/
|
|
169
|
+
startedBy?: Principal | string;
|
|
167
170
|
given: Given[];
|
|
168
171
|
input: unknown;
|
|
169
172
|
decisions: Decision[];
|
|
@@ -208,11 +211,11 @@ interface Then {
|
|
|
208
211
|
|
|
209
212
|
/**
|
|
210
213
|
* What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
|
|
211
|
-
* and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
|
|
214
|
+
* and `actor` a `Given` saying who starts the run (`{who} is acting`), by id or as a principal.
|
|
212
215
|
*/
|
|
213
216
|
interface Items {
|
|
214
217
|
given: Unstepped<Given>[];
|
|
215
|
-
actor: string;
|
|
218
|
+
actor: Principal | string;
|
|
216
219
|
when: When;
|
|
217
220
|
then: Then;
|
|
218
221
|
any: Unstepped<Decision>;
|
|
@@ -398,6 +401,12 @@ function builtFor(scope: Scope): Built {
|
|
|
398
401
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
399
402
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
400
403
|
|
|
404
|
+
/** The scenario's visitor (`a visitor is acting`) with what its login verified, as `publicFront` passes it on. */
|
|
405
|
+
const visitorWith = (verified: { email: unknown; github?: unknown }): Principal => ({
|
|
406
|
+
...visitorPrincipal("scenario", "visitor"),
|
|
407
|
+
...(verified as Pick<Principal, "email" | "github">),
|
|
408
|
+
});
|
|
409
|
+
|
|
401
410
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
402
411
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
403
412
|
const { types, collections } = scope;
|
|
@@ -455,6 +464,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
455
464
|
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
456
465
|
// A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
|
|
457
466
|
{ kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
|
|
467
|
+
// A visitor with the email its login verified, and a GitHub login it linked.
|
|
468
|
+
{ kind: "actor", source: "a visitor {string} is acting", read: ([email]) => visitorWith({ email }) },
|
|
469
|
+
{
|
|
470
|
+
kind: "actor",
|
|
471
|
+
source: "a visitor {string} with GitHub {string} is acting",
|
|
472
|
+
read: ([email, github]) => visitorWith({ email, github }),
|
|
473
|
+
},
|
|
458
474
|
{
|
|
459
475
|
kind: "given",
|
|
460
476
|
source: "{op} was called with",
|
|
@@ -985,7 +1001,9 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
|
|
|
985
1001
|
switch (rule.kind) {
|
|
986
1002
|
case "actor":
|
|
987
1003
|
if (spec.startedBy !== undefined)
|
|
988
|
-
throw new Error(
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`${typeof spec.startedBy === "string" ? spec.startedBy : spec.startedBy.id} is acting already: a run has one actor`,
|
|
1006
|
+
);
|
|
989
1007
|
spec.startedBy = rule.read(args, pickle);
|
|
990
1008
|
break;
|
|
991
1009
|
case "given": {
|