@sanoma-ai/workflows 0.4.1 → 0.5.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 +19 -10
- 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 +2 -0
- package/dist/describe.js +39 -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 +4 -3
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +57 -3
- package/dist/op.js +10 -2
- 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 +27 -2
- package/dist/shared.js +52 -3
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +35 -6
- package/dist/sites.js +129 -25
- package/dist/sites.js.map +1 -1
- package/dist/vendor-info.d.ts +19 -0
- package/dist/vendor-info.js +49 -0
- package/dist/vendor-info.js.map +1 -0
- 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 +48 -4
- package/src/host.ts +2 -0
- package/src/index.ts +11 -0
- package/src/op.ts +61 -5
- 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 +61 -3
- package/src/sites.ts +162 -30
- package/src/vendor-info.ts +53 -0
- package/src/version.ts +4 -2
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ export default defineWorkflow({
|
|
|
37
37
|
});
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
A third argument says who the vendor is, so the app can show it: `defineConnector("shop", specs, { title: "Shop", logo: { svg, dark }, package, homepage })`. `logo.svg` is the vendor's mark as inline SVG markup, one `<svg>…</svg>` element (`defineConnector` refuses anything else, such as a file path or a URL), and `logo.dark` its variant for dark backgrounds, if it has one. The app shows a logo before each of the vendor's operations, in graphs and lists, and only ever as an image (`<img>` with a `data:` URL), where an SVG's scripts and external references never run or load. A vendor with no logo is shown by its operations' ids alone, as before. `package` is the connector's npm package name (`"@sanoma-ai/connector-resend"`), which the app's Connectors page links to on npm. `homepage` is the connector package's `homepage` from its package.json: where its code and README are, as an https URL (`defineConnector` refuses anything else); the Connectors page links to it as the connector's source. A vendor whose operations are split over several connectors is described by the first connector's info, its package and homepage as well as its title and logo.
|
|
40
|
+
A third argument says who the vendor is, so the app can show it: `defineConnector("shop", specs, { title: "Shop", logo: { svg, dark }, package, homepage })`. `logo.svg` is the vendor's mark as inline SVG markup, one `<svg>…</svg>` element (`defineConnector` refuses anything else, such as a file path or a URL), and `logo.dark` its variant for dark backgrounds, if it has one. The app shows a logo before each of the vendor's operations, in graphs and lists, and only ever as an image (`<img>` with a `data:` URL), where an SVG's scripts and external references never run or load. A vendor with no logo is shown by its operations' ids alone, as before. `package` is the connector's npm package name (`"@sanoma-ai/connector-resend"`), which the app's Connectors page links to on npm. `homepage` is the connector package's `homepage` from its package.json: where its code and README are, as an https URL (`defineConnector` refuses anything else); the Connectors page links to it as the connector's source. A vendor whose operations are split over several connectors is described by the first connector's info, its package and homepage as well as its title and logo. A connector package writes all of this once, in its package.json, and passes `vendorInfo(import.meta.url)` as the third argument (the nearest package.json is read): it reads the `"sanoma": { "title", "logo", "logoDark"? }` field, whose logos are SVG files in the package (relative `.svg` paths, listed in `files`), and the package's `name` and `homepage`. With the `sanoma-connector` keyword, the same field is how the app lists the package on npm before it is installed.
|
|
41
41
|
|
|
42
42
|
A [driver](#drivers) implements the operations and holds the credentials, and a [fake](#fakes-for-tests) stands in for the vendor in [sandbox runs](#scenarios-and-sandbox-runs). A connector package attaches both to the connector it exports, with `withSetup` (and its [steps](#steps-a-connector-ships) for Gherkin tests, as `steps`), so a config names the connector alone:
|
|
43
43
|
|
|
@@ -51,6 +51,8 @@ export const shop = withSetup(defineConnector("shop", specs, info), {
|
|
|
51
51
|
|
|
52
52
|
`withSetup` lays what it is given over what the connector already carries, and keeps it on the connector's vendor record, so a spread copy of the connector keeps it too. A config that needs a driver made otherwise (another bridge, other options) swaps the driver and keeps the fake: `withSetup(github, { driver: () => githubDriver({ bridge }) })`. It is for connector packages and configs, not for workflows, which the lint does not let import it. Both are optional: a connector without a driver declares its operations and implements none, so a workflow using one is refused; a connector without a fake has none for sandbox runs, which the app's connector page says.
|
|
53
53
|
|
|
54
|
+
A connector that signs visitors in to a public site attaches a login half too, as `login`: `withSetup(connector, { login: (context) => half })`. The app calls it once per public site whose `site.ts` names the connector (`defineSite({ public: true, login: "<vendor>" })`), when it starts, with a `LoginContext` (the database, a key of its own derived from `SANOMA_SECRETS_KEY` to sign its cookies with, a vendor's secrets, the site's origins); a login that keeps tables in the database migrates them there, serialized and idempotent. The `LoginHalf` it makes (see its type) answers the provider's stable subject for a request, never a principal: the app makes the visitor's principal from it, so a connector cannot set a visitor's groups. A visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header the app sets, never `X-Forwarded-For`. One vendor's connectors attach one `login`, as for a driver. [`@sanoma-ai/connector-better-auth`](https://www.npmjs.com/package/@sanoma-ai/connector-better-auth) is one, email and password on the company's database. The app makes it as it starts and sends it every request to the site's own host ([public sites](https://www.npmjs.com/package/@sanoma-ai/app#public-sites)).
|
|
55
|
+
|
|
54
56
|
A config names the workflows and the connectors:
|
|
55
57
|
|
|
56
58
|
```ts
|
|
@@ -70,7 +72,7 @@ That is the whole config; the rest is convention, from its `root` (`defineConfig
|
|
|
70
72
|
|
|
71
73
|
The [data files](#data-files) are `resources/` in the root. `ledger` and `databaseUrl` are the only other fields. `drivers`, `fakes`, `appName`, `scenarios` and `policy` are typed `never` on `SanomaConfig`, each deprecated with where it went, so the compiler and the editor say so; an untyped config that still names `drivers` or `fakes` is refused at run time rather than run live on what it no longer reads, and one that names `policy` rather than run allowing everything.
|
|
72
74
|
|
|
73
|
-
The audit record goes to the ledger, by default `postgresLedger(databaseUrl)` on the config's database (see [Ledger](#ledger)); `ledger: jsonlLedger(dir)` keeps it in files instead, and `memoryLedger()` in this process only, for tests. `resolveConfig(config)` checks a config and derives what the runtime uses from it: the app name, database, version and queue (`sanoma:<appName>`, for every run, [sandbox runs](#scenarios-and-sandbox-runs) included), the operations by id, the driver each vendor's connector attaches, made once for the resolution (`driversByVendor`, in `connectors` order, and their functions by operation id, `drivers`), the makers of their fakes (`fakes`, by vendor), the scenarios directory, the policy (`policy`,
|
|
75
|
+
The audit record goes to the ledger, by default `postgresLedger(databaseUrl)` on the config's database (see [Ledger](#ledger)); `ledger: jsonlLedger(dir)` keeps it in files instead, and `memoryLedger()` in this process only, for tests. `resolveConfig(config)` checks a config and derives what the runtime uses from it: the app name, database, version and queue (`sanoma:<appName>`, for every run, [sandbox runs](#scenarios-and-sandbox-runs) included), the operations by id, the driver each vendor's connector attaches, made once for the resolution (`driversByVendor`, in `connectors` order, and their functions by operation id, `drivers`), the makers of their fakes (`fakes`, by vendor), the scenarios directory, the policy (`policy`, the policies it is made from as `policies`, and what is wrong in them as `policyProblems`), and the config's `ledger` when it gives one; it opens nothing and reads no file outside the root: its `package.json`, whether `scenarios/` is a directory, and the policies with the people files. It throws, with one message, whatever the worker would refuse: a workflow or driver naming an operation the `connectors` don't declare, a workflow redeclaring an operation with another effect, an operation with no driver (`` `shop.order.refund` has no driver: the connector package must attach one ``), two different drivers or fakes for one vendor, two workflows with one name, a `ledger` that is not a store, a `drivers`, `fakes` or `policy` an untyped config still names. `startWorker`, `SanomaClient.connect` and `describeConfig` all call it, so they fail the same way. It adds the built-in [`drift`](#drift) and [`apply`](#apply) workflows when the connectors declare resource types, and [`call`](#direct-calls) when an operation may be called directly. The worker takes each operation's effect, schemas and retry setting from `connectors`, never from the workflow.
|
|
74
76
|
|
|
75
77
|
An operation names the instance it acts on with `target`, a function of its parsed input, so a policy can decide per post or per order rather than per operation (its Cedar resource, `Sanoma::Target::"order/ord_1"`). TypeScript types its input `any` there, so annotate it to have it checked:
|
|
76
78
|
|
|
@@ -119,9 +121,11 @@ export const finance = sanoma.group({ name: "finance", members: [ada] });
|
|
|
119
121
|
|
|
120
122
|
A membership is written on the group alone. An agent is in the group `agents` besides its own, with `onBehalfOf` the person whose instruction it carries; the runtime's own agents (`scenarios`, `host-check`, `schedule`) are declared by no file. They have no vendor object: no driver, no drift. `readPeople(root)` reads them (again only when a data file changed), and `principalOf(id, people)` is who an id is: its groups, by inverting the members, and its `onBehalfOf`. Once a person is declared, `SanomaClient.start`, `call`, `decide`, `setCredential`, `setSecret` and `clearCredential` admit only a declared id (`not_admitted`, "<id> is not declared in resources/people.ts"), as the app's login admits only a declared person; while none is, any id is taken as given; while the people files have problems, nobody is admitted but the runtime's agents.
|
|
121
123
|
|
|
124
|
+
A visitor of a public site is a principal too, which no data file declares: `visitorPrincipal(login, subject)` is one signed in through a visitor login (`visitor:<login>:<subject>`, in the group `visitors`), and `ANONYMOUS_PRINCIPAL` the one a page with nobody signed in runs as (`anonymous`, in no group); `isVisitor(principal)` tells either by its id. `admit` takes a visitor's id whatever the files say and stamps its groups, never the ones given; the staff logins (a sign-in) admit none, and a visitor sets no credential and decides no approval. So `visitors` and `anonymous` are no group's name, and `anonymous` and the `visitor:…` form no person's or agent's id (`readPeople` reports them, as it does `agents`).
|
|
125
|
+
|
|
122
126
|
### Policies
|
|
123
127
|
|
|
124
|
-
The policy is [Cedar](https://docs.cedarpolicy.com): `policies/**/*.cedar` in the root, evaluated in process with `@cedar-policy/cedar-wasm` against a schema built from the resolved config and the people (`schemaOf`). Everything is allowed unless a `forbid` refuses it: the runtime adds the one `permit`, so a company's policies are `forbid`s.
|
|
128
|
+
The policy is [Cedar](https://docs.cedarpolicy.com): `policies/**/*.cedar` in the root, evaluated in process with `@cedar-policy/cedar-wasm` against a schema built from the resolved config and the people (`schemaOf`). Everything is allowed unless a `forbid` refuses it: the runtime adds the one `permit`, so a company's policies are `forbid`s. That permit leaves [visitors](#people) out at a run's start and at a direct call: a visitor may do neither until a company `permit` names it, the one kind of `permit` a company file may hold, its principal exactly `in Sanoma::Group::"visitors"` or `== Sanoma::Principal::"anonymous"`. Inside a run a visitor was let start, its calls are allowed as anyone's, the forbids still holding, so one permit per workflow is enough and the run's operations stay out of a visitor's direct reach. A visitor's start or direct call no permit allows is denied ("A visitor may do nothing until a policy permits it", `VISITOR_DENIED`), never held; `allowAll`, a repo with no policies, denies them the same, and a visitor starts none of the runtime's own workflows but `call`.
|
|
125
129
|
|
|
126
130
|
```cedar
|
|
127
131
|
@hold("finance")
|
|
@@ -132,12 +136,16 @@ unless { principal in Sanoma::Group::"finance" };
|
|
|
132
136
|
@id("no-agent-refunds")
|
|
133
137
|
@reason("Agents do not refund")
|
|
134
138
|
forbid (principal in Sanoma::Group::"agents", action == Sanoma::Action::"shop.order.refund", resource);
|
|
139
|
+
|
|
140
|
+
// A public site's visitors may sign up; nobody signed in may list the posts.
|
|
141
|
+
permit (principal in Sanoma::Group::"visitors", action == Sanoma::Action::"workflow:signup", resource);
|
|
142
|
+
permit (principal == Sanoma::Principal::"anonymous", action == Sanoma::Action::"data.posts.list", resource);
|
|
135
143
|
```
|
|
136
144
|
|
|
137
|
-
- **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups and `
|
|
145
|
+
- **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups, `agents` and `visitors`, so a misspelt group fails validation), with `onBehalfOf`; each operation's id an action in its effect's group (`effect:money`), on a `Target` (its `target`, else its id; a collection's in `Collection::"<name>"`), with `context.input` from its input schema and, for a collection's save or delete, `context.before`; `workflow:<name>` in the group `start`, on a `Workflow`, with the run's input. `schemaText` prints it; the `sanoma` bin writes it to `.sanoma/schema.cedarschema`.
|
|
138
146
|
- **A hold** is a `forbid` with `@hold("<group>" | "<person id>" | "on-behalf-of")`: the call waits for that approver (`on-behalf-of`: whom the agent acts for), with `@title` as the approval's title. The runtime lifts it once an approval addressed to that approver and covering the operation is approved (`approvedFor`), so a workflow's own `ctx.approval` to the same approver, covering the operation, lets it through. Several holds on one call or start are asked one at a time, in the order written: once one is approved, the policy is asked again. **A deny** is a `forbid` without `@hold`, with `@reason` as its message (else `Refused by <id>`). A policy's id is its `@id`, else `<file>#<n>`; a decision's `reasons` carry the ids into the ledger.
|
|
139
|
-
- **Where it runs**: before every operation call, a run's node, a [direct call](#direct-calls) and [apply](#apply)'s changes (`decideCall`), and before a run starts (see [Approvals](#approvals)). `policy.preview(requests)` asks the same with the input unknown, through Cedar's partial evaluation: `allow`, `hold` with its approver, `deny`, or `depends` on the input, each with the rule behind it; the app's run form and Permissions page show it.
|
|
140
|
-
- **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit`, a `@hold` naming no declared group or person) are `resolveConfig`'s `policyProblems`, one line each, `file:line:col: <policy id>: <message>`. While there are any, the policy refuses every call, start and preview ("The policies do not hold: …"): a repo never runs on part of its rules. `describeConfig(config).policy` carries them as `problems`; `startWorker` warns with them (unless `quiet`), `startApp` lists them and its pages say the policies do not hold, `sanoma check` reports them and runs no scenario, and the `sanoma` bin starts nothing.
|
|
147
|
+
- **Where it runs**: before every operation call, a run's node, a [direct call](#direct-calls) and [apply](#apply)'s changes (`decideCall`), and before a run starts (see [Approvals](#approvals)). `policy.preview(requests)` asks the same with the input unknown, through Cedar's partial evaluation: `allow`, `hold` with its approver, `deny`, or `depends` on the input, each with the rule behind it; the app's run form and Permissions page show it. A request with `inRun: true` asks about an operation inside a run, not a direct call, which only a visitor's answer tells apart.
|
|
148
|
+
- **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit` for anyone but the visitors or `anonymous`, or with a `@hold`, a `@hold` naming no declared group or person, `visitors` among them) are `resolveConfig`'s `policyProblems`, one line each, `file:line:col: <policy id>: <message>`. While there are any, the policy refuses every call, start and preview ("The policies do not hold: …"): a repo never runs on part of its rules. `describeConfig(config).policy` carries them as `problems`; `startWorker` warns with them (unless `quiet`), `startApp` lists them and its pages say the policies do not hold, `sanoma check` reports them and runs no scenario, and the `sanoma` bin starts nothing.
|
|
141
149
|
|
|
142
150
|
Later, not yet: the person's numeric GitHub id is stored but unused, so a login renamed and taken by someone else would be admitted as them. The app describes the config when it starts, so a person added while it runs shows in the who-are-you picker and the Permissions grid after a restart (login admits them at once). The Permissions grid asks about workflow starts and direct operations only, not the operations a run calls.
|
|
143
151
|
|
|
@@ -392,7 +400,7 @@ This package knows nothing of OpenTofu. Resource types from an OpenTofu provider
|
|
|
392
400
|
|
|
393
401
|
### Hosts
|
|
394
402
|
|
|
395
|
-
A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port }`, `secrets` a write-only field), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
|
|
403
|
+
A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port, domains? }`, `secrets` a write-only field; `domains`, when the type takes it, a public site's domain by site name, which the host hands the app as `SANOMA_DOMAINS` and `describeConfig` reports a problem for, at the declaration, when it names a site that is not public or gives one domain two sites: `sanoma.example is the domain of both www and docs`), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
|
|
396
404
|
|
|
397
405
|
A config whose connectors declare a host type has the built-in operation `sanoma.source.resolve` (`SOURCE_RESOLVE`, effect `read`): `{ url, ref } → { revision }`, by `git ls-remote` on this machine (a branch, then a tag, peeled; a full commit id is itself). Apply calls it for each host it plans, and gives the host the commit as its `revision` and the clone URL as its `source` (a referenced repository's, from its `by` field); a host whose ref does not resolve, or whose repository has no clone URL yet (apply the repository first), is a problem of the plan. The drift run calls it for every host it reads. A host's `secrets` map may name sanoma's own secrets (`SANOMA_…`), and no other field of any type may. `resolveRevision(url, ref)` is the same, outside a run.
|
|
398
406
|
|
|
@@ -478,7 +486,8 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
|
|
|
478
486
|
| Given | `{op} fails once`, `{op} is rate limited once`, `{op} loses its reply once` | Injects the fault into the operation's next call (`failNext`, `rateLimit`, `loseReply`); its step has `fault: true` |
|
|
479
487
|
| Given | `{resource} {string} has` and a two-column table (`field \| value`) or a JSON doc string | The vendor's fake holds the object with these fields: `Given github.repository "website" has` then `\| has_wiki \| true \|` |
|
|
480
488
|
| Given | `{resource} {string} is gone` | The vendor's fake no longer holds the object: `Given github.repository "docs" is gone` |
|
|
481
|
-
| Given | `{who} is acting` | Starts the run as that declared person or agent
|
|
489
|
+
| Given | `{who} is acting` | Starts the run as that declared person or agent, or `anonymous`: `Given refund-bot is acting` (else `runScenario`'s `startedBy`, `scenarios` by default) |
|
|
490
|
+
| Given | `a visitor is acting` | Starts the run as a visitor of a public site signed in (`visitor:scenario:visitor`, in `visitors`), which only a `permit` lets start one |
|
|
482
491
|
| When | `{workflow} runs with` and a JSON doc string or a two-column table (`name \| value`) | The workflow the scenario runs, and its input |
|
|
483
492
|
| When | `{workflow} runs` | The same, with the input made up |
|
|
484
493
|
| Any | `{string} is approved by {who}`, `… with note {string}`, and the `rejected` forms | How the approval with that title (or id, such as `approval-2`) is decided |
|
|
@@ -499,7 +508,7 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
|
|
|
499
508
|
|
|
500
509
|
A resource step names an object a vendor's fake holds, by resource type and import id (a binding id, or an identity: a GitHub repository's name, a branch protection rule's `repository:pattern`; a fake also finds an object by its binding id, such as a repository's `node_id`), for a fake that holds objects (`@sanoma-ai/bridge`'s `tfFake`, as the GitHub and Stripe fakes are; a sandbox run whose scenario seeds a resource of another vendor's fake fails with `invalid_input`). `has` puts the fields on the object (`put`: over the one the vendor has, keeping its other fields, or a new one), each read as a table cell is for an operation's input and checked against the type's schema; `is gone` removes it (`remove`), and is nothing when there is none. The run's own fake is what is seeded. A fake starts a sandbox run with what its replies were recorded with, so a declared resource the recording lacks is `gone` to a drift run until a scenario puts it.
|
|
501
510
|
|
|
502
|
-
The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`, `shop.refunds.create was denied with "Agents do not refund", not "No"`. When the data files declare people, `is acting` names a declared person or agent (one of the runtime's own too) and `held for group` a declared group or `
|
|
511
|
+
The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`, `shop.refunds.create was denied with "Agents do not refund", not "No"`. When the data files declare people, `is acting` names a declared person or agent (one of the runtime's own too, or `anonymous`) and `held for group` a declared group, `agents` or `visitors`, else the scenario does not load, naming those declared; while they declare no one, any id and any group go, as a run's start admits anyone then. A scenario has one actor. The drift steps read the report a finished [drift](#drift) run returned, so their scenario must run `drift` (a parse error otherwise), as the apply steps read an apply run's report, and an apply run's input is only what `runs with` gives, never made up; a failed one says how the report found the resource: `resources/identity/github.ts#website is drifted in has_wiki, not clean`, `… is gone, not drifted in has_wiki`, or that the report has no such resource.
|
|
503
512
|
|
|
504
513
|
The `step` steps name a [step](#step-names) of the scenario's workflow, after its `When`: one whose name is no step's, or several's (a default an operation called twice shares), is an error listing the names it has, so a test names one call (an unnamed call with a computed segment, `ctx.ghost.post[action](…)`, is named in a run by the operation it calls, so it shares that operation's id; an approval whose title is no literal is named by its title in a run, which the outline cannot tell apart), and `was called with` checks its operation's input fields. They read the ledger by the `name` its records carry: a step ran when a call by it was made (not denied, rejected or failed for good), a sleep by it started, or the workflow asked for an approval of that title; a policy's hold on a call is that call's, not a run of its own. A call in a loop runs once per pass: `ran 3 times` for three items. A failed one says what happened: `step "Draft the post" ran twice, not 3 times`.
|
|
505
514
|
|
|
@@ -571,7 +580,7 @@ A sandbox run does not survive a worker restart: its fakes keep their state in t
|
|
|
571
580
|
`@sanoma-ai/app` is one UI over a config; another (a Slack bot, an internal tool) can be built on the same pieces, which the package exports for that:
|
|
572
581
|
|
|
573
582
|
- `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `readDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. It is a separate entry so the worker never loads the parser the outline uses, and the lint refuses it in workflow files. `resolveConfig(config)` returns the checked config as a `ResolvedConfig`, with the operations and drivers by id, the resource types by `<vendor>.<type>` (`types`), the workflows by name (the built-ins `drift`, `apply` and `call` among them), its `root` and each vendor's `credentials` (`CredentialStatus`); `isOp(x)` tells an operation from a built-in in a workflow's `uses`.
|
|
574
|
-
- `readSites(root, description)`, from `@sanoma-ai/workflows/describe`, which `describeConfig` returns as `sites`: what each site calls, read with oxc and never run, for a workflow's **Used by**. A site is a folder of `sites/` with a `routes/`, named as a URL segment; its `routes/**` and `components/**` (`.ts`, `.tsx`, less tests) are read for each `<RunForm workflow="…" />`
|
|
583
|
+
- `readSites(root, description, connectors, policies?)`, from `@sanoma-ai/workflows/describe`, which `describeConfig` returns as `sites`: what each site calls, read with oxc and never run, for a workflow's **Used by**. A site is a folder of `sites/` with a `routes/`, named as a URL segment; its `routes/**` and `components/**` (`.ts`, `.tsx`, less tests) are read for each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`): `api.<name>` and `api.<name>.<method>` are the workflow whose `apiNameOf` is `<name>`, `api.data.<collection>.<op>` a collection, `api.<vendor>.<resource>.<op>` the operation of that id. Each is a `SiteCall` (`kind`, `target`, `method`, what it calls of a workflow or collection as written (`start`, `latest`, `list`), `via`, `file`, `line`, `column`, and `page`, the page's path from its `createFileRoute` or `createLazyFileRoute` id, in a page's file); a `SiteEntry` holds a site's `name`, its `title` (a string `defineSite({ title })` in `site.ts`), its `calls` and its `problems`: each call to a workflow, collection or operation the config does not have (`calls workflow "<name>", which does not exist`), or to an operation a page may not call directly, and, given the policies (`describeConfig` passes them unless they have problems), on a public site each start (`api.<name>(…)`, `.start(…)`, a `RunForm`) and direct call that no company `permit` could let a visitor make, its `when` and `unless` aside (one naming `anonymous`; on a site with a `login`, `anonymous` or the group `visitors`), whose message ends with the permit to add, and, whatever the policies, its call of a built-in workflow or read of a workflow's runs but `.result` (`.latest`, `.runs`, `.approvals`, `.ledger`), which the app refuses a visitor; which `sanoma check` fails on. A name not written out (a variable, a template with holes, a computed member) is skipped, as is a call through a namespace import or a destructured `api`.
|
|
575
584
|
- `generateApi(description)` and `writeApi(root, description)`, from `@sanoma-ai/workflows/describe`: a site's typed `api`, `.sanoma/api.ts` (`API_FILE`), from a `ConfigDescription`, types and JSDoc only and the same text for the same description. It holds a record type per collection and `CompanyApi`: `<apiNameOf(workflow)>: WorkflowFn<input>` per workflow, built-ins too; `<vendor>: { <resource>: { <op>: OpFn<input> } }` for the operations `direct` marks; `data: { <collection>: CollectionApi<record> }`; each member documented from its title or description. Then it augments `sanoma/site`'s `Register`. Inputs are typed from their JSON Schema for the kinds a start form draws, constants, and unions of them (`null` among them); anything else, and every output, is `unknown`. A workflow with `describeConfig`'s api-name problem is left out (`problems`). `writeApi` writes only when the text differs (`wrote`).
|
|
576
585
|
- `SanomaClient`: call an operation directly (`call`, see [Direct calls](#direct-calls)), start runs, list them (`runs({ limit, status, workflow })`) or read a workflow's last result (`latest`), read a run's ledger and approvals, decide approvals, with the checks described above, and start a drift check or an apply and read its report (`drift`, `driftReport`, `apply`, `applyReport`, see [Drift](#drift) and [Apply](#apply)).
|
|
577
586
|
- `APPROVALS_EVENT` and `decisionEventOf(approvalId)`: the DBOS events a run publishes its approvals and each decision on, for a UI that reads DBOS directly. `ApprovalMessage` is the zod schema of a decision as a run reads it. `RunArgs` is what a run receives: its input, `startedBy` and, for a sandbox run, its scenario as `sandbox`.
|
package/dist/cedar.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "./config.ts";
|
|
2
2
|
import { type Policy } from "./policy.ts";
|
|
3
|
-
import { type PeopleResource } from "./shared.ts";
|
|
3
|
+
import { ANONYMOUS, type PeopleResource, VISITORS } from "./shared.ts";
|
|
4
4
|
/** Where a company's policies are, under its root. */
|
|
5
5
|
export declare const POLICIES_DIR = "policies";
|
|
6
6
|
/** One policy, as loaded: its id, its text and where it is written, and the annotations Sanoma reads. */
|
|
@@ -20,6 +20,8 @@ export interface CedarPolicySource {
|
|
|
20
20
|
title?: string;
|
|
21
21
|
/** `@reason(...)`: the message of its deny. */
|
|
22
22
|
reason?: string;
|
|
23
|
+
/** Whom a `permit` is for, when its principal is exactly `in Sanoma::Group::"visitors"` or `== Sanoma::Principal::"anonymous"`. */
|
|
24
|
+
scope?: typeof VISITORS | typeof ANONYMOUS;
|
|
23
25
|
}
|
|
24
26
|
/** What is wrong in the policies, at a file, line and column when Cedar says where. */
|
|
25
27
|
export interface PolicyProblem {
|
|
@@ -50,16 +52,22 @@ export declare function loadPolicies(files: readonly {
|
|
|
50
52
|
};
|
|
51
53
|
/**
|
|
52
54
|
* The policies checked against the schema of the resolved config (`validate`, strict), and what
|
|
53
|
-
* Cedar cannot check: a `permit`
|
|
54
|
-
*
|
|
55
|
+
* Cedar cannot check: a `permit` for anyone but the visitors or `anonymous` (the runtime's is the
|
|
56
|
+
* only one for people and agents), and a `@hold` naming neither a declared group, nor a declared
|
|
57
|
+
* person, nor `on-behalf-of`. In the order written, each at its
|
|
55
58
|
* file, line and column, once per place: a policy scoped to every action fails the same way for each.
|
|
56
59
|
*/
|
|
57
60
|
export declare function validatePolicies(resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">, policies: readonly CedarPolicySource[], people: readonly PeopleResource[]): PolicyProblem[];
|
|
58
61
|
/**
|
|
59
62
|
* The policy a repo's Cedar files make, the runtime's `permit` added; its `version` a hash of the
|
|
60
|
-
* policies' ids and text.
|
|
61
|
-
*
|
|
63
|
+
* policies' ids and text. The runtime's permit leaves visitors out (`isVisitor`) of a run's start,
|
|
64
|
+
* a direct call (a call of the `call` workflow) and a preview not `inRun`: those are asked of the
|
|
65
|
+
* company's permits alone first. Any other call in a run is decided as anyone's, so a visitor let
|
|
66
|
+
* start a run makes its calls, forbids holding still. It decides each call with
|
|
67
|
+
* `statefulIsAuthorized` over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
|
|
62
68
|
*
|
|
69
|
+
* - a visitor's start or direct call no company `permit` allows: `deny`, `VISITOR_DENIED`, never
|
|
70
|
+
* held, so a visitor cannot ask anyone for what no policy lets it do;
|
|
63
71
|
* - allow: `allow`, with the policies that allowed as `reasons`;
|
|
64
72
|
* - deny, a determining `forbid` without `@hold`: `deny` with its `@reason`, or "Refused by <id>";
|
|
65
73
|
* - deny by `@hold` forbids only: each lifted once an approval addressed to its approver and
|
|
@@ -74,14 +82,23 @@ export declare function validatePolicies(resolved: Pick<ResolvedConfig, "ops" |
|
|
|
74
82
|
* `preview` asks the same ahead of a call, its context unknown; `start`, about a run's start.
|
|
75
83
|
*/
|
|
76
84
|
export declare function cedarPolicy(resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">, policies: readonly CedarPolicySource[], people: readonly PeopleResource[]): Policy;
|
|
85
|
+
/**
|
|
86
|
+
* Whether a company `permit` could let a visitor take `action`, in its action group `group`
|
|
87
|
+
* (`start`, or its operation's `effect:<effect>`): asked of the permits whose `scope` is in
|
|
88
|
+
* `scopes`, each with its principal taken as its scope says and its `when` and `unless` left out,
|
|
89
|
+
* on the workflow a start names, or on any target. False means none can apply, so a visitor's
|
|
90
|
+
* start or direct call of it is refused whatever its input (`VISITOR_DENIED`).
|
|
91
|
+
*/
|
|
92
|
+
export declare function visitorPermits(policies: readonly CedarPolicySource[]): (scopes: ReadonlySet<string>, action: string, group: string) => boolean;
|
|
77
93
|
/**
|
|
78
94
|
* The policy of the repo at `root`: its `policies/**\/*.cedar` over `resolved` and the people its
|
|
79
95
|
* data files declare (`readPeople`, `cedarPolicy`), or `allowAll` when it has none. With problems
|
|
80
96
|
* (`loadPolicies`, `validatePolicies`), each a printable line (`file:line:col: <policy id>:
|
|
81
97
|
* <message>`), a policy that refuses every call, start and preview naming the first: a repo never
|
|
82
|
-
* runs on part of its rules.
|
|
98
|
+
* runs on part of its rules. `policies` are what it is made from: undefined while they have problems.
|
|
83
99
|
*/
|
|
84
100
|
export declare function policyAt(root: string | undefined, resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">): {
|
|
85
101
|
policy: Policy;
|
|
102
|
+
policies?: CedarPolicySource[];
|
|
86
103
|
problems: string[];
|
|
87
104
|
};
|
package/dist/cedar.js
CHANGED
|
@@ -3,17 +3,20 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { join, relative, sep } from "node:path";
|
|
4
4
|
import { locator } from "./ast.js";
|
|
5
5
|
import { filesUnder } from "./paths.js";
|
|
6
|
-
import { allow, allowAll, approve, approvedFor, deny, denyAll, } from "./policy.js";
|
|
7
|
-
import { readPeople } from "./resources.js";
|
|
6
|
+
import { allow, allowAll, approve, approvedFor, deny, denyAll, VISITOR_DENIED, } from "./policy.js";
|
|
7
|
+
import { admit, readPeople } from "./resources.js";
|
|
8
8
|
import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf } from "./schema.js";
|
|
9
|
-
import { approverLabel, approverOf, DATA_VENDOR, groupNamesOf, principalIdsOf, principalOf, problemLine, WORKFLOW_ACTION, } from "./shared.js";
|
|
9
|
+
import { ANONYMOUS, approverLabel, approverOf, CALL_WORKFLOW, DATA_VENDOR, groupNamesOf, isVisitor, principalIdsOf, principalOf, problemLine, VISITORS, WORKFLOW_ACTION, } from "./shared.js";
|
|
10
10
|
// A company's policies in Cedar (`policies/**/*.cedar`), decided with `@cedar-policy/cedar-wasm`
|
|
11
11
|
// against the schema of its resolved config (`schemaOf`). Allow is the default: the runtime adds
|
|
12
|
-
// the one `permit
|
|
13
|
-
// `@hold(...)` and a deny otherwise.
|
|
12
|
+
// the one `permit` for people and agents, so a company's policies are `forbid`s, each a hold when
|
|
13
|
+
// it carries `@hold(...)` and a deny otherwise, and the `permit`s that let visitors in.
|
|
14
14
|
/** Where a company's policies are, under its root. */
|
|
15
15
|
export const POLICIES_DIR = "policies";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The runtime's own policy: allow whatever no `forbid` refuses. A visitor's start or direct call
|
|
18
|
+
* is asked of the company's permits first (`cedarPolicy`): the runtime's permit leaves visitors out there.
|
|
19
|
+
*/
|
|
17
20
|
const ALLOW_ID = "allow-by-default";
|
|
18
21
|
const ALLOW = "permit (principal, action, resource);";
|
|
19
22
|
/** The `@hold` that addresses the principal's `onBehalfOf`. */
|
|
@@ -68,7 +71,7 @@ export function loadPolicies(files) {
|
|
|
68
71
|
problems.push(...answer.errors.map((e) => problemOf(e, file, part, start)));
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
|
-
const { effect, annotations = {} } = answer.json;
|
|
74
|
+
const { effect, principal, annotations = {} } = answer.json;
|
|
72
75
|
const id = annotations.id ?? `${file}#${n + 1}`;
|
|
73
76
|
if (ids.has(id)) {
|
|
74
77
|
const whose = id === ALLOW_ID ? "the runtime's own policy's" : "another policy's";
|
|
@@ -85,15 +88,29 @@ export function loadPolicies(files) {
|
|
|
85
88
|
...(annotations.hold !== undefined && { hold: annotations.hold }),
|
|
86
89
|
...(annotations.title !== undefined && { title: annotations.title }),
|
|
87
90
|
...(annotations.reason !== undefined && { reason: annotations.reason }),
|
|
91
|
+
...scopeOf(principal),
|
|
88
92
|
});
|
|
89
93
|
});
|
|
90
94
|
}
|
|
91
95
|
return { policies, problems };
|
|
92
96
|
}
|
|
97
|
+
/** A permit's `scope`: whom its principal names, when that is exactly the visitors or `anonymous`. */
|
|
98
|
+
function scopeOf(principal) {
|
|
99
|
+
if (!("entity" in principal) || !("id" in principal.entity))
|
|
100
|
+
return {};
|
|
101
|
+
const { op, entity } = principal;
|
|
102
|
+
const is = ({ type, id }) => entity.type === type && entity.id === id;
|
|
103
|
+
if (op === "in" && is(uid("Group", VISITORS)))
|
|
104
|
+
return { scope: VISITORS };
|
|
105
|
+
if (op === "==" && is(uid("Principal", ANONYMOUS)))
|
|
106
|
+
return { scope: ANONYMOUS };
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
93
109
|
/**
|
|
94
110
|
* The policies checked against the schema of the resolved config (`validate`, strict), and what
|
|
95
|
-
* Cedar cannot check: a `permit`
|
|
96
|
-
*
|
|
111
|
+
* Cedar cannot check: a `permit` for anyone but the visitors or `anonymous` (the runtime's is the
|
|
112
|
+
* only one for people and agents), and a `@hold` naming neither a declared group, nor a declared
|
|
113
|
+
* person, nor `on-behalf-of`. In the order written, each at its
|
|
97
114
|
* file, line and column, once per place: a policy scoped to every action fails the same way for each.
|
|
98
115
|
*/
|
|
99
116
|
export function validatePolicies(resolved, policies, people) {
|
|
@@ -120,7 +137,16 @@ export function validatePolicies(resolved, policies, people) {
|
|
|
120
137
|
.toSorted((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
|
|
121
138
|
const ours = [];
|
|
122
139
|
if (p.effect === "permit") {
|
|
123
|
-
|
|
140
|
+
if (p.scope === undefined) {
|
|
141
|
+
ours.push({
|
|
142
|
+
...place,
|
|
143
|
+
message: 'a permit is allowed only for visitors: its principal must be `in Sanoma::Group::"visitors"` or ' +
|
|
144
|
+
'`== Sanoma::Principal::"anonymous"`; for people and agents every call no forbid refuses is allowed already',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else if (p.hold !== undefined) {
|
|
148
|
+
ours.push({ ...place, message: "a permit holds nothing: write the @hold on a forbid" });
|
|
149
|
+
}
|
|
124
150
|
}
|
|
125
151
|
else if (p.hold !== undefined && p.hold !== ON_BEHALF_OF && approverOf(p.hold, people) === undefined) {
|
|
126
152
|
ours.push({ ...place, message: `@hold("${p.hold}") names no declared group or person, nor "${ON_BEHALF_OF}"` });
|
|
@@ -130,9 +156,14 @@ export function validatePolicies(resolved, policies, people) {
|
|
|
130
156
|
}
|
|
131
157
|
/**
|
|
132
158
|
* The policy a repo's Cedar files make, the runtime's `permit` added; its `version` a hash of the
|
|
133
|
-
* policies' ids and text.
|
|
134
|
-
*
|
|
159
|
+
* policies' ids and text. The runtime's permit leaves visitors out (`isVisitor`) of a run's start,
|
|
160
|
+
* a direct call (a call of the `call` workflow) and a preview not `inRun`: those are asked of the
|
|
161
|
+
* company's permits alone first. Any other call in a run is decided as anyone's, so a visitor let
|
|
162
|
+
* start a run makes its calls, forbids holding still. It decides each call with
|
|
163
|
+
* `statefulIsAuthorized` over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
|
|
135
164
|
*
|
|
165
|
+
* - a visitor's start or direct call no company `permit` allows: `deny`, `VISITOR_DENIED`, never
|
|
166
|
+
* held, so a visitor cannot ask anyone for what no policy lets it do;
|
|
136
167
|
* - allow: `allow`, with the policies that allowed as `reasons`;
|
|
137
168
|
* - deny, a determining `forbid` without `@hold`: `deny` with its `@reason`, or "Refused by <id>";
|
|
138
169
|
* - deny by `@hold` forbids only: each lifted once an approval addressed to its approver and
|
|
@@ -150,14 +181,22 @@ export function cedarPolicy(resolved, policies, people) {
|
|
|
150
181
|
const cedar = cedarWasm();
|
|
151
182
|
const schema = schemaOf(resolved, people);
|
|
152
183
|
const staticPolicies = { ...Object.fromEntries(policies.map((p) => [p.id, p.text])), [ALLOW_ID]: ALLOW };
|
|
184
|
+
// What lets a visitor in at a start or a direct call: the company's permits alone, the forbids aside.
|
|
185
|
+
const permits = Object.fromEntries(policies.filter((p) => p.effect === "permit").map((p) => [p.id, p.text]));
|
|
153
186
|
const version = hash(staticPolicies);
|
|
154
|
-
// Cedar caches both per thread, by name: one for these policies over this schema.
|
|
187
|
+
// Cedar caches both per thread, by name: one for these policies over this schema, and its permits.
|
|
155
188
|
const set = `sanoma-${hash({ staticPolicies, schema })}`;
|
|
156
|
-
|
|
189
|
+
const permitsSet = `${set}-permits`;
|
|
190
|
+
for (const answer of [
|
|
191
|
+
cedar.preparsePolicySet(set, { staticPolicies }),
|
|
192
|
+
cedar.preparsePolicySet(permitsSet, { staticPolicies: permits }),
|
|
193
|
+
cedar.preparseSchema(set, schema),
|
|
194
|
+
]) {
|
|
157
195
|
if (answer.type === "failure") {
|
|
158
196
|
throw new Error(`The Cedar policies do not parse: ${answer.errors.map(messageOf).join("; ")}`);
|
|
159
197
|
}
|
|
160
198
|
}
|
|
199
|
+
const hasPermits = Object.keys(permits).length > 0;
|
|
161
200
|
const directory = directoryOf(people);
|
|
162
201
|
/**
|
|
163
202
|
* The determining `forbid`s, in the order written, each with whom it holds for: the first that
|
|
@@ -192,26 +231,44 @@ export function cedarPolicy(resolved, policies, people) {
|
|
|
192
231
|
entities,
|
|
193
232
|
};
|
|
194
233
|
};
|
|
195
|
-
/**
|
|
196
|
-
|
|
234
|
+
/**
|
|
235
|
+
* May `actor` take `action` on `target` with `context`, given the run's `approvals` so far; at a
|
|
236
|
+
* start or a direct call (`atStart`), a visitor only where a company permit lets it in.
|
|
237
|
+
*/
|
|
238
|
+
const authorize = (actor, action, target, context, approvals, atStart) => {
|
|
197
239
|
const principal = directory.principal(actor);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
/** The answer of the policy set `id`. */
|
|
241
|
+
const ask = (id) => {
|
|
242
|
+
const check = (asked) => cedar.statefulIsAuthorized({
|
|
243
|
+
...requestOf(principal, action, target, () => asked),
|
|
244
|
+
preparsedSchemaName: set,
|
|
245
|
+
validateRequest: true,
|
|
246
|
+
preparsedPolicySetId: id,
|
|
247
|
+
});
|
|
248
|
+
const answer = check(context);
|
|
249
|
+
// A record saved under fields since changed does not fit the schema as `before`. When the rest
|
|
250
|
+
// of the call does, it is asked again without the schema (the context is plain values), the
|
|
251
|
+
// action's groups given as entities: a policy reading a field the record lacks fails, so denies.
|
|
252
|
+
const { before, ...rest } = context;
|
|
253
|
+
if (answer.type === "success" || before === undefined || check(rest).type === "failure")
|
|
254
|
+
return answer;
|
|
210
255
|
const request = requestOf(principal, action, target, () => context);
|
|
211
256
|
const groups = schema[NAMESPACE]?.actions[action]?.memberOf ?? [];
|
|
212
257
|
request.entities.push({ uid: request.action, attrs: {}, parents: groups.map((g) => uid("Action", g.id)) });
|
|
213
|
-
|
|
258
|
+
return cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: id });
|
|
259
|
+
};
|
|
260
|
+
// Refused, never held: a visitor cannot ask anyone for what no policy lets it do.
|
|
261
|
+
if (atStart && isVisitor(principal)) {
|
|
262
|
+
const permitted = hasPermits ? ask(permitsSet) : undefined;
|
|
263
|
+
// A request the schema refuses is told below, as anyone's; a permit that fails, by its id.
|
|
264
|
+
if (permitted?.type !== "failure" && permitted?.response.decision !== "allow") {
|
|
265
|
+
const failed = permitted?.response.diagnostics.errors[0];
|
|
266
|
+
if (failed)
|
|
267
|
+
return deny(`${failed.policyId} failed: ${messageOf(failed.error)}`, [failed.policyId]);
|
|
268
|
+
return deny(VISITOR_DENIED);
|
|
269
|
+
}
|
|
214
270
|
}
|
|
271
|
+
const answer = ask(set);
|
|
215
272
|
// A request the schema refuses (an input Cedar cannot hold) is denied, not an error of the call.
|
|
216
273
|
if (answer.type === "failure")
|
|
217
274
|
return deny(`The call does not fit the Cedar schema: ${answer.errors.map(messageOf).join("; ")}`);
|
|
@@ -233,19 +290,30 @@ export function cedarPolicy(resolved, policies, people) {
|
|
|
233
290
|
const title = waiting.p.title ?? `${action} needs ${approverLabel(approver)}`;
|
|
234
291
|
return approve(approver, { title, reasons: [waiting.p.id] });
|
|
235
292
|
};
|
|
236
|
-
const decide = (call) => authorize(call.actor, call.op.id, call.target, { input: call.input, ...(call.before !== undefined && { before: call.before }) }, call.run.approvals);
|
|
237
|
-
const start = (actor, workflow, input, approvals = []) => authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals);
|
|
238
|
-
const preview = (requests) => requests.map(({ principal: actor, action, resource }) => {
|
|
293
|
+
const decide = (call) => authorize(call.actor, call.op.id, call.target, { input: call.input, ...(call.before !== undefined && { before: call.before }) }, call.run.approvals, call.run.workflow === CALL_WORKFLOW);
|
|
294
|
+
const start = (actor, workflow, input, approvals = []) => authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals, true);
|
|
295
|
+
const preview = (requests) => requests.map(({ principal: actor, action, resource, inRun }) => {
|
|
239
296
|
const principal = directory.principal(actor);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
297
|
+
const partial = (asked) => {
|
|
298
|
+
const answer = cedar.isAuthorizedPartial({
|
|
299
|
+
...requestOf(principal, action.id, resource, unknown),
|
|
300
|
+
schema,
|
|
301
|
+
validateRequest: false,
|
|
302
|
+
policies: { staticPolicies: asked },
|
|
303
|
+
});
|
|
304
|
+
if (answer.type === "failure")
|
|
305
|
+
throw new Error(answer.errors.map(messageOf).join("; "));
|
|
306
|
+
return answer.response;
|
|
307
|
+
};
|
|
308
|
+
if (!inRun && isVisitor(principal)) {
|
|
309
|
+
const gate = hasPermits ? partial(permits) : undefined;
|
|
310
|
+
if (gate?.decision !== "allow") {
|
|
311
|
+
// A permit the input decides: the answer depends on it, whatever the rest says.
|
|
312
|
+
const open = policies.find((p) => gate?.nontrivialResiduals.includes(p.id));
|
|
313
|
+
return open ? { answer: "depends", rule: ruleOf(open) } : { answer: "deny" };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const { decision, mustBeDetermining, nontrivialResiduals, residuals } = partial(staticPolicies);
|
|
249
317
|
if (decision === "allow")
|
|
250
318
|
return { answer: "allow" };
|
|
251
319
|
if (decision === "deny") {
|
|
@@ -261,22 +329,58 @@ export function cedarPolicy(resolved, policies, people) {
|
|
|
261
329
|
});
|
|
262
330
|
return Object.freeze(Object.assign(decide, { version, preview, start }));
|
|
263
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Whether a company `permit` could let a visitor take `action`, in its action group `group`
|
|
334
|
+
* (`start`, or its operation's `effect:<effect>`): asked of the permits whose `scope` is in
|
|
335
|
+
* `scopes`, each with its principal taken as its scope says and its `when` and `unless` left out,
|
|
336
|
+
* on the workflow a start names, or on any target. False means none can apply, so a visitor's
|
|
337
|
+
* start or direct call of it is refused whatever its input (`VISITOR_DENIED`).
|
|
338
|
+
*/
|
|
339
|
+
export function visitorPermits(policies) {
|
|
340
|
+
const cedar = cedarWasm();
|
|
341
|
+
const permits = policies.flatMap(({ id, effect, scope, text }) => {
|
|
342
|
+
if (effect !== "permit" || scope === undefined)
|
|
343
|
+
return [];
|
|
344
|
+
const answer = cedar.policyToJson(text);
|
|
345
|
+
if (answer.type !== "success")
|
|
346
|
+
return [];
|
|
347
|
+
const json = { ...answer.json, principal: { op: "All" }, conditions: [] };
|
|
348
|
+
return [{ id, scope, json }];
|
|
349
|
+
});
|
|
350
|
+
return (scopes, action, group) => {
|
|
351
|
+
const asked = permits.filter((p) => scopes.has(p.scope));
|
|
352
|
+
if (!asked.length)
|
|
353
|
+
return false;
|
|
354
|
+
const uidOf = uid("Action", action);
|
|
355
|
+
const answer = cedar.isAuthorizedPartial({
|
|
356
|
+
principal: uid("Principal", ANONYMOUS),
|
|
357
|
+
action: uidOf,
|
|
358
|
+
// An operation's target is the call's: unknown, so a permit naming one may apply.
|
|
359
|
+
resource: action.startsWith(WORKFLOW_ACTION) ? uid("Workflow", action.slice(WORKFLOW_ACTION.length)) : null,
|
|
360
|
+
context: {},
|
|
361
|
+
entities: [{ uid: uidOf, attrs: {}, parents: [uid("Action", group)] }],
|
|
362
|
+
policies: { staticPolicies: Object.fromEntries(asked.map((p) => [p.id, p.json])) },
|
|
363
|
+
});
|
|
364
|
+
// A request Cedar cannot evaluate is not a refusal this can be sure of.
|
|
365
|
+
return answer.type === "failure" || answer.response.decision !== "deny";
|
|
366
|
+
};
|
|
367
|
+
}
|
|
264
368
|
/**
|
|
265
369
|
* The policy of the repo at `root`: its `policies/**\/*.cedar` over `resolved` and the people its
|
|
266
370
|
* data files declare (`readPeople`, `cedarPolicy`), or `allowAll` when it has none. With problems
|
|
267
371
|
* (`loadPolicies`, `validatePolicies`), each a printable line (`file:line:col: <policy id>:
|
|
268
372
|
* <message>`), a policy that refuses every call, start and preview naming the first: a repo never
|
|
269
|
-
* runs on part of its rules.
|
|
373
|
+
* runs on part of its rules. `policies` are what it is made from: undefined while they have problems.
|
|
270
374
|
*/
|
|
271
375
|
export function policyAt(root, resolved) {
|
|
272
376
|
const files = root === undefined ? [] : readPolicyFiles(root);
|
|
273
377
|
if (!files.length)
|
|
274
|
-
return { policy: allowAll, problems: [] };
|
|
378
|
+
return { policy: allowAll, policies: [], problems: [] };
|
|
275
379
|
const { people } = readPeople(root);
|
|
276
380
|
const { policies, problems } = loadPolicies(files);
|
|
277
381
|
problems.push(...validatePolicies(resolved, policies, people));
|
|
278
382
|
if (!problems.length)
|
|
279
|
-
return { policy: cedarPolicy(resolved, policies, people), problems: [] };
|
|
383
|
+
return { policy: cedarPolicy(resolved, policies, people), policies, problems: [] };
|
|
280
384
|
const lines = problems.map(({ policy, ...p }) => problemLine({ ...p, message: policy ? `${policy}: ${p.message}` : p.message }));
|
|
281
385
|
return { policy: denyAll(`The policies do not hold: ${lines[0]}`), problems: lines };
|
|
282
386
|
}
|
|
@@ -298,6 +402,9 @@ const holdOf = (hold, principal, people) => hold === ON_BEHALF_OF ? principal.on
|
|
|
298
402
|
function directoryOf(people) {
|
|
299
403
|
const groups = new Set(groupNamesOf(people));
|
|
300
404
|
const principal = (actor) => {
|
|
405
|
+
// A visitor is told by its id, as `admit` takes it: never in a declared group.
|
|
406
|
+
if (isVisitor(actor))
|
|
407
|
+
return admit(actor, { people, problems: [] });
|
|
301
408
|
// An admitted principal (`admit`) carries its groups as the files say now: those are taken as
|
|
302
409
|
// they are. One without groups is given those declared when the policy was made.
|
|
303
410
|
const declared = actor.groups === undefined ? principalOf(actor.id, people) : undefined;
|