diff --git a/Makefile b/Makefile index 8cb420e42..edc9e0e21 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ test-unit: FORCE # (port 0, mkdtemp dirs, unique schemas) and none rely on fresh module state, # so sharing the registry across files is safe. test-workflow: FORCE - $(BUN) test --timeout 120000 --parallel=$(WF_PARALLEL) --no-isolate tests/workflow-deploy/multistep-signal.test.ts tests/workflow-deploy/hub-link-reconnect.test.ts tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts tests/workflow-deploy/crash-respawn-fifo.test.ts tests/workflow-deploy/crash-loop-latch.test.ts tests/workflow-deploy/on-trigger-signal-delivery.test.ts tests/workflow-deploy/on-trigger-between-events-restart.test.ts tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts tests/workflow-deploy/sleep-restart-keeps-authority.test.ts tests/workflow-deploy/sleep-restart-hub-access.test.ts tests/workflow-deploy/on-trigger-agent-body.test.ts tests/workflow-deploy/on-trigger-action-body.test.ts tests/workflow-deploy/on-trigger-loop-body.test.ts tests/workflow-deploy/single-step-real-agent.test.ts tests/workflow-deploy/map-fan-out-real-agent.test.ts tests/workflow-deploy/multistep-signed-send.test.ts tests/workflow-deploy/single-step-message-input.test.ts tests/workflow-deploy/single-step-posix-tool.test.ts tests/workflow-deploy/single-step-credential-tool.test.ts tests/workflow-deploy/credential-bound-route-deploy.test.ts tests/workflow-deploy/action-step-credential.test.ts tests/workflow-deploy/action-hub-access.test.ts tests/workflow-deploy/tool-hub-access.test.ts tests/workflow-deploy/invoker-hub-access.test.ts tests/workflow-deploy/body-hub-access.test.ts tests/workflow-deploy/revoked-creator-grant-running-run.test.ts tests/workflow-deploy/expired-run-grant-running-run.test.ts tests/workflow-deploy/tarball-route-deploy.test.ts tests/workflow-deploy/folded-tools-failover-real-agent.test.ts tests/workflow-deploy/single-step-grants-bridge.test.ts tests/workflow-deploy/single-step-pinned-ask-tool.test.ts tests/workflow-deploy/single-step-per-run-grants.test.ts tests/workflow-deploy/approval-tracer-roundtrip.test.ts tests/workflow-deploy/scope-always-roundtrip.test.ts tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts tests/workflow-deploy/run-mail-send-real-route.test.ts tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-federation-run-completes.test.ts tests/workflow-deploy/single-step-event-threading.test.ts tests/workflow-deploy/single-step-conversation-durability.test.ts tests/workflow-deploy/multistep-conversation-failover.test.ts tests/workflow-deploy/per-level-pipeline-real-agents.test.ts tests/workflow-deploy/dispatch-orchestrator-real-agents.test.ts tests/workflow-deploy/single-step-full-lifecycle.test.ts tests/workflow-deploy/single-step-mail-loop.test.ts tests/workflow-deploy/single-step-mail-in-reply-to.test.ts tests/workflow-deploy/single-step-mail-wait-wake.test.ts tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts tests/workflow-deploy/single-step-mail-interactive-turns.test.ts tests/workflow-deploy/single-step-mail-flag-expunge.test.ts tests/workflow-deploy/cross-process-custom-adapter.test.ts tests/workflow-deploy/conversation-state-wal.test.ts tests/workflow-deploy/conversation-state-connector-seed.test.ts tests/workflow-deploy/drain-roundtrip.test.ts tests/workflow-deploy/fixtures/child-workflow.test.ts tests/workflow-deploy/child-workflow-roundtrip.test.ts tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-roundtrip.test.ts tests/workflow-deploy/loop-nested-roundtrip.test.ts tests/workflow-deploy/loop-action-roundtrip.test.ts tests/workflow-deploy/all-action-deploy.test.ts tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts tests/workflow-deploy/onfailure-action-roundtrip.test.ts tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts tests/workflow-deploy/childworkflow-restart-resumes.test.ts tests/workflow-deploy/body-action-restart-effect-once.test.ts tests/workflow-deploy/body-action-crash-mid-effect.test.ts tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts tests/workflow-deploy/unresolvable-director.test.ts tests/workflow-deploy/fifo-mail.test.ts tests/workflow-deploy/mail-edge-cases.test.ts tests/workflow-deploy/run-event-batching.test.ts tests/workflow-deploy/walking-skeleton.e2e.test.ts tests/workflow-deploy/asset-source.e2e.test.ts tests/workflow-deploy/asset-tool-restarts.e2e.test.ts tests/workflow-deploy/source-workflow.e2e.test.ts tests/workflow-deploy/source-monorepo.e2e.test.ts tests/workflow-deploy/source-monorepo-many.e2e.test.ts tests/workflow-deploy/source-tamper-reject.e2e.test.ts tests/workflow-deploy/source-catalog.e2e.test.ts tests/workflow-deploy/source-mixed-closure.e2e.test.ts tests/workflow-deploy/source-closure-director.e2e.test.ts tests/workflow-deploy/source-credential.e2e.test.ts tests/workflow-deploy/source-inline-tool.e2e.test.ts tests/workflow-deploy/source-posix-lsp.e2e.test.ts tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-credential-tool.test.ts tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts + $(BUN) test --timeout 120000 --parallel=$(WF_PARALLEL) --no-isolate tests/workflow-deploy/multistep-signal.test.ts tests/workflow-deploy/hub-link-reconnect.test.ts tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts tests/workflow-deploy/crash-respawn-fifo.test.ts tests/workflow-deploy/crash-loop-latch.test.ts tests/workflow-deploy/on-trigger-signal-delivery.test.ts tests/workflow-deploy/on-trigger-between-events-restart.test.ts tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts tests/workflow-deploy/sleep-restart-keeps-authority.test.ts tests/workflow-deploy/sleep-restart-hub-access.test.ts tests/workflow-deploy/on-trigger-agent-body.test.ts tests/workflow-deploy/on-trigger-action-body.test.ts tests/workflow-deploy/on-trigger-loop-body.test.ts tests/workflow-deploy/single-step-real-agent.test.ts tests/workflow-deploy/map-fan-out-real-agent.test.ts tests/workflow-deploy/multistep-signed-send.test.ts tests/workflow-deploy/single-step-message-input.test.ts tests/workflow-deploy/single-step-posix-tool.test.ts tests/workflow-deploy/single-step-credential-tool.test.ts tests/workflow-deploy/credential-bound-route-deploy.test.ts tests/workflow-deploy/action-step-credential.test.ts tests/workflow-deploy/action-hub-access.test.ts tests/workflow-deploy/tool-hub-access.test.ts tests/workflow-deploy/invoker-hub-access.test.ts tests/workflow-deploy/body-hub-access.test.ts tests/workflow-deploy/revoked-creator-grant-running-run.test.ts tests/workflow-deploy/expired-run-grant-running-run.test.ts tests/workflow-deploy/tarball-route-deploy.test.ts tests/workflow-deploy/folded-tools-failover-real-agent.test.ts tests/workflow-deploy/single-step-grants-bridge.test.ts tests/workflow-deploy/single-step-pinned-ask-tool.test.ts tests/workflow-deploy/single-step-per-run-grants.test.ts tests/workflow-deploy/approval-tracer-roundtrip.test.ts tests/workflow-deploy/scope-always-roundtrip.test.ts tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts tests/workflow-deploy/mail-send-denied-real-route.test.ts tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts tests/workflow-deploy/run-mail-send-real-route.test.ts tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-federation-run-completes.test.ts tests/workflow-deploy/single-step-event-threading.test.ts tests/workflow-deploy/single-step-conversation-durability.test.ts tests/workflow-deploy/multistep-conversation-failover.test.ts tests/workflow-deploy/per-level-pipeline-real-agents.test.ts tests/workflow-deploy/dispatch-orchestrator-real-agents.test.ts tests/workflow-deploy/single-step-full-lifecycle.test.ts tests/workflow-deploy/single-step-mail-loop.test.ts tests/workflow-deploy/single-step-mail-in-reply-to.test.ts tests/workflow-deploy/single-step-mail-wait-wake.test.ts tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts tests/workflow-deploy/single-step-mail-interactive-turns.test.ts tests/workflow-deploy/single-step-mail-flag-expunge.test.ts tests/workflow-deploy/cross-process-custom-adapter.test.ts tests/workflow-deploy/conversation-state-wal.test.ts tests/workflow-deploy/conversation-state-connector-seed.test.ts tests/workflow-deploy/drain-roundtrip.test.ts tests/workflow-deploy/fixtures/child-workflow.test.ts tests/workflow-deploy/child-workflow-roundtrip.test.ts tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-roundtrip.test.ts tests/workflow-deploy/loop-nested-roundtrip.test.ts tests/workflow-deploy/loop-action-roundtrip.test.ts tests/workflow-deploy/all-action-deploy.test.ts tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts tests/workflow-deploy/onfailure-action-roundtrip.test.ts tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts tests/workflow-deploy/childworkflow-restart-resumes.test.ts tests/workflow-deploy/body-action-restart-effect-once.test.ts tests/workflow-deploy/body-action-crash-mid-effect.test.ts tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts tests/workflow-deploy/unresolvable-director.test.ts tests/workflow-deploy/fifo-mail.test.ts tests/workflow-deploy/mail-edge-cases.test.ts tests/workflow-deploy/run-event-batching.test.ts tests/workflow-deploy/walking-skeleton.e2e.test.ts tests/workflow-deploy/asset-source.e2e.test.ts tests/workflow-deploy/asset-tool-restarts.e2e.test.ts tests/workflow-deploy/source-workflow.e2e.test.ts tests/workflow-deploy/source-monorepo.e2e.test.ts tests/workflow-deploy/source-monorepo-many.e2e.test.ts tests/workflow-deploy/source-tamper-reject.e2e.test.ts tests/workflow-deploy/source-catalog.e2e.test.ts tests/workflow-deploy/source-mixed-closure.e2e.test.ts tests/workflow-deploy/source-closure-director.e2e.test.ts tests/workflow-deploy/source-credential.e2e.test.ts tests/workflow-deploy/source-inline-tool.e2e.test.ts tests/workflow-deploy/source-posix-lsp.e2e.test.ts tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-credential-tool.test.ts tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts # The core integration pass: inference, hub-api, and db suites that need # real Postgres and the extended timeout. Files run in parallel diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 4cccbda17..b000c330c 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -26,6 +26,7 @@ import { createApp, createAuth, createCorrespondentGrantMinter, + createMailSendAuthorizer, createMailTriggeredRunGrantsMaterializer, createRunGrantsLoader, sweepLapsedDeployerGrants, @@ -296,7 +297,10 @@ export async function createHubServer({ // the same authorization an externally-triggered run gets. Threaded into // the sidecar router as a lookup its `mail.outbound` handler invokes for // each workflow-deployment recipient. - const lookups: SidecarLookups = { + // The relay refuses every recipient without a send authorizer, so the + // type requires one here. + const lookups: SidecarLookups & + Required> = { ...createHubSessionLookups({ db, agentRepoStore, @@ -334,9 +338,13 @@ export async function createHubServer({ // the sending run gains a `mail.accept:principal:` grant so that // party's reply is admitted. Gated on the sending definition having // authored the `correspondent` relation; best-effort, never breaks a send. - mintCorrespondentGrant: createCorrespondentGrantMinter({ + mintCorrespondentGrant: createCorrespondentGrantMinter({ db }), + // Decides which recipients a relayed mail's sender may send to, from its + // `mail.send` grants. It evaluates conditions with its own narrower + // registry, not the one createHubApp uses. + authorizeMailSend: createMailSendAuthorizer({ db, - principalKeyStore, + grantStoreFor: createGrantStore, }), }; diff --git a/bin/workflow-fixture.ts b/bin/workflow-fixture.ts index 2f21b1c17..a3aad4ad1 100644 --- a/bin/workflow-fixture.ts +++ b/bin/workflow-fixture.ts @@ -84,8 +84,8 @@ export const WORKFLOW_RUN_GRANT_RESOURCE = "workflow-run:*"; export const WORKFLOW_RUN_GRANT_ACTION = "manage"; /** - * Authored trigger address. The capability walk derives `mail.address` - * / `mail.send` approvals from this value; the runtime inbound address + * Authored trigger address. The capability walk derives the + * `mail.address` approval from this value; the runtime inbound address * is derived independently from the deployment id at deploy time, so * this only has to be a well-formed address whose domain matches the * Acme tenant domain (`.localhost`) for a coherent approval set. diff --git a/bun.lock b/bun.lock index a3a423c36..bb690edee 100644 --- a/bun.lock +++ b/bun.lock @@ -342,6 +342,7 @@ "isomorphic-git": "catalog:", }, "devDependencies": { + "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", "@intx/hub-sessions": "workspace:*", "hono": "catalog:", diff --git a/docs/API.md b/docs/API.md index a6d70e061..6924ab93b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -426,7 +426,7 @@ Creates a grant targeting either a role or a principal directly. Exactly one of Body: CreateGrant 201: GrantResponse -- Grant created -400: ErrorResponse -- Validation error. A body that fails its schema gets the body validator's error envelope. A body whose `conditions` names an unregistered condition, holds a malformed condition body, or, on a workflow run's principal, holds a condition the run cannot evaluate gets an `ErrorResponse` whose message lists each problem as a JSON array. +400: ErrorResponse -- Validation error. A body that fails its schema gets the body validator's error envelope. A body whose `conditions` names an unregistered condition, holds a malformed condition body, or, on a workflow run's principal, holds a condition the run cannot evaluate gets an `ErrorResponse` whose message lists each problem as a JSON array. A `mail.send` resource that can match no recipient coordinate, a domain pattern that is not in lower case, a `mail.send` grant whose action cannot match `send`, or a pattern outside the `mail.send` namespace that names part of a send resource, such as `mail.send*:domain:`, gets an `ErrorResponse` that names the problem. ### GET /api/tenants/:tenantId/grants/:grantId Get grant details @@ -584,6 +584,7 @@ Body: SendMessage 202: unknown -- Trigger accepted for delivery 400: ErrorResponse -- Attachment validation error. Each variant carries a structured code (too_many_attachments, oversize_attachment, disallowed_mime_type, invalid_attachment_name, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead. +403: ErrorResponse -- The caller lacks `manage` on the run, (`insufficient_grants`) a delegator lacks a grant the definition requires, or (`mail_send_denied`) no `mail.send` grant of the caller allows mail to the run 404: ErrorResponse -- Run not found 409: ErrorResponse -- Run address is not routable, its allocation is no longer active, the run is stopping or terminal, (`deployer_unavailable`) the definition has invoker-sourced grant requirements and the deployer is not recorded or no longer active, or (`workflow_run_principal_inactive`) the first trigger finds the run principal suspended or deactivated 413: ErrorResponse -- Request body exceeds the maximum allowed size @@ -711,6 +712,7 @@ Body: SendMessage 202: unknown -- Trigger accepted for delivery 400: ErrorResponse -- Attachment validation error. Each variant carries a structured code (too_many_attachments, oversize_attachment, disallowed_mime_type, invalid_attachment_name, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead. +403: ErrorResponse -- The caller lacks `manage` on the deployment, (`insufficient_grants`) a delegator lacks a grant the definition requires, or (`mail_send_denied`) no `mail.send` grant of the caller allows mail to the deployment 404: ErrorResponse -- Workflow deployment not found 409: ErrorResponse -- Deployment address is not routable, its allocation is no longer active, its top-level run is stopping or terminal, (`deployer_unavailable`) the definition has invoker-sourced grant requirements and the deployer is not recorded or no longer active, or (`workflow_run_principal_inactive`) the first trigger finds the run principal suspended or deactivated 413: ErrorResponse -- Request body exceeds the maximum allowed size diff --git a/docs/AUTH.md b/docs/AUTH.md index 6944610e1..8a6ac7651 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -129,7 +129,15 @@ role UNIQUE(tenant_id, name) ``` -System roles (owner, admin, member) are created automatically when a tenant is created. They cannot be deleted or renamed. Tenant admins can define additional custom roles. +System roles (owner, admin, member) are created automatically when a tenant is created. They cannot be deleted or renamed. Tenant admins can define additional custom roles. Tenant creation gives the system roles these grants, each an `allow` with origin `system`: + +| Role | Grants | +| ------ | ---------------------------------------------------------------------------- | +| owner | `*` / `*`; `mail.send:tenant:` / `send` | +| admin | `*` / `read`, `create` and `manage`; `mail.send:tenant:` / `send` | +| member | `*` / `read` | + +A `mail.send` check ignores a `*` grant, so the owner needs its own send row. ``` principal_role @@ -162,7 +170,7 @@ The `source` field declares where the delegated authority should come from: A workflow's `mailAccept` `invoker` rule admits mail from the same principal; see INBOUND_MAIL_POLICY.md. -Delegating to a deployment is a trust decision, as it is for a creator. Every principal that can trigger a run of the deployment starts a run that holds the deployer's delegated grants: a principal with `manage` on its anchor run, and every mail sender that inbound admission accepts. +Delegating to a deployment is a trust decision, as it is for a creator. Every principal that can trigger a run of the deployment starts a run that holds the deployer's delegated grants: a principal with `manage` on its anchor run and a `mail.send` grant that allows mail to it, and every mail sender that inbound admission accepts. A requirement can name the run it is materialized for as `workflow-run:self` (`SELF_RUN_RESOURCE` in `@intx/types`, re-exported from `@intx/workflow`). Launch binds it to `workflow-run:`, the top-level run, which child bodies share, before the authority check. The delegator must therefore hold authority over that run id, for example `workflow-run:*` or the run itself. A deployer holds `workflow-run:` / `read` from the deploy, so an invoker-sourced `workflow-run:self` / `read` resolves without a wider grant. The approval records the literal `workflow-run:self`. `self` is valid only as that whole resource. The probe gate refuses a definition whose requirement uses it anywhere else, at install and approve, ignoring case and surrounding whitespace. @@ -182,7 +190,7 @@ When a workflow run is launched, the control plane processes each grant requirem 4. Create a `grant` row on the run's new principal with the appropriate `origin` value 5. Ship the effective grant set to the harness in the deploy frame -The `grantRequirements` field on a workflow definition is a grant requirements manifest — it specifies `GrantRequirement` entries with source annotations, not live grants. Each launch resolves these requirements against the current state of creator, tenant, and invoker authority. +The `grantRequirements` field on a workflow definition is a grant requirements manifest — it specifies `GrantRequirement` entries with source annotations, not live grants. Each launch resolves these requirements against the current state of creator, tenant, and invoker authority. The deploy gate refuses a requirement in a reserved namespace (`mail.accept`, `mail.send`), whose rows come from one source only. A snapshot stored before its namespace was reserved can still carry such a requirement: the first trigger and re-derivation skip it and log a warning. ## Definition Approval and Re-Verification @@ -307,6 +315,7 @@ Resources use a `type:identifier` format with glob support: - `api:stripe:*` -- all Stripe API operations - `federation:*` -- all federation trusts of the tenant - `federation:tnt_abc` -- the federation trust with tenant `tnt_abc` (keyed on the target tenant id, not the trust row id). Establishing a trust checks this resource with `create`, and revoking it checks `manage`. Listing the trusts checks `federation:*` / `read`. +- `mail.send:domain:example.com` -- sending mail to recipients at `example.com` (see Mail send grants) - `*` -- everything Actions are operation verbs: @@ -317,6 +326,7 @@ Actions are operation verbs: - `spend` -- financial operations - `manage` -- update/delete/configure - `recover` -- release a workflow run's capacity whose cleanup stopped, on the caller's word that its copy is gone; only the owner role holds it by default +- `send` -- send mail to a recipient (only on `mail.send` resources) - `*` -- all actions ### Evaluation @@ -348,19 +358,20 @@ Spending limits and threshold-based escalation are planned conditions with no ev - `{ "max_spend_per_day": 100, "currency": "USD" }` -- spending limits (not yet available). - `{ "require_approval_above": 50 }` -- threshold-based escalation (not yet available). -Every grant is evaluated on the hub with this registry. A grant that sits directly on a workflow run's principal is also evaluated where the run is, with narrower registries: +Every grant is evaluated on the hub with this registry. A grant that sits directly on a workflow run's principal is also evaluated where the run is, and a `mail.send` grant on any holder is also evaluated by the hub's mail send check. Each has a narrower registry: | Where | Evaluates | Registry | | ------------------- | ----------------------------------------------------------------------------------------------- | ------------------ | | The run child | a row whose resource pattern can match a resource outside `credential:` | `time_window` only | | The credential gate | a row whose resource pattern can match `credential:` and whose action pattern matches `use` | `tool` only | +| The mail send check | a row in the `mail.send` namespace, on any holder | `time_window` only | A role grant never reaches a run, because a run loads only the rows on its own principal. So a run-principal row may carry `time_window` on a non-credential resource and `tool` on credential use. A row whose patterns can reach both, such as resource `*` with an action pattern that matches `use`, may carry no condition, because neither key is evaluable in both places. The hub checks conditions against every registry that will evaluate the row before it stores the row: - The probe gate (`gateAndFreezeProbeResult`) checks a workflow definition's declared grant requirements, which become rows on the run principal, at install and approve. It refuses the definition with the reason `grant_requirement_conditions_invalid`, before the operator approval checks and before anything is frozen. -- Grant create (`POST`) and update (`PATCH`) check `conditions` for the grant's target, resource, and action. An unregistered key, such as the planned conditions above, a body that does not match its evaluator's shape, or a key that a run cannot evaluate on a run-principal row, gives a 400 that names each problem, and the grant is not stored. A `PATCH` that omits `conditions` does not check or change the stored value. +- Grant create (`POST`) and update (`PATCH`) check `conditions` for the grant's target, resource, and action. An unregistered key, such as the planned conditions above, a body that does not match its evaluator's shape, a key that a run cannot evaluate on a run-principal row, or a key that the mail send check cannot evaluate on a `mail.send` row, gives a 400 that names each problem, and the grant is not stored. A `PATCH` that omits `conditions` does not check or change the stored value. The authorization engine evaluates conditions at runtime: a grant whose registered condition is unmet is skipped. A stored grant that carries an unregistered key or a malformed body still throws and fails the authorization, to surface the misconfiguration. Such a row comes from a path that does not check conditions: a grant materialized (at launch, or when a change to the delegator's grants re-derives the run) from a declared grant requirement that was frozen before the probe gate checked conditions, a direct database write, or a row stored before the check existed. @@ -372,10 +383,36 @@ The sidecar's step grant check evaluates `time_window` against the sidecar's clo Specificity still decides between grants: a broad conditioned `deny` loses to a more specific unconditioned `allow`, as it would on a request where its condition holds. +### Mail send grants + +A `mail.send` grant decides which recipients a principal may send mail to. Two gates apply to mail that a run sends through its sidecar. The sidecar's tool gate decides whether a step may send mail at all: a run's `tool:mail_send` and `tool:mail_reply` grants. The hub's send gate decides which recipients the sender may reach: its `mail.send` grants. The hub checks every send the sidecar relays, from a mail tool, a warm agent's reply, a plugin tool or a director. + +The resource is `mail.send::` with the action `send`. A recipient has these coordinates: + +| Recipient | Coordinates | +| ---------------------------------------- | --------------------------------------------------------------------- | +| A deployment's run address, fired or not | `domain`, `principal` (its run principal), `asset` (if any), `tenant` | +| A user's address | `domain`, `principal`, `tenant` | +| Any other address | `domain` | + +The `domain` id is the part after the `@`, in lower case and without a trailing dot. An address whose domain is not a host name, such as an address literal, has no `domain` coordinate; if it is not a local party either, it matches no grant and is refused. A recipient's coordinates do not depend on its signing key or its run state. + +The hub evaluates the sender's direct and role grants in its own tenant. It reads only grants in the `mail.send` namespace, so a `*`, `mail.*` or `mail.send*` grant never decides a send. The grant that names the most specific coordinate wins: `principal`, then `asset`, then `tenant`, then `domain`, then a grant whose type segment is a pattern, such as `mail.send:*:*`. Inside one tier, the grant with the more specific coordinate pattern wins. The action and the spelling of a namespace-wide pattern do not count, so `mail.send:*` and `mail.send:*:*` rank the same, and so do the actions `send` and `*`. Effect (`deny` > `ask` > `allow`) breaks a tie. An `ask` that wins refuses the send. With no matching grant, the send is refused. The grants API refuses a `mail.send` resource that could never match a coordinate, such as a domain id in upper case or with a trailing dot, a `mail.send` grant whose action cannot match `send`, a `mail.send` grant whose conditions name a key other than `time_window`, and a pattern outside the namespace that names part of a send resource, such as `mail.send*:domain:` or `mail.*:domain:`. The send check has no consumer, so it evaluates only `time_window`. + +A run gets `mail.send:*:*` at its first trigger (see A run's send grant under Grant Revocation). The owner and admin system roles carry `mail.send:tenant:` (see Roles). + +A user starts a run with the REST mail routes (`POST /:runId/mail` under `/workflows` and under `/workflows/runs`). The routes keep the `workflow-run:` `manage` check: `manage` decides who may start a run with the deployer's delegated grants. The trigger then checks the user's `mail.send` grants against the deployment's run address, with the same authorizer the relay uses. A fault while the check resolves the run's coordinates fails the request with `500`, not `403`. A refused mail gets `403` with the code `mail_send_denied`, before the trigger links the run principal, commits a grant or sends anything. No sidecar tool gate applies on this path. + +On the relay, the hub checks a mail before it routes, holds or relays it. A mail it holds until the sender's key or a recipient's run is ready is checked again when the hub re-drives it. A mail already routed to a sidecar is not checked again when the hub redelivers it. It drops each refused recipient and logs it; the other recipients continue. A recipient whose coordinates the hub cannot resolve, such as an ambiguous user address, is refused on its own. It refuses every recipient when the sender address names no deployment with a run principal, or when the check fails. The check does not read the sender's run principal status, so mail that a run sent before it ended still goes. To stop a run's sends, deny or remove its send grant. A refused recipient gets no correspondent grant, and the sending agent gets no bounce. The sidecar also sends the hub an audit copy of each mail, which the hub stores in each recipient's session and shows to that recipient's subscribers. The hub checks the audit copy in the same way and stores it only for the recipients the check allows. The sender's own record of the mail stays. + +**State that predates the send gate**: A data migration (`0124_mail_send_backfill`) keeps the mail that existing deployments and users can send. It gives each deployment anchor whose run principal is linked the `mail.send:*:*` row that the first trigger stages. It gives each holder of a live `allow` grant that can match `workflow-run:` / `manage` a `mail.send:tenant:` row, with the holder, origin, conditions and expiry of the source grant. It skips `deny` and `ask` grants, grants held directly by a run principal, and grants with a condition other than `time_window`. Stop the old hub before the migration runs, and start the new hub after it. An anchor that the old hub links after the migration, or a tenant that it creates, gets no send rows, and the new hub refuses its sends. + ## Grant Revocation Grant revocation is policy-driven with a default of fail-secure. +**A run's send grant**: At the run's first trigger the hub also writes one `mail.send:*:*` / `send` / `allow` row on the run principal, for every deployment. It allows sends to every recipient that has a coordinate: a local party, or an address whose domain is a host name. It does not decide whether a step may send mail; the run's `tool:mail_send` and `tool:mail_reply` grants decide that. A `deny` row narrows it: any `mail.send` deny that matches the recipient refuses it, whatever its action or spelling. A narrower `allow` row does not, because this row still allows every recipient that the narrower row does not name. To allow only some recipients, an operator removes this row and adds narrower `allow` rows. + **Delegated grants follow their delegators**: A run's grant requirements are delegated from its creator (the creator of the definition's asset) or its invoker (the deployment's deployer). At the run's first trigger the hub writes one row on the run principal for each requirement the delegator allows. The run principal exists from deploy, so an operator can grant to it before the first trigger; those rows stay, the first trigger sends them with the rows it writes, and an operator `mail.accept` deny among them refuses the first mail. An operator who suspends or deactivates the run principal before the first trigger makes the trigger fail with `workflow_run_principal_inactive`. After that, the hub re-derives those rows whenever a delegator's grants change, so a running run holds what its delegators hold now: - A requirement the delegator no longer allows loses the run's `allow` rows for it. This includes an `ask` that a standing approval changed to `allow`, because that `allow` exceeds what the delegator holds. The run's `deny` and `ask` rows for it stay as floors, with no expiry. @@ -384,7 +421,7 @@ Grant revocation is policy-driven with a default of fail-secure. **Row expiry**: A requirement's `allow` row expires at the first expiry of a delegator grant after which the delegator's grants no longer allow the requirement, or never if no such expiry exists. When the delegator's `allow` grants for the requirement stand alone, that is the latest of their expiries. A specific `allow` that overrides a broader `deny` ends the row when the specific `allow` ends. A role grant with an expiry counts the same as a direct grant. An invoker `allow` row also ends 24 hours after deploy at the latest: the limit counts from the creation of the run principal, which deploy creates. A run whose first trigger comes more than 24 hours after deploy therefore gets no invoker `allow` rows. A `deny` or `ask` row is a floor: it restricts the run, so it has no expiry. After the 24-hour limit an invoker requirement counts as lapsed: its `ask` row stays, and an `allow` row for it is deleted at the next re-derivation. A regained `tool:` row that a standing `always` approval makes `allow` gets the `allow` expiry. A standing (`always`) resolution that changes a requirement row's `ask` to `allow` or `deny` re-derives the run in the same request, so the row gets the expiry of its new effect at once. The hub's REST checks and the run's child both skip a row after its expiry; the child compares the expiry with its own clock. Re-derivation deletes a requirement's expired rows, so a delegator grant renewed after it expired gives the row back. A run that was live before the hub gave delegated rows these expiries keeps its earlier row expiries until its next re-derivation: a creator `allow` row with no expiry, and an invoker row, `allow` or floor, that ends 24 hours after the first trigger. -Only the rows that grant requirements produce depend on a delegator. The runtime `tool:` and `effect:` rows, the `mail.accept` rows and the credential-use rows come from the deploy approval, and a delegator change does not touch them. A requirement whose row has the same key as a runtime row is also left alone. +Only the rows that grant requirements produce depend on a delegator. The runtime `tool:` and `effect:` rows, the `mail.accept` rows and the credential-use rows come from the deploy approval, and a delegator change does not touch them. A delegator change does not touch the `mail.send` rows either: the deploy gate refuses a grant requirement in that namespace. A requirement whose row has the same key as a runtime row is also left alone. These routes re-derive the runs that delegate from the principals they change: @@ -500,7 +537,7 @@ On user registration: 1. Create a tenant whose slug is derived from the user's email address and reduced to a single DNS label. The address itself is not a legal slug, because `TenantSlug` and `tenant_slug_dns_label_check` both reject `@` and `.` (see Tenant Schema). 2. Create a principal for the user in that tenant (`kind = 'user'`). 3. Assign the system `owner` role. -4. The owner role includes a broad default grant: `resource = "*", action = "*", effect = "allow"`. +4. The owner role includes the default owner grants listed under Roles: a broad grant (`resource = "*", action = "*", effect = "allow"`) and the tenant send grant. The personal tenant has the same authorization machinery as any other tenant. When the user creates workflows there, those runs get principals and grants through the same system. The onboarding UX is simple, but the underlying model is uniform. diff --git a/docs/INBOUND_MAIL_POLICY.md b/docs/INBOUND_MAIL_POLICY.md index 407050605..a97b45de0 100644 --- a/docs/INBOUND_MAIL_POLICY.md +++ b/docs/INBOUND_MAIL_POLICY.md @@ -200,17 +200,19 @@ mail that fires a run is checked against the run's staged grants before the run exists. Later mail is checked against the grants that `collectGrants` returns for the run principal: its direct grants and the grants of its roles in the run's tenant. An operator can therefore refuse a sender with a `deny` on the -run principal or one of its roles. The deny must use the action `accept`, the -action of the launch and `correspondent` grants. Specificity counts the action, -so a `deny` with the action `*` ranks below an `accept` allow at the same -coordinate and does not override it. +run principal or one of its roles. The deny's action must match `accept`, the +action of the launch and `correspondent` grants. The action does not change the +rank, so a `deny` with the action `*` overrides an `accept` allow at the same +coordinate. The grants that match any of the sender's coordinates form one set, and one grant wins. The order is: 1. The coordinate the grant names: `principal`, then `asset`, then `tenant`, then a namespace-wide grant such as `mail.accept:*`. -2. Pattern specificity. +2. The specificity of the coordinate pattern. The action and the `:` + separators do not count, so `mail.accept:*` and `mail.accept:*:*` rank the + same. 3. Effect: `deny`, then `ask`, then `allow`. The coordinate type ranks first, before pattern specificity. An @@ -226,8 +228,8 @@ admits mail. A namespace-wide grant ranks below every coordinate, so a `deny mail.accept:*` does not close a run to mail: each launch grant names a coordinate and outranks -it. An exact `deny` with the action `accept` on a coordinate that the run's -grants name refuses that coordinate, for example +it. An exact `deny` on a coordinate that the run's grants name refuses that +coordinate, for example `deny mail.accept:principal:` for the invoker and `deny mail.accept:tenant:` for the tenant rule. @@ -236,8 +238,8 @@ A run with the `correspondent` rule gets a new exact outranks every broader deny: `deny mail.accept:*`, `deny mail.accept:tenant:` and `deny mail.accept:principal:*`. So a broad deny does not close the run to the parties it mails later. An exact -`deny mail.accept:principal:` with the action `accept` refuses that -party, whether the operator writes it before or after the run mails the party. +`deny mail.accept:principal:` refuses that party, whether the operator +writes it before or after the run mails the party. To close the run to every party, end the run. ### State that predates the sender gate diff --git a/docs/MESSAGE.md b/docs/MESSAGE.md index 129d3ce77..50046735d 100644 --- a/docs/MESSAGE.md +++ b/docs/MESSAGE.md @@ -701,6 +701,8 @@ Returns on error: `{ error: string, code: string }`. Error codes: `invalid_argum The send opens no correlation of its own, whatever `correlationId` it carries. To await a correlated response, call `mail.wait` after the send. +The hub decides which recipients a run may send to from the run principal's `mail.send` grants (see `docs/AUTH.md`). The sidecar relays the mail without waiting for that decision, so a send to a recipient the hub refuses still returns `{ messageId }`. The hub drops the mail for that recipient, does not store its audit copy for that recipient, and logs it; no bounce reaches the agent. The same holds for `mail.reply` and for a warm agent's reply. + The `correlationId`, the `subject`, the `inReplyTo` and each address in `to` become a header field body, which RFC 5322 § 2.2 allows no CR and no LF, so a value carrying either is rejected as `invalid_arguments` before the send. RFC 5322 § 3.6.4 gives `In-Reply-To` as `1*msg-id` and § 3.4 gives the `To` field body as an address list, so a blank `inReplyTo` and a blank recipient each name nothing and are rejected there too, as is an empty `to` array, which names no destination at all. Whether a recipient address resolves is the transport's to answer, not the tool's: an unresolvable one is a `send_failed`. The tool constrains nothing else about the correlation id: the correlation matches on the whole string. **mail.reply** — Reply to a specific message. Convenience wrapper around `mail.send` that automatically sets `inReplyTo` and extends the `References` chain from the parent message. diff --git a/docs/WORKFLOW_AUTHORING.md b/docs/WORKFLOW_AUTHORING.md index 2881387dc..fd7606af9 100644 --- a/docs/WORKFLOW_AUTHORING.md +++ b/docs/WORKFLOW_AUTHORING.md @@ -424,9 +424,12 @@ reserved, not implemented), and rejects an `inboundMailPolicy` on a workflow with no mail trigger. A `mailAccept` needs no mail trigger: every run has an address, so a workflow of any trigger may declare who it accepts mail from. `defineWorkflow` also rejects a grant requirement in the `mail.accept` -namespace, so `mailAccept` is the only way to author an accept-rule. Every -key of `inboundMailPolicy` defaults to REJECT, so a workflow declaring none -receives only mail the signature gate found nothing to complain about. +namespace, so `mailAccept` is the only way to author an accept-rule. It +rejects a grant requirement in the `mail.send` namespace too. A run gets its +send grant from the hub at its first trigger, and other send grants come +only from operators. Every key of `inboundMailPolicy` defaults to REJECT, so +a workflow declaring none receives only mail the signature gate found nothing +to complain about. Before that gate, the hub refuses hub-relayed mail from a sender that no `mailAccept` rule and no operator grant admits. A workflow declaring no `mailAccept` receives hub-relayed mail only when an operator grant on the run diff --git a/packages/authz/package.json b/packages/authz/package.json index a5b62de3a..aa5fc6db6 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -9,6 +9,11 @@ "intx-src": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./testing": { + "intx-src": "./src/testing/index.ts", + "types": "./dist/testing/index.d.ts", + "default": "./dist/testing/index.js" } }, "dependencies": { diff --git a/packages/authz/src/condition-registry-invariants.test.ts b/packages/authz/src/condition-registry-invariants.test.ts index ce113cfe6..3ccdbbee6 100644 --- a/packages/authz/src/condition-registry-invariants.test.ts +++ b/packages/authz/src/condition-registry-invariants.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test"; import { HUB_CONDITION_REGISTRY } from "./hub-condition-registry"; +import { MAIL_SEND_CONDITION_REGISTRY } from "./mail-send-condition-registry"; import { RUN_CHILD_CONDITION_REGISTRY } from "./run-child-condition-registry"; import { CREDENTIAL_USE_CONDITIONS } from "./tool-consumer"; import type { ConditionRegistry } from "./types"; @@ -9,6 +10,7 @@ const REGISTRIES: [string, Readonly][] = [ ["HUB_CONDITION_REGISTRY", HUB_CONDITION_REGISTRY], ["CREDENTIAL_USE_CONDITIONS", CREDENTIAL_USE_CONDITIONS], ["RUN_CHILD_CONDITION_REGISTRY", RUN_CHILD_CONDITION_REGISTRY], + ["MAIL_SEND_CONDITION_REGISTRY", MAIL_SEND_CONDITION_REGISTRY], ]; describe("condition registries", () => { @@ -31,4 +33,14 @@ describe("condition registries", () => { expect(HUB_CONDITION_REGISTRY[key]).toBe(evaluator); } }); + + // The grants API checks a `mail.send` grant against both registries, so a + // condition the send check accepts must mean the same thing on the hub. + test("the mail send registry is a subset of the hub registry", () => { + for (const [key, evaluator] of Object.entries( + MAIL_SEND_CONDITION_REGISTRY, + )) { + expect(HUB_CONDITION_REGISTRY[key]).toBe(evaluator); + } + }); }); diff --git a/packages/authz/src/coord-evaluation.test.ts b/packages/authz/src/coord-evaluation.test.ts new file mode 100644 index 000000000..caa371adf --- /dev/null +++ b/packages/authz/src/coord-evaluation.test.ts @@ -0,0 +1,155 @@ +import { describe, test, expect } from "bun:test"; + +import { evaluateCoordinateGrants } from "./coord-evaluation"; +import { coordTypeTier, defineCoordGrammar, type Coordinate } from "./coord"; +import type { GrantRule } from "./types"; + +function grant( + resource: string, + effect: GrantRule["effect"], + action = "use", +): GrantRule { + return { + id: `grt_${Math.random().toString(36).slice(2, 10)}`, + resource, + action, + effect, + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + }; +} + +const LOW_HIGH = defineCoordGrammar({ + namespace: "test.ns", + action: "use", + coordTypes: ["low", "high"], +}); + +const HIGH_LOW = defineCoordGrammar({ + namespace: "test.ns", + action: "use", + coordTypes: ["high", "low"], +}); + +// The low coordinate has the longer id, so pattern specificity alone would +// pick it; a high-tier winner proves the tier ordering. +const COORDS: Coordinate<"low" | "high">[] = [ + { coordType: "low", id: "low_target_with_a_longer_id" }, + { coordType: "high", id: "hi" }, +]; + +describe("evaluateCoordinateGrants", () => { + test("reads the tier order from the grammar", async () => { + const grants = [ + grant("test.ns:low:low_target_with_a_longer_id", "deny"), + grant("test.ns:high:hi", "allow"), + ]; + + const lowFirst = await evaluateCoordinateGrants({ + grammar: LOW_HIGH, + coordinates: COORDS, + grants, + registry: undefined, + }); + const highFirst = await evaluateCoordinateGrants({ + grammar: HIGH_LOW, + coordinates: COORDS, + grants, + registry: undefined, + }); + + expect(lowFirst?.effect).toBe("allow"); + expect(highFirst?.effect).toBe("deny"); + }); + + test("ignores grants outside the grammar namespace", async () => { + const winner = await evaluateCoordinateGrants({ + grammar: LOW_HIGH, + coordinates: COORDS, + grants: [grant("other.ns:high:hi", "allow"), grant("*", "allow", "*")], + registry: undefined, + }); + + expect(winner).toBeNull(); + }); + + test("ranks a wildcard type segment below a named type", async () => { + // `high*` has the more specific coordinate pattern than `low*`, so only + // the tier rule can pick the named-type grant. + const winner = await evaluateCoordinateGrants({ + grammar: LOW_HIGH, + coordinates: COORDS, + grants: [grant("test.ns:high*", "deny"), grant("test.ns:low:*", "allow")], + registry: undefined, + }); + + expect(winner?.resource).toBe("test.ns:low:*"); + expect(winner?.effect).toBe("allow"); + }); + + async function winnerOf(grants: GrantRule[]) { + return evaluateCoordinateGrants({ + grammar: LOW_HIGH, + coordinates: COORDS, + grants, + registry: undefined, + }); + } + + // A namespace-wide deny must narrow a namespace-wide allow however either + // spells its action or its pattern. + test.each([ + { deny: ["test.ns:*", "*"], allow: ["test.ns:*:*", "use"] }, + { deny: ["test.ns:*:*", "*"], allow: ["test.ns:*:*", "use"] }, + { deny: ["test.ns:*", "use"], allow: ["test.ns:*:*", "use"] }, + ] as const)("a deny $deny beats an allow $allow", async ({ deny, allow }) => { + const winner = await winnerOf([ + grant(allow[0], "allow", allow[1]), + grant(deny[0], "deny", deny[1]), + ]); + expect(winner?.effect).toBe("deny"); + }); + + test("a deny with action * beats an allow of the same coordinate", async () => { + const winner = await winnerOf([ + grant("test.ns:high:hi", "allow", "use"), + grant("test.ns:high:hi", "deny", "*"), + ]); + expect(winner?.effect).toBe("deny"); + }); + + test("a more specific coordinate pattern still wins inside a tier", async () => { + const winner = await winnerOf([ + grant("test.ns:*:*", "deny", "*"), + grant("test.ns:*:hi", "allow", "use"), + ]); + expect(winner?.resource).toBe("test.ns:*:hi"); + expect(winner?.effect).toBe("allow"); + }); +}); + +describe("coord grammar", () => { + test("gives tier 0 to a type segment that is not a coord type", () => { + expect(coordTypeTier(LOW_HIGH, "constructor")).toBe(0); + expect(coordTypeTier(LOW_HIGH, "toString")).toBe(0); + expect(coordTypeTier(LOW_HIGH, "*")).toBe(0); + expect(coordTypeTier(LOW_HIGH, "low")).toBe(1); + expect(coordTypeTier(LOW_HIGH, "high")).toBe(2); + }); + + test("rejects a namespace with a colon and duplicate coord types", () => { + expect(() => + defineCoordGrammar({ namespace: "a:b", action: "use", coordTypes: [] }), + ).toThrow(); + expect(() => + defineCoordGrammar({ + namespace: "test.ns", + action: "use", + coordTypes: ["low", "low"], + }), + ).toThrow(); + }); +}); diff --git a/packages/authz/src/coord-evaluation.ts b/packages/authz/src/coord-evaluation.ts new file mode 100644 index 000000000..31787a0d7 --- /dev/null +++ b/packages/authz/src/coord-evaluation.ts @@ -0,0 +1,135 @@ +import { isNamespaceResource } from "@intx/types/authz"; + +import type { ConditionRegistry, GrantRule, MatchedGrant } from "./types"; +import { EFFECT_PRIORITY, evaluateGrants } from "./evaluate"; +import { patternSpecificity } from "./specificity"; +import { + coordResource, + coordTypeTier, + type CoordGrammar, + type Coordinate, +} from "./coord"; + +/** + * Pick the grant that decides a coordinate-namespace check. + * + * This composes the existing grant-evaluation engine; it does not + * reimplement authorization. + * + * - The grants are filtered to `grammar.namespace` before evaluation. A + * broad `*` or `mail.*` grant matches a resource in the namespace under + * `matchPattern`, so without the filter such a grant would decide every + * check in the namespace. + * - Each coordinate is a distinct resource string; `matchPattern` has no + * alternation, so each coordinate is evaluated on its own. The grants that + * match any coordinate then form ONE set, the same as a principal's direct + * and role grants form one set in `evaluateGrants`, and one grant wins. The + * order is: the coordinate tier the grant names (its position in + * `grammar.coordTypes`, with a grant that names no coord type such as + * `:*` lowest), then the specificity of the coordinate pattern, + * then effect priority (deny > ask > allow). The action and the spelling of + * a namespace-wide pattern do not count, so a deny that matches the + * coordinate beats an allow of the same coordinate pattern whatever action + * either spells. + * - The tier comes from the literal type segment of the grant. A grant whose + * type segment holds a wildcard (`:principal*`, + * `:*`) names no coord type, so it ranks as namespace-wide, + * below every grant that names a coordinate. A wildcard id does not lower + * the tier. + * + * Without a condition `registry` the check cannot tell whether a grant's + * condition holds. So when no registry is supplied and any grant in the + * namespace carries conditions, this THROWS rather than guess, and the + * misconfiguration surfaces loudly. + * + * Returns the winning grant, or `null` when no grant in the namespace + * matched any coordinate. + */ +export async function evaluateCoordinateGrants(args: { + grammar: CoordGrammar; + coordinates: readonly Coordinate[]; + grants: GrantRule[]; + registry: ConditionRegistry | undefined; +}): Promise { + const { grammar, coordinates, grants, registry } = args; + + const namespaceGrants = grants.filter((g) => + isNamespaceResource(grammar.namespace, g.resource), + ); + + if (registry === undefined) { + const conditioned = namespaceGrants.find( + (g) => g.conditions !== null && Object.keys(g.conditions).length > 0, + ); + if (conditioned !== undefined) { + throw new Error( + `${grammar.namespace} grant ${JSON.stringify(conditioned.resource)} carries conditions but no condition registry was supplied; pass a registry or author ${grammar.namespace} grants unconditioned`, + ); + } + } + + // With no registry, the guard above leaves no conditioned grant, so the + // pessimistic mode gives the same result as a registry would. + const opts = { conditions: registry ?? ("pessimistic" as const) }; + + // A namespace-wide grant matches every coordinate, so key the pooled set on + // grant id to count it once. + const matched = new Map(); + for (const coord of coordinates) { + const result = await evaluateGrants( + namespaceGrants, + coordResource(grammar, coord.coordType, coord.id), + grammar.action, + opts, + ); + for (const g of result.matchingGrants) matched.set(g.id, g); + } + + let winner: MatchedGrant | null = null; + for (const g of matched.values()) { + if (winner === null || outranks(grammar, g, winner)) winner = g; + } + return winner; +} + +// A grant only matches coordinates of the type it names, so the type segment +// of its resource is also the tier of the coordinate it matched. +function grantTier( + grammar: CoordGrammar, + resource: string, +): number { + const coordType = resource.split(":")[1]; + return coordType === undefined ? 0 : coordTypeTier(grammar, coordType); +} + +// The specificity of the coordinate a grant names: its resource without the +// namespace prefix and the `:` separators. The action does not count: every +// grant here already matches the grammar's one action, so how it spells that +// action (`send` or `*`) says nothing about what it covers. The separators do +// not count either, so `:*` and `:*:*` rank the same. +function coordSpecificity( + grammar: CoordGrammar, + resource: string, +): number { + const coordPart = resource + .slice(grammar.namespace.length + 1) + .replaceAll(":", ""); + // The bare namespace names no coordinate and matches none; it must not + // score as an exact match. + return coordPart === "" ? 0 : patternSpecificity(coordPart); +} + +function outranks( + grammar: CoordGrammar, + a: MatchedGrant, + b: MatchedGrant, +): boolean { + const tierDiff = + grantTier(grammar, a.resource) - grantTier(grammar, b.resource); + if (tierDiff !== 0) return tierDiff > 0; + const specDiff = + coordSpecificity(grammar, a.resource) - + coordSpecificity(grammar, b.resource); + if (specDiff !== 0) return specDiff > 0; + return EFFECT_PRIORITY[a.effect] > EFFECT_PRIORITY[b.effect]; +} diff --git a/packages/authz/src/coord.ts b/packages/authz/src/coord.ts index c8539b983..1d0e1a9b9 100644 --- a/packages/authz/src/coord.ts +++ b/packages/authz/src/coord.ts @@ -1,59 +1,152 @@ /** - * Grammar for `mail.accept::` authorization resources. + * Grammar for `::` authorization resources. * - * A coordinate names one durable target that a `mail.accept` grant applies to: - * a principal, an asset, or a tenant. An `asset` coordinate names a workflow - * asset, not one workflow definition. One asset backs many definitions (one - * per wire hash), so a grant on an asset trusts every definition that the - * asset backs. The grant stays valid when a definition's content changes. + * A coordinate names one durable target that a grant in a coordinate + * namespace applies to. A `CoordGrammar` fixes the namespace, the action its + * grants carry, and its coordinate types, ordered lowest tier first. The + * namespace is one segment: it may not contain a `:`, because the coord type + * is read as the second colon-separated segment of a resource. * - * The resource string is exactly three colon-separated segments - * (`mail.accept`, the coord type, and the id), so the id may not contain a - * `:` — an id with a colon would split into extra segments and make the + * The resource string is exactly three colon-separated segments (the + * namespace, the coord type, and the id), so the id may not contain a `:` -- + * an id with a colon would split into extra segments and make the * three-segment shape ambiguous. The id is a literal: it may not contain a * `*`, which grant matching reads as a wildcard, so a built or parsed resource - * always names one concrete target. `isValidCoordId` (from `@intx/types`, where - * the authored `MailAccept` shape lives) owns both id rules, so the authoring - * boundary, this resource builder, and the parser share one definition. + * always names one concrete target. `isValidCoordId` (from `@intx/types`, + * where the authored `MailAccept` shape lives) owns both id rules, so the + * authoring boundary, the resource builder, and the parser share one + * definition. + * + * In `mail.accept` an `asset` coordinate names a workflow asset, not one + * workflow definition. One asset backs many definitions (one per wire hash), + * so a grant on an asset trusts every definition that the asset backs. The + * grant stays valid when a definition's content changes. */ import { MAIL_ACCEPT_NAMESPACE, isValidCoordId } from "@intx/types/mail-accept"; +import { MAIL_SEND_NAMESPACE } from "@intx/types/mail-send"; -export type CoordType = "principal" | "asset" | "tenant"; +export type CoordGrammar = { + readonly namespace: string; + readonly action: string; + /** The coordinate types, lowest tier first. */ + readonly coordTypes: readonly T[]; +}; -export const MAIL_ACCEPT_ACTION = "accept"; +export type Coordinate = { coordType: T; id: string }; -export type MailAcceptCoordinate = { coordType: CoordType; id: string }; +export function defineCoordGrammar( + grammar: CoordGrammar, +): CoordGrammar { + if (grammar.namespace.includes(":")) { + throw new Error( + `defineCoordGrammar: namespace must be one segment without ":" (received ${JSON.stringify(grammar.namespace)})`, + ); + } + if (new Set(grammar.coordTypes).size !== grammar.coordTypes.length) { + throw new Error( + `defineCoordGrammar: ${grammar.namespace} coord types must be unique`, + ); + } + return grammar; +} -const COORD_TYPES = { - principal: true, - asset: true, - tenant: true, -} satisfies Record; +export function isGrammarCoordType( + grammar: CoordGrammar, + value: string, +): value is T { + return grammar.coordTypes.some((coordType) => coordType === value); +} -export function isCoordType(value: string): value is CoordType { - return Object.hasOwn(COORD_TYPES, value); +/** + * The tier of a coord type in `grammar`: its 1-based position in + * `coordTypes`, or 0 when `value` is not one of them. + */ +export function coordTypeTier( + grammar: CoordGrammar, + value: string, +): number { + return grammar.coordTypes.findIndex((coordType) => coordType === value) + 1; } -export function mailAcceptResource(coordType: CoordType, id: string): string { +export function coordResource( + grammar: CoordGrammar, + coordType: T, + id: string, +): string { if (!isValidCoordId(id)) { throw new Error( - `mailAcceptResource: id must be a non-empty string without ":" or "*" (received ${JSON.stringify(id)})`, + `${grammar.namespace} resource: id must be a non-empty string without ":" or "*" (received ${JSON.stringify(id)})`, ); } - return `${MAIL_ACCEPT_NAMESPACE}:${coordType}:${id}`; + return `${grammar.namespace}:${coordType}:${id}`; } -export function parseMailAcceptResource( +export function parseCoordResource( + grammar: CoordGrammar, resource: string, -): MailAcceptCoordinate | null { +): Coordinate | null { const segments = resource.split(":"); if (segments.length !== 3) return null; const [namespace, coordType, id] = segments; - if (namespace !== MAIL_ACCEPT_NAMESPACE) return null; - if (coordType === undefined || !isCoordType(coordType)) return null; + if (namespace !== grammar.namespace) return null; + if (coordType === undefined || !isGrammarCoordType(grammar, coordType)) { + return null; + } if (id === undefined || !isValidCoordId(id)) return null; return { coordType, id }; } + +export const MAIL_ACCEPT_ACTION = "accept"; + +export const MAIL_ACCEPT_GRAMMAR = defineCoordGrammar({ + namespace: MAIL_ACCEPT_NAMESPACE, + action: MAIL_ACCEPT_ACTION, + coordTypes: ["tenant", "asset", "principal"], +}); + +/** The coordinate types the hub resolves for a local principal. */ +export type CoordType = (typeof MAIL_ACCEPT_GRAMMAR.coordTypes)[number]; + +export type MailAcceptCoordinate = Coordinate; + +export function isCoordType(value: string): value is CoordType { + return isGrammarCoordType(MAIL_ACCEPT_GRAMMAR, value); +} + +export function mailAcceptResource(coordType: CoordType, id: string): string { + return coordResource(MAIL_ACCEPT_GRAMMAR, coordType, id); +} + +export function parseMailAcceptResource( + resource: string, +): MailAcceptCoordinate | null { + return parseCoordResource(MAIL_ACCEPT_GRAMMAR, resource); +} + +export const MAIL_SEND_ACTION = "send"; + +/** + * `mail.send` adds a `domain` coordinate below the local coordinates. Every + * recipient address whose domain is a host name has one, so such a recipient + * that the hub cannot resolve to a local principal is still named by its + * domain. + */ +export const MAIL_SEND_GRAMMAR = defineCoordGrammar({ + namespace: MAIL_SEND_NAMESPACE, + action: MAIL_SEND_ACTION, + coordTypes: ["domain", "tenant", "asset", "principal"], +}); + +export type MailSendCoordType = (typeof MAIL_SEND_GRAMMAR.coordTypes)[number]; + +export type MailSendCoordinate = Coordinate; + +export function mailSendResource( + coordType: MailSendCoordType, + id: string, +): string { + return coordResource(MAIL_SEND_GRAMMAR, coordType, id); +} diff --git a/packages/authz/src/grant-conditions.test.ts b/packages/authz/src/grant-conditions.test.ts index 66cc25492..9052df247 100644 --- a/packages/authz/src/grant-conditions.test.ts +++ b/packages/authz/src/grant-conditions.test.ts @@ -35,6 +35,27 @@ describe("validateGrantConditions", () => { }); }); + // The mail send check has no consumer, so a `tool` condition never holds + // there, and a deny that carries one would refuse nothing. + describe("a mail.send grant on any holder", () => { + test("refuses a tool condition", () => { + expect( + problems("mail.send:domain:evil.com", "send", TOOL, false), + ).toEqual([ + 'a mail send cannot evaluate this grant: Unknown condition: "tool"', + ]); + expect(problems("mail.send:*", "*", TOOL, false)).toEqual([ + 'a mail send cannot evaluate this grant: Unknown condition: "tool"', + ]); + }); + + test("accepts a time window", () => { + expect( + problems("mail.send:domain:evil.com", "send", WINDOW, false), + ).toEqual([]); + }); + }); + describe("a grant on a run principal", () => { test("accepts no conditions anywhere", () => { expect(problems("*", "*", null, true)).toEqual([]); diff --git a/packages/authz/src/grant-conditions.ts b/packages/authz/src/grant-conditions.ts index 5cb46a372..91047b20d 100644 --- a/packages/authz/src/grant-conditions.ts +++ b/packages/authz/src/grant-conditions.ts @@ -1,4 +1,7 @@ +import { isMailSendNamespaceResource } from "@intx/types/mail-send"; + import { validateConditions } from "./conditions"; +import { MAIL_SEND_CONDITION_REGISTRY } from "./mail-send-condition-registry"; import { matchPattern } from "./patterns"; import { RUN_CHILD_CONDITION_REGISTRY } from "./run-child-condition-registry"; import { CREDENTIAL_USE_CONDITIONS } from "./tool-consumer"; @@ -42,11 +45,12 @@ function canMatchNonCredential(resource: string): boolean { /** * Check a grant's conditions against every registry that will evaluate the * grant, not only the hub's. The hub evaluates every grant, with - * `hubRegistry`. A row on a - * workflow run's principal is also evaluated by the run child, for any - * resource its pattern can match outside `credential:`, and by the - * credential gate, for `credential:` / `use`. Each of those has a - * narrower registry, and a condition outside it throws there. + * `hubRegistry`. The hub's mail send check evaluates a `mail.send` grant on + * any holder with a narrower registry. A row on a workflow run's principal + * is also evaluated by the run child, for any resource its pattern can match + * outside `credential:`, and by the credential gate, for `credential:` / + * `use`. Each of those has a narrower registry, and a condition outside it + * throws there. * * Returns one problem per bad entry. A problem only a narrower registry * reports names the place that rejects it. @@ -56,17 +60,21 @@ export function validateGrantConditions( hubRegistry: Readonly, ): string[] { const problems = validateConditions(target.conditions, hubRegistry); - if (!target.onRunPrincipal) return problems; const narrower: [string, Readonly][] = []; - if (canMatchNonCredential(target.resource)) { - narrower.push(["a workflow run", RUN_CHILD_CONDITION_REGISTRY]); + if (isMailSendNamespaceResource(target.resource)) { + narrower.push(["a mail send", MAIL_SEND_CONDITION_REGISTRY]); } - if ( - canMatchCredential(target.resource) && - matchPattern(target.action, "use") - ) { - narrower.push(["credential use", CREDENTIAL_USE_CONDITIONS]); + if (target.onRunPrincipal) { + if (canMatchNonCredential(target.resource)) { + narrower.push(["a workflow run", RUN_CHILD_CONDITION_REGISTRY]); + } + if ( + canMatchCredential(target.resource) && + matchPattern(target.action, "use") + ) { + narrower.push(["credential use", CREDENTIAL_USE_CONDITIONS]); + } } for (const [place, registry] of narrower) { diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index 33d892369..db048de62 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -12,20 +12,40 @@ export { mailAcceptResource, parseMailAcceptResource, MAIL_ACCEPT_ACTION, + mailSendResource, + MAIL_SEND_ACTION, +} from "./coord"; +export type { + CoordType, + MailAcceptCoordinate, + MailSendCoordType, + MailSendCoordinate, } from "./coord"; -export type { CoordType, MailAcceptCoordinate } from "./coord"; export { mailAcceptRelationToken, resolveMailAcceptRelations, } from "./mail-accept-relation"; export { evaluateMailAdmission } from "./mail-admission"; export type { MailAdmissionResult } from "./mail-admission"; +export { + evaluateMailSend, + mailAddressDomain, + mailSendResourceProblem, +} from "./mail-send"; +export type { + MailSendAuthorization, + MailSendAuthorizer, + MailSendDecision, + MailSendResult, + MailSendSender, +} from "./mail-send"; export { createConditionEvaluator } from "./condition-evaluator"; export { evaluateConditions, validateConditions } from "./conditions"; export { validateGrantConditions } from "./grant-conditions"; export type { GrantConditionTarget } from "./grant-conditions"; export { HUB_CONDITION_REGISTRY } from "./hub-condition-registry"; export { RUN_CHILD_CONDITION_REGISTRY } from "./run-child-condition-registry"; +export { MAIL_SEND_CONDITION_REGISTRY } from "./mail-send-condition-registry"; export { timeWindowEvaluator } from "./time-window"; export { actionConsumer, diff --git a/packages/authz/src/mail-admission.test.ts b/packages/authz/src/mail-admission.test.ts index 31f9ff1ac..b2f8ae242 100644 --- a/packages/authz/src/mail-admission.test.ts +++ b/packages/authz/src/mail-admission.test.ts @@ -358,9 +358,9 @@ describe("evaluateMailAdmission", () => { expect(result.effect).toBe("deny"); }); - test("a deny overrides an accept allow only with the accept action", async () => { - // Specificity counts the action, so a deny with action `*` ranks below - // the `accept` allow at the same coordinate and does not override it. + test("a deny overrides an accept allow whatever its action", async () => { + // The action does not rank a grant, so a deny with action `*` ties the + // `accept` allow at the same coordinate and its effect wins. const exactDeny = await evaluateMailAdmission({ senderCoordinates: [PRINCIPAL], recipientGrants: [ @@ -378,8 +378,21 @@ describe("evaluateMailAdmission", () => { expect(exactDeny.admit).toBe(false); expect(exactDeny.effect).toBe("deny"); - expect(wildcardActionDeny.admit).toBe(true); - expect(wildcardActionDeny.effect).toBe("allow"); + expect(wildcardActionDeny.admit).toBe(false); + expect(wildcardActionDeny.effect).toBe("deny"); + }); + + test("a namespace-wide deny refuses past a namespace-wide allow of any spelling", async () => { + const result = await evaluateMailAdmission({ + senderCoordinates: [PRINCIPAL], + recipientGrants: [ + { ...acceptGrant(PRINCIPAL, "allow"), resource: "mail.accept:*" }, + { ...acceptGrant(PRINCIPAL, "deny"), resource: "mail.accept:*:*" }, + ], + }); + + expect(result.admit).toBe(false); + expect(result.effect).toBe("deny"); }); test("an exact principal allow outranks every broader deny", async () => { diff --git a/packages/authz/src/mail-admission.ts b/packages/authz/src/mail-admission.ts index 71b2d0e12..471cb97cc 100644 --- a/packages/authz/src/mail-admission.ts +++ b/packages/authz/src/mail-admission.ts @@ -1,14 +1,6 @@ -import { isMailAcceptNamespaceResource } from "@intx/types/mail-accept"; - -import type { ConditionRegistry, GrantRule, MatchedGrant } from "./types"; -import { EFFECT_PRIORITY, evaluateGrants } from "./evaluate"; -import { - MAIL_ACCEPT_ACTION, - isCoordType, - mailAcceptResource, - type CoordType, - type MailAcceptCoordinate, -} from "./coord"; +import type { ConditionRegistry, GrantRule } from "./types"; +import { MAIL_ACCEPT_GRAMMAR, type MailAcceptCoordinate } from "./coord"; +import { evaluateCoordinateGrants } from "./coord-evaluation"; /** * Outcome of an inbound-mail admission decision. @@ -28,9 +20,10 @@ export type MailAdmissionResult = { /** * Decide whether an inbound mail sender is admitted. * - * This composes the existing grant-evaluation engine; it does not - * reimplement authorization. Both admission scopes (start-a-run and - * deliver-to-existing) call this one helper. + * Both admission scopes (start-a-run and deliver-to-existing) call this one + * helper. It picks the winning `mail.accept` grant with + * `evaluateCoordinateGrants`, which owns the namespace filter, the tier + * ordering, and the conditioned-grant guard. * * Behavior (fail-closed): * - A null `senderCoordinates` means the sender is unresolvable/unknown and @@ -39,32 +32,23 @@ export type MailAdmissionResult = { * evaluation. This is load-bearing: a broad `*` or `mail.*` grant matches a * `mail.accept:...` resource under `matchPattern`, so without the filter any * such grant would auto-admit all mail and defeat the accept model. - * - Each sender coordinate is a distinct resource string; `matchPattern` has - * no alternation, so each coordinate is evaluated on its own. The grants - * that match any coordinate then form ONE set, the same as a principal's - * direct and role grants form one set in `evaluateGrants`, and one grant - * wins. The order is: the coordinate tier the grant names (principal, then - * asset, then tenant, then a namespace-wide grant such as `mail.accept:*` - * that names no coord type), then pattern specificity, then effect - * priority (deny > ask > allow). - * - The coordinate type ranks first, before pattern specificity. So - * `allow mail.accept:principal:` admits that sender past a - * `deny mail.accept:tenant:` or a `deny mail.accept:*`, and a - * `deny mail.accept:tenant:` refuses that tenant past an + * - The tiers are principal, then asset, then tenant, then a namespace-wide + * grant such as `mail.accept:*`. The coordinate type ranks first, before + * pattern specificity. So `allow mail.accept:principal:` admits that + * sender past a `deny mail.accept:tenant:` or a `deny mail.accept:*`, + * and a `deny mail.accept:tenant:` refuses that tenant past an * `allow mail.accept:*`. A wildcard id does not lower the tier: an * `allow mail.accept:principal:*` outranks an exact - * `deny mail.accept:tenant:`. - * - The tier comes from the literal type segment of the grant. A grant whose - * type segment holds a wildcard (`mail.accept:principal*`, `mail.accept:*`) - * names no coord type, so it ranks as namespace-wide, below every grant that - * names a coordinate. + * `deny mail.accept:tenant:`. Inside one tier the more specific + * coordinate pattern wins, and effect breaks a tie. The action does not + * count, so a `deny` with action `*` refuses past an `accept` allow of the + * same coordinate. * - * Conditioned `mail.accept` grants require a condition `registry`: without - * one the check cannot tell whether a grant's condition holds. So when no - * registry is supplied and any matched-namespace grant carries conditions, - * this helper THROWS rather than guess; the delivery seam turns that throw - * into a fail-closed drop and the misconfiguration surfaces loudly. Callers - * that legitimately use conditioned `mail.accept` grants MUST pass a registry. + * Conditioned `mail.accept` grants require a condition `registry`. Without + * one, any conditioned grant in the namespace makes this helper THROW rather + * than guess; the delivery seam turns that throw into a fail-closed drop and + * the misconfiguration surfaces loudly. Callers that legitimately use + * conditioned `mail.accept` grants MUST pass a registry. */ export async function evaluateMailAdmission(args: { senderCoordinates: MailAcceptCoordinate[] | null; @@ -77,47 +61,14 @@ export async function evaluateMailAdmission(args: { return { admit: false, effect: null }; } - const acceptGrants = recipientGrants.filter((g) => - isMailAcceptNamespaceResource(g.resource), - ); - - // Without a registry the check cannot tell whether a grant's condition holds. - // Refuse to evaluate a conditioned accept grant rather than guess; fail loud - // so the misconfiguration is fixed instead of silently absorbed. - if (registry === undefined) { - const conditioned = acceptGrants.find( - (g) => g.conditions !== null && Object.keys(g.conditions).length > 0, - ); - if (conditioned !== undefined) { - throw new Error( - `evaluateMailAdmission: mail.accept grant ${JSON.stringify(conditioned.resource)} carries conditions but no condition registry was supplied; pass a registry or author mail.accept grants unconditioned`, - ); - } - } - - // With no registry, the guard above leaves no conditioned grant, so the - // pessimistic mode gives the same result as a registry would. - const opts = { conditions: registry ?? ("pessimistic" as const) }; - - // A namespace-wide grant matches every coordinate, so key the pooled set on - // grant id to count it once. - const matched = new Map(); - for (const coord of senderCoordinates) { - const result = await evaluateGrants( - acceptGrants, - mailAcceptResource(coord.coordType, coord.id), - MAIL_ACCEPT_ACTION, - opts, - ); - for (const g of result.matchingGrants) matched.set(g.id, g); - } - - let winner: MatchedGrant | undefined; - for (const g of matched.values()) { - if (winner === undefined || outranks(g, winner)) winner = g; - } + const winner = await evaluateCoordinateGrants({ + grammar: MAIL_ACCEPT_GRAMMAR, + coordinates: senderCoordinates, + grants: recipientGrants, + registry, + }); - if (winner === undefined) { + if (winner === null) { return { admit: false, effect: null }; } switch (winner.effect) { @@ -133,28 +84,3 @@ export async function evaluateMailAdmission(args: { } } } - -const COORD_TIER = { - tenant: 1, - asset: 2, - principal: 3, -} satisfies Record; - -// The tier of a `mail.accept` grant is the coord type its resource names. A -// grant only matches coordinates of the type it names, so this is also the -// tier of the coordinate it matched. A grant whose type segment is not a -// literal coord type (`mail.accept`, `mail.accept:*`) ranks lowest. -function coordTier(resource: string): number { - const coordType = resource.split(":")[1]; - return coordType !== undefined && isCoordType(coordType) - ? COORD_TIER[coordType] - : 0; -} - -function outranks(a: MatchedGrant, b: MatchedGrant): boolean { - const tierDiff = coordTier(a.resource) - coordTier(b.resource); - if (tierDiff !== 0) return tierDiff > 0; - const specDiff = a.specificity - b.specificity; - if (specDiff !== 0) return specDiff > 0; - return EFFECT_PRIORITY[a.effect] > EFFECT_PRIORITY[b.effect]; -} diff --git a/packages/authz/src/mail-send-condition-registry.ts b/packages/authz/src/mail-send-condition-registry.ts new file mode 100644 index 000000000..0601e1220 --- /dev/null +++ b/packages/authz/src/mail-send-condition-registry.ts @@ -0,0 +1,13 @@ +import { timeWindowEvaluator } from "./time-window"; +import type { ConditionRegistry } from "./types"; + +/** + * The conditions the hub's mail send check can evaluate: only the clock. The + * check has no consumer, so a `tool` condition could never hold there. The + * check evaluates `mail.send` grants with this registry, and any other + * condition key throws there, so the send fails closed. + */ +export const MAIL_SEND_CONDITION_REGISTRY: Readonly = + Object.freeze({ + time_window: timeWindowEvaluator, + }); diff --git a/packages/authz/src/mail-send.test.ts b/packages/authz/src/mail-send.test.ts new file mode 100644 index 000000000..4a9e2dee7 --- /dev/null +++ b/packages/authz/src/mail-send.test.ts @@ -0,0 +1,439 @@ +import { describe, test, expect } from "bun:test"; + +import { + evaluateMailSend, + mailAddressDomain, + mailSendResourceProblem, +} from "./mail-send"; +import { + MAIL_SEND_ACTION, + mailSendResource, + type MailSendCoordinate, +} from "./coord"; +import { HUB_CONDITION_REGISTRY } from "./hub-condition-registry"; +import { MAIL_SEND_CONDITION_REGISTRY } from "./mail-send-condition-registry"; +import type { GrantRule } from "./types"; + +function grant( + resource: string, + effect: GrantRule["effect"], + action: string = MAIL_SEND_ACTION, +): GrantRule { + return { + id: `grt_${Math.random().toString(36).slice(2, 10)}`, + resource, + action, + effect, + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + }; +} + +// Each lower tier has a longer id than the tier above it, so pattern +// specificity alone would pick the lower tier. A test where the higher tier +// wins therefore proves the tier ordering, not the id lengths. +const PRINCIPAL: MailSendCoordinate = { coordType: "principal", id: "prn_r" }; +const ASSET: MailSendCoordinate = { coordType: "asset", id: "ast_recipient" }; +const TENANT: MailSendCoordinate = { + coordType: "tenant", + id: "tnt_recipient_with_a_longer_id", +}; +const DOMAIN: MailSendCoordinate = { + coordType: "domain", + id: "recipient-domain-with-the-longest-id.example.com", +}; +const LOCAL = [DOMAIN, TENANT, ASSET, PRINCIPAL]; + +function exact(coord: MailSendCoordinate, effect: GrantRule["effect"]) { + return grant(mailSendResource(coord.coordType, coord.id), effect); +} + +async function decide( + recipientCoordinates: readonly MailSendCoordinate[], + senderGrants: GrantRule[], +) { + return evaluateMailSend({ + recipientCoordinates, + senderGrants, + registry: HUB_CONDITION_REGISTRY, + }); +} + +describe("evaluateMailSend", () => { + test("an allow grant on the recipient allows the send", async () => { + const result = await decide(LOCAL, [exact(PRINCIPAL, "allow")]); + expect(result.allowed).toBe(true); + expect(result.effect).toBe("allow"); + expect(result.grant?.resource).toBe("mail.send:principal:prn_r"); + }); + + test("a deny grant on the recipient refuses the send", async () => { + const result = await decide(LOCAL, [exact(TENANT, "deny")]); + expect(result).toMatchObject({ allowed: false, effect: "deny" }); + }); + + test("no matching grant refuses the send", async () => { + const result = await decide(LOCAL, [ + exact({ coordType: "principal", id: "prn_other" }, "allow"), + ]); + expect(result).toEqual({ allowed: false, effect: null, grant: null }); + }); + + test("grants outside the mail.send namespace do not allow", async () => { + const result = await decide(LOCAL, [ + grant("*", "allow", "*"), + grant("mail.*", "allow", "*"), + grant("mail.accept:*:*", "allow"), + ]); + expect(result.allowed).toBe(false); + expect(result.grant).toBeNull(); + }); + + test("a grant with another action does not allow", async () => { + const result = await decide(LOCAL, [ + grant("mail.send:*:*", "allow", "accept"), + ]); + expect(result.allowed).toBe(false); + }); + + test("a wildcard action allows a send", async () => { + const result = await decide(LOCAL, [grant("mail.send:*:*", "allow", "*")]); + expect(result.allowed).toBe(true); + }); + + test("a namespace-wide grant allows a recipient named only by its domain", async () => { + const result = await decide([DOMAIN], [grant("mail.send:*:*", "allow")]); + expect(result.allowed).toBe(true); + }); + + test("a recipient with no coordinates is refused", async () => { + const result = await decide([], [grant("mail.send:*:*", "allow")]); + expect(result).toEqual({ allowed: false, effect: null, grant: null }); + }); + + test("a principal deny wins over a tenant allow", async () => { + const result = await decide(LOCAL, [ + exact(TENANT, "allow"), + exact(PRINCIPAL, "deny"), + ]); + expect(result.effect).toBe("deny"); + }); + + test("a principal allow wins over a namespace-wide deny", async () => { + const result = await decide(LOCAL, [ + grant("mail.send:*:*", "deny"), + exact(PRINCIPAL, "allow"), + ]); + expect(result.effect).toBe("allow"); + }); + + test("an asset grant wins over a tenant grant", async () => { + const result = await decide(LOCAL, [ + exact(TENANT, "deny"), + exact(ASSET, "allow"), + ]); + expect(result.effect).toBe("allow"); + }); + + test("a tenant grant of either effect wins over an exact domain grant", async () => { + const allowed = await decide(LOCAL, [ + exact(DOMAIN, "deny"), + exact(TENANT, "allow"), + ]); + const denied = await decide(LOCAL, [ + exact(DOMAIN, "allow"), + exact(TENANT, "deny"), + ]); + expect(allowed.effect).toBe("allow"); + expect(denied.effect).toBe("deny"); + }); + + test("a domain grant wins over a namespace-wide grant", async () => { + const result = await decide( + [DOMAIN], + [grant("mail.send:*:*", "allow"), exact(DOMAIN, "deny")], + ); + expect(result.effect).toBe("deny"); + }); + + test("a wildcard domain id matches subdomains at the domain tier", async () => { + const sub: MailSendCoordinate = { + coordType: "domain", + id: "a.example.com", + }; + const apex: MailSendCoordinate = { coordType: "domain", id: "example.com" }; + const grants = [ + grant("mail.send:*:*", "allow"), + grant("mail.send:domain:*.example.com", "deny"), + ]; + + expect((await decide([sub], grants)).effect).toBe("deny"); + expect((await decide([apex], grants)).effect).toBe("allow"); + }); + + test("a wildcard type segment ranks below a domain grant", async () => { + // Both coordinate patterns hold six literal characters, so only the tier + // rule can pick the domain grant. + const result = await decide( + [DOMAIN], + [ + grant("mail.send:*domain:*", "deny"), + grant("mail.send:domain:*", "allow"), + ], + ); + expect(result.grant?.resource).toBe("mail.send:domain:*"); + expect(result.effect).toBe("allow"); + }); + + test("an ask that wins does not allow", async () => { + const result = await decide(LOCAL, [exact(PRINCIPAL, "ask")]); + expect(result.allowed).toBe(false); + expect(result.effect).toBeNull(); + expect(result.grant?.effect).toBe("ask"); + }); +}); + +// A run's own send grant is `mail.send:*:*` / `send` / allow. An operator +// narrows it with a deny, whatever action or namespace-wide pattern the deny +// spells. +describe("an operator deny under a run's send grant", () => { + const runGrant = grant("mail.send:*:*", "allow"); + + test.each([ + ["mail.send:*", MAIL_SEND_ACTION], + ["mail.send:*:*", "*"], + ["mail.send:*", "*"], + ])("a deny %p / %p refuses the send", async (resource, action) => { + const result = await decide(LOCAL, [ + runGrant, + grant(resource, "deny", action), + ]); + expect(result.allowed).toBe(false); + expect(result.effect).toBe("deny"); + }); + + test("a deny on one domain id refuses only that domain", async () => { + const grants = [runGrant, grant("mail.send:*:evil.com", "deny")]; + const evil = await decide( + [{ coordType: "domain", id: "evil.com" }], + grants, + ); + const other = await decide([DOMAIN], grants); + expect(evil.allowed).toBe(false); + expect(other.allowed).toBe(true); + }); + + test("a domain deny with action * refuses past a domain allow", async () => { + const result = await decide( + [DOMAIN], + [ + grant(mailSendResource("domain", DOMAIN.id), "allow"), + grant(mailSendResource("domain", DOMAIN.id), "deny", "*"), + ], + ); + expect(result.allowed).toBe(false); + }); +}); + +// The send check evaluates only `time_window`. A UTC window around the +// current hour holds now; the one two hours later does not. Windows that +// cross midnight are supported, so both hold their meaning at any hour. +describe("a conditioned send grant under the send check's registry", () => { + function window(holds: boolean) { + const hour = new Date().getUTCHours() + (holds ? 0 : 2); + const at = (h: number) => `${String(h % 24).padStart(2, "0")}:00`; + return { + time_window: { after: at(hour), before: at(hour + 1), timezone: "UTC" }, + }; + } + + function check(senderGrants: GrantRule[]) { + return evaluateMailSend({ + recipientCoordinates: [DOMAIN], + senderGrants, + registry: MAIL_SEND_CONDITION_REGISTRY, + }); + } + + const domain = mailSendResource("domain", DOMAIN.id); + + test("an allow inside its window allows the send", async () => { + const result = await check([ + { ...grant(domain, "allow"), conditions: window(true) }, + ]); + expect(result.allowed).toBe(true); + }); + + test("an allow outside its window does not", async () => { + const result = await check([ + { ...grant(domain, "allow"), conditions: window(false) }, + ]); + expect(result.allowed).toBe(false); + }); + + test("a deny outside its window does not refuse the run's send", async () => { + const result = await check([ + grant("mail.send:*:*", "allow"), + { ...grant(domain, "deny"), conditions: window(false) }, + ]); + expect(result.allowed).toBe(true); + }); + + test("a tool condition fails the check closed", async () => { + await expect( + check([{ ...grant(domain, "deny"), conditions: { tool: "tool:pkg" } }]), + ).rejects.toThrow(); + }); +}); + +describe("mail send resources", () => { + test("build a coordinate resource", () => { + expect(mailSendResource("domain", "example.com")).toBe( + "mail.send:domain:example.com", + ); + }); + + test("refuse an id with a colon or a wildcard", () => { + expect(() => mailSendResource("domain", "a:b")).toThrow(); + expect(() => mailSendResource("domain", "*.example.com")).toThrow(); + }); +}); + +describe("mailAddressDomain", () => { + test("returns the lower-cased domain", () => { + expect(mailAddressDomain("Someone@Example.COM")).toBe("example.com"); + }); + + test("returns null for an address it cannot split", () => { + expect(mailAddressDomain("no-at-sign")).toBeNull(); + expect(mailAddressDomain("@example.com")).toBeNull(); + expect(mailAddressDomain("someone@")).toBeNull(); + }); + + test("returns null for an address with more than one @", () => { + expect(mailAddressDomain("a@b@example.com")).toBeNull(); + }); + + test("drops a trailing root dot", () => { + expect(mailAddressDomain("a@Example.com.")).toBe("example.com"); + }); + + test("returns null for a domain that is not a host name", () => { + expect(mailAddressDomain("a@exa*mple.com")).toBeNull(); + expect(mailAddressDomain("a@example.com:25")).toBeNull(); + expect(mailAddressDomain("a@example.com ")).toBeNull(); + expect(mailAddressDomain("a@ ")).toBeNull(); + expect(mailAddressDomain("Bob ")).toBeNull(); + expect(mailAddressDomain("a@example..com")).toBeNull(); + expect(mailAddressDomain("a@-example.com")).toBeNull(); + expect(mailAddressDomain("a@.")).toBeNull(); + // Two spellings of one internationalized domain would give two ids, and + // a deny on one would miss the other. + expect(mailAddressDomain("a@münchen.de")).toBeNull(); + expect(mailAddressDomain("a@x_y.com")).toBeNull(); + expect(mailAddressDomain("a@[192.0.2.1]")).toBeNull(); + }); +}); + +// A run gets `mail.send:*:*` allow at first trigger and operators narrow it +// with domain deny rows, so a deny must decide for every spelling of its +// domain that has a domain coordinate. +describe("a domain deny under a namespace-wide allow", () => { + const grants = [ + grant("mail.send:*:*", "allow"), + grant("mail.send:domain:evil.com", "deny"), + ]; + + test.each(["x@evil.com", "x@EVIL.com", "x@evil.com.", "x@evil.com "])( + "refuses %p", + async (address) => { + const domain = mailAddressDomain(address); + const result = await decide( + domain === null ? [] : [{ coordType: "domain", id: domain }], + grants, + ); + expect(result.allowed).toBe(false); + }, + ); +}); + +describe("mailSendResourceProblem", () => { + // The send check reads only the `mail.send` namespace, so a pattern written + // toward it from outside never decides a send. + test.each([ + "mail.send*:domain:evil.com", + "mail.sen*:domain:evil.com", + "mail.*:domain:evil.com", + "mail.send*evil.com", + "mail.send*", + ])("refuses the near miss %p", (resource) => { + expect(mailSendResourceProblem(resource)).toContain( + "outside the mail.send namespace", + ); + }); + + test.each([ + "*", + "*:*", + "mail.*", + "mail.*:*", + "m*", + "m*:read", + "ma*x", + "m*:principal:prn_a", + "wallet:*", + "mail.accept:*", + "mail.sender:x", + "*:prn_a", + ])("accepts the grant %p for other namespaces", (resource) => { + expect(mailSendResourceProblem(resource)).toBeNull(); + }); + + test("accepts usable resources and resources outside the namespace", () => { + expect(mailSendResourceProblem("mail.send:domain:example.com")).toBeNull(); + expect( + mailSendResourceProblem("mail.send:domain:*.example.com"), + ).toBeNull(); + expect(mailSendResourceProblem("mail.send:*:*")).toBeNull(); + // A wildcard type also matches non-domain ids, which may hold a `.`. + expect(mailSendResourceProblem("mail.send:*:evil.com.")).toBeNull(); + expect(mailSendResourceProblem("mail.send:*")).toBeNull(); + expect(mailSendResourceProblem("mail.send:principal:prn_ABC")).toBeNull(); + expect(mailSendResourceProblem("mail.send:tenant:*")).toBeNull(); + expect( + mailSendResourceProblem("mail.accept:domain:Example.com"), + ).toBeNull(); + expect(mailSendResourceProblem("mail.sender:x")).toBeNull(); + expect(mailSendResourceProblem("*")).toBeNull(); + }); + + test.each([ + "mail.send", + "mail.send:DOMAIN:evil.com", + "mail.send:domian:evil.com", + "mail.send:domain:", + "mail.send:domain", + "mail.send:domain:evil.com:x", + "mail.send:domain:evil.com.", + "mail.send:domain:*.evil.com.", + "mail.send:domain:evil com", + "mail.send:domain:evil_com", + "mail.send:domain:-evil.com", + "mail.send:domain:evil..com", + "mail.send:*:a:b", + "mail.send:*:", + ])("reports %p, which matches no coordinate", (resource) => { + expect(mailSendResourceProblem(resource)).not.toBeNull(); + }); + + test("rejects upper case where the resource can match a domain", () => { + expect( + mailSendResourceProblem("mail.send:domain:Example.com"), + ).not.toBeNull(); + expect(mailSendResourceProblem("mail.send:*:Evil.com")).not.toBeNull(); + expect(mailSendResourceProblem("mail.send:*Evil.com")).not.toBeNull(); + }); +}); diff --git a/packages/authz/src/mail-send.ts b/packages/authz/src/mail-send.ts new file mode 100644 index 000000000..7e5ffa75f --- /dev/null +++ b/packages/authz/src/mail-send.ts @@ -0,0 +1,212 @@ +import { parseAddress } from "@intx/types/agent-address"; +import { + MAIL_SEND_NAMESPACE, + isMailSendNamespaceResource, +} from "@intx/types/mail-send"; + +import type { ConditionRegistry, GrantRule, MatchedGrant } from "./types"; +import { + MAIL_SEND_GRAMMAR, + isGrammarCoordType, + type MailSendCoordinate, +} from "./coord"; +import { evaluateCoordinateGrants } from "./coord-evaluation"; +import { matchPattern } from "./patterns"; + +/** + * Outcome of a send decision for one recipient. + * + * `effect` is the effect of the winning `mail.send` grant across the + * recipient's coordinates, or `null` when no grant matched (default deny). + * An `ask` that wins is treated as no allow (`allowed: false`, + * `effect: null`): a relay cannot satisfy an interactive prompt. `grant` is + * the winning grant, so a caller can name the row that decided. + */ +export type MailSendResult = { + allowed: boolean; + effect: "allow" | "deny" | null; + grant: MatchedGrant | null; +}; + +/** + * Decide whether a sender may send mail to one recipient. + * + * `recipientCoordinates` are the recipient's coordinates: its domain, and its + * tenant, asset and principal when the hub resolves them. `senderGrants` are + * the sender's direct and role grants. The winning `mail.send` grant decides, + * by tier (principal, then asset, then tenant, then domain, then a + * namespace-wide grant such as `mail.send:*:*`), then the specificity of the + * coordinate pattern (the action does not count), then effect. A broad `*` + * or `mail.*` grant is outside the namespace and never allows a send. A + * recipient with no coordinates matches no grant, so it is refused. + */ +export async function evaluateMailSend(args: { + recipientCoordinates: readonly MailSendCoordinate[]; + senderGrants: GrantRule[]; + registry: ConditionRegistry; +}): Promise { + const winner = await evaluateCoordinateGrants({ + grammar: MAIL_SEND_GRAMMAR, + coordinates: args.recipientCoordinates, + grants: args.senderGrants, + registry: args.registry, + }); + + if (winner === null) { + return { allowed: false, effect: null, grant: null }; + } + switch (winner.effect) { + case "allow": + return { allowed: true, effect: "allow", grant: winner }; + case "deny": + return { allowed: false, effect: "deny", grant: winner }; + case "ask": + return { allowed: false, effect: null, grant: winner }; + default: { + const _exhaustive: never = winner.effect; + throw new Error(`unhandled effect: ${String(_exhaustive)}`); + } + } +} + +// Dot-separated labels of letters, digits and inner hyphens. +const HOST_NAME = + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; + +/** + * The `domain` coordinate id of a mail address: the part after the `@`, as + * `parseAddress` splits it, in lower case and without a trailing root dot. + * Every spelling of one domain therefore gives one id, so a domain grant + * decides for all of them. Returns `null` when the address does not parse or + * its domain is not a host name. A recipient whose domain is `null` has no + * domain coordinate. + */ +export function mailAddressDomain(address: string): string | null { + const parsed = parseAddress(address); + if (parsed === null) return null; + let domain = parsed.domain.toLowerCase(); + if (domain.endsWith(".")) domain = domain.slice(0, -1); + return HOST_NAME.test(domain) ? domain : null; +} + +/** + * Why a resource can never decide a send as its author means, or `null` when + * it can, or when it is a grant for other namespaces. + * + * The send check reads only grants in the `mail.send` namespace. A pattern + * outside it that the author wrote toward `mail.send`, such as + * `mail.send*:domain:x` or `mail.*:domain:x`, matches some send resources but + * never decides a send, so it is refused. A pattern that matches every send + * resource, such as `*` or `mail.*`, serves other namespaces and is accepted. + * + * A usable resource is either `mail.send::`, with a coord + * type of the send grammar and a non-empty id, or a pattern whose type + * segment holds a `*` and whose id part, when present, is one non-empty + * segment. Any other shape matches no coordinate, so a deny row of that shape + * would block nothing. + * + * Domain coordinates are lower-case host names without a trailing dot (see + * `mailAddressDomain`), and grant matching is case sensitive. A `domain` id + * must therefore have that form, and a resource whose type segment holds a + * `*` must be lower case, because it can match a domain coordinate. A grant + * id also matches literally, so `mail.send:domain:*example.com` matches + * `evilexample.com`; `mail.send:domain:*.example.com` names the subdomains + * of `example.com`. + */ +export function mailSendResourceProblem(resource: string): string | null { + const quoted = JSON.stringify(resource); + if (!isMailSendNamespaceResource(resource)) { + return nearMailSendPattern(resource) + ? `resource ${quoted} is outside the ${MAIL_SEND_NAMESPACE} namespace, so it never decides a send; write a ${MAIL_SEND_NAMESPACE}:: grant, and a separate grant for other namespaces` + : null; + } + if (resource === MAIL_SEND_NAMESPACE) { + return `${MAIL_SEND_NAMESPACE} resource ${quoted} names no coordinate`; + } + + const rest = resource.slice(MAIL_SEND_NAMESPACE.length + 1); + const [coordType = "", ...idSegments] = rest.split(":"); + const lowerCase = `${MAIL_SEND_NAMESPACE} resource ${quoted} can match a domain coordinate, so it must be lower case`; + const shape = `${MAIL_SEND_NAMESPACE} resource ${quoted} must be ${MAIL_SEND_NAMESPACE}::`; + const id = idSegments.join(":"); + // A coordinate id holds no `:` and is not empty, so an id part with more + // than one segment, or an empty one, matches no coordinate. + if (idSegments.length > 1 || (idSegments.length === 1 && id.length === 0)) { + return shape; + } + // A wildcard type segment also matches tenant, asset and principal ids, and + // a coordinate id may hold a `.`, so its id part gets no host-name check: + // `mail.send:*:evil.com.` can match a non-domain id. + if (coordType.includes("*")) { + return rest === rest.toLowerCase() ? null : lowerCase; + } + if (!isGrammarCoordType(MAIL_SEND_GRAMMAR, coordType)) { + return `${MAIL_SEND_NAMESPACE} resource ${quoted} names an unknown coord type; use one of ${MAIL_SEND_GRAMMAR.coordTypes.join(", ")}`; + } + if (idSegments.length !== 1) return shape; + if (coordType === "domain") { + if (id !== id.toLowerCase()) return lowerCase; + // A domain coordinate is a host name without a trailing dot. Test a + // pattern with each `*` read as one letter. + if (!HOST_NAME.test(id.replaceAll("*", "a"))) { + return `${MAIL_SEND_NAMESPACE} resource ${quoted} names no domain coordinate; write the domain as a host name in lower case, without a trailing dot`; + } + } + return null; +} + +// Whether a pattern outside the namespace was written toward `mail.send` and +// matches only some send resources. Its text before the first `*` is a start +// of `mail.send:` that names at least the `mail.` family, so the author typed +// toward the namespace; a shorter text, such as the `m` of `m*:read`, or a +// pattern that starts with `*`, such as `*:prn_a`, serves other namespaces. +// A pattern that ends in `*` and matches `mail.send:` matches every send +// resource, so it is a broad grant and not a near miss, unless its text names +// the whole namespace, as `mail.send*` does: no other namespace starts with +// that text, so it is a mistyped `mail.send:*`. +function nearMailSendPattern(resource: string): boolean { + const star = resource.indexOf("*"); + if (star < 0) return false; + const prefix = resource.slice(0, star); + if (!prefix.startsWith("mail.")) return false; + if (!`${MAIL_SEND_NAMESPACE}:`.startsWith(prefix)) return false; + if (prefix === MAIL_SEND_NAMESPACE) return true; + return !( + resource.endsWith("*") && matchPattern(resource, `${MAIL_SEND_NAMESPACE}:`) + ); +} + +/** + * Who sends: a deployment named by its run address (the relay path, where the + * sidecar connection already binds the address), or a principal the caller + * has already authenticated (the REST path). + */ +export type MailSendSender = + | { kind: "address"; address: string } + | { kind: "principal"; principalId: string; tenantId: string }; + +/** + * The send decision for one recipient, keyed by the caller's string. A caller + * decides on `allowed` alone. An `evaluated` decision's `effect` and `grant` + * describe the winning grant for a log: `effect` is `null` when no grant + * matched, and an `ask` winner is not allowed although `grant` names it. An + * `unresolved` decision means the recipient's coordinates could not be + * resolved, a fault and not a grant decision; it is never allowed. + */ +export type MailSendDecision = + | ({ recipient: string; outcome: "evaluated" } & MailSendResult) + | { recipient: string; outcome: "unresolved"; allowed: false }; + +export type MailSendAuthorization = + | { ok: true; decisions: MailSendDecision[] } + | { ok: false; reason: "sender_unresolved" }; + +/** + * Decide, for each recipient of one mail, whether the sender may send it. + * The hub implements it against its database; a fault rejects the promise, + * and the caller then refuses every recipient. + */ +export type MailSendAuthorizer = (args: { + sender: MailSendSender; + recipients: readonly string[]; +}) => Promise; diff --git a/packages/authz/src/testing/index.ts b/packages/authz/src/testing/index.ts new file mode 100644 index 000000000..f48ae12f7 --- /dev/null +++ b/packages/authz/src/testing/index.ts @@ -0,0 +1,9 @@ +// @intx/authz/testing -- permissive implementations of the authz contracts, +// for tests. +// +// The exports here allow every decision. They exist so a test that is not +// about authorization can satisfy a required authorizer without a database +// or real grants. Importing from this subpath in production allows every +// request it guards. + +export { permitAllMailSend } from "./mail-send-allow"; diff --git a/packages/authz/src/testing/mail-send-allow.test.ts b/packages/authz/src/testing/mail-send-allow.test.ts new file mode 100644 index 000000000..44796dd6a --- /dev/null +++ b/packages/authz/src/testing/mail-send-allow.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; + +import { permitAllMailSend } from "./mail-send-allow"; + +describe("permitAllMailSend", () => { + test("allows every recipient, in order", async () => { + const result = await permitAllMailSend({ + sender: { kind: "address", address: "run_a@example.com" }, + recipients: ["b@example.com", "c@example.com"], + }); + expect(result).toEqual({ + ok: true, + decisions: [ + { + recipient: "b@example.com", + outcome: "evaluated", + allowed: true, + effect: "allow", + grant: expect.objectContaining({ effect: "allow" }), + }, + { + recipient: "c@example.com", + outcome: "evaluated", + allowed: true, + effect: "allow", + grant: expect.objectContaining({ effect: "allow" }), + }, + ], + }); + }); +}); diff --git a/packages/authz/src/testing/mail-send-allow.ts b/packages/authz/src/testing/mail-send-allow.ts new file mode 100644 index 000000000..c89cc1ac6 --- /dev/null +++ b/packages/authz/src/testing/mail-send-allow.ts @@ -0,0 +1,32 @@ +// A permissive MailSendAuthorizer for tests. +// +// It allows every recipient of every sender. Useful when a test exercises +// mail delivery and not the send check. Production callers must use the +// hub's database-backed authorizer. + +import type { MailSendAuthorizer } from "../mail-send"; +import type { MatchedGrant } from "../types"; + +// The grant each decision names, so a decision has the shape the real check +// gives an allowed send: an `allow` effect and the grant that decided it. +const PERMIT_ALL_GRANT: MatchedGrant = { + id: "grt_permit_all_mail_send", + resource: "mail.send:*:*", + action: "send", + effect: "allow", + origin: "system", + specificity: 0, +}; + +export const permitAllMailSend: MailSendAuthorizer = async ({ + recipients, +}) => ({ + ok: true, + decisions: recipients.map((recipient) => ({ + recipient, + outcome: "evaluated", + allowed: true, + effect: "allow", + grant: PERMIT_ALL_GRANT, + })), +}); diff --git a/packages/db/migrations/0124_mail_send_backfill.sql b/packages/db/migrations/0124_mail_send_backfill.sql new file mode 100644 index 000000000..2d2c27a7d --- /dev/null +++ b/packages/db/migrations/0124_mail_send_backfill.sql @@ -0,0 +1,132 @@ +-- Keep the mail that existing deployments and users can send. The send gate +-- refuses a send unless a `mail.send` grant of the sender allows it, and state +-- that predates the gate has no such grants. +-- +-- 1. Each deployment anchor whose first trigger linked its run principal +-- gets one `mail.send:*:*` allow row, the row the first trigger stages +-- for a new deployment: action `send`, origin `creator`, no conditions +-- and no expiry. An anchor of any status gets it, and so does a run +-- principal that is not active: the first trigger does not run again +-- for a linked anchor, and the send check does not read the sender's +-- status, so mail that an ended run sent is decided by this row. An +-- anchor that has not fired gets the row at its first trigger. +-- An operator row on `mail.send:*:*` with conditions or an expiry does +-- not count as that row. +-- 2. Each holder of a live `allow` grant whose action can match `manage` +-- and whose resource can match a `workflow-run:` resource gets a +-- `mail.send:tenant:` allow row. Such a holder can send +-- mail through the REST route to a run in that tenant today. Each new +-- row keeps the holder, origin, conditions and expiry of its source +-- rows; source rows that agree on all of these give one row. An action +-- can match when it is `manage`, or when it holds a `*` and matches +-- `manage` as a pattern. A resource can match when the text before +-- its first `*` starts with `workflow-run:`, or when it holds a `*` and +-- `workflow-run:` starts with that text (the rule the hub uses for +-- `credential:` grants). A `deny` or `ask` row is skipped, because the +-- route still checks `manage` and that check enforces it. A run +-- principal is skipped: statement 1 gives it its send grant, and a +-- delegated row on it would never lapse. A row whose conditions name a +-- key other than `time_window` is skipped too: the send check cannot +-- evaluate such a key and would fail on the copy. Such a key is `tool`, +-- which holds only for a run token's consumer, so a user's +-- `tool`-conditioned `manage` grant does not pass the route today. +-- +-- Apply this migration with the old hub stopped, then start the new hub. An +-- anchor that the old hub links after this runs, or a tenant that it creates, +-- gets no send rows, and the new hub refuses its sends. Running this again +-- later is not a remedy: it would restore a row that an operator removed. +-- +-- The data statements are idempotent: a `WHERE NOT EXISTS` guard skips a +-- holder that already holds the row it would get. Grant ids follow the app +-- convention (`grt_` and 32 hex characters). +INSERT INTO "grant" ( + "id", "tenant_id", "principal_id", "resource", "action", "effect", + "origin", "expires_at" +) +SELECT + 'grt_' || replace(gen_random_uuid()::text, '-', ''), + r."tenant_id", + r."principal_id", + 'mail.send:*:*', + 'send', + 'allow', + 'creator', + NULL +FROM "workflow_run" r +JOIN "principal" p + ON p."id" = r."principal_id" AND p."tenant_id" = r."tenant_id" +WHERE r."id" = r."anchor_run_id" + AND NOT EXISTS ( + SELECT 1 FROM "grant" existing + WHERE existing."principal_id" = r."principal_id" + AND existing."resource" = 'mail.send:*:*' + AND existing."action" = 'send' + AND existing."effect" = 'allow' + AND (existing."conditions" IS NULL OR existing."conditions" = '{}'::jsonb) + AND existing."expires_at" IS NULL + ); +--> statement-breakpoint +INSERT INTO "grant" ( + "id", "tenant_id", "principal_id", "role_id", "resource", "action", + "effect", "conditions", "origin", "expires_at" +) +SELECT + 'grt_' || replace(gen_random_uuid()::text, '-', ''), + source."tenant_id", + source."principal_id", + source."role_id", + 'mail.send:tenant:' || source."tenant_id", + 'send', + 'allow', + source."conditions", + source."origin", + source."expires_at" +FROM ( + SELECT DISTINCT + g."tenant_id", g."principal_id", g."role_id", g."conditions", + g."origin", g."expires_at" + FROM "grant" g + LEFT JOIN "principal" holder ON holder."id" = g."principal_id" + WHERE g."effect" = 'allow' + AND ( + g."action" = 'manage' + OR ( + strpos(g."action", '*') > 0 + AND 'manage' LIKE replace( + replace(replace(replace(g."action", '\', '\\'), '%', '\%'), '_', '\_'), + '*', '%' + ) ESCAPE '\' + ) + ) + AND (g."expires_at" IS NULL OR g."expires_at" > now()) + -- A CASE fixes the order: `-` raises on a jsonb scalar, so a row whose + -- conditions are not an object must not reach it. A JSON null holds no + -- condition; any other non-object shape is skipped. + AND CASE + WHEN g."conditions" IS NULL OR jsonb_typeof(g."conditions") = 'null' + THEN true + WHEN jsonb_typeof(g."conditions") = 'object' + THEN g."conditions" - 'time_window' = '{}'::jsonb + ELSE false + END + AND (g."role_id" IS NOT NULL OR holder."kind" <> 'workflow') + AND ( + starts_with(split_part(g."resource", '*', 1), 'workflow-run:') + OR ( + strpos(g."resource", '*') > 0 + AND starts_with('workflow-run:', split_part(g."resource", '*', 1)) + ) + ) +) source +WHERE NOT EXISTS ( + SELECT 1 FROM "grant" existing + WHERE existing."tenant_id" = source."tenant_id" + AND existing."principal_id" IS NOT DISTINCT FROM source."principal_id" + AND existing."role_id" IS NOT DISTINCT FROM source."role_id" + AND existing."resource" = 'mail.send:tenant:' || source."tenant_id" + AND existing."action" = 'send' + AND existing."effect" = 'allow' + AND existing."origin" = source."origin" + AND existing."conditions" IS NOT DISTINCT FROM source."conditions" + AND existing."expires_at" IS NOT DISTINCT FROM source."expires_at" +); diff --git a/packages/db/migrations/meta/0124_snapshot.json b/packages/db/migrations/meta/0124_snapshot.json new file mode 100644 index 000000000..314ad552e --- /dev/null +++ b/packages/db/migrations/meta/0124_snapshot.json @@ -0,0 +1,4995 @@ +{ + "id": "23dabe89-c44b-494c-b2a4-b60d4ba3221d", + "prevId": "96142fd0-7059-4b59-8b92-efac58488312", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_trust": { + "name": "federation_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_trust_tenant_id_tenant_id_fk": { + "name": "federation_trust_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_trust_target_tenant_id_tenant_id_fk": { + "name": "federation_trust_target_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["target_tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_trust_tenant_id_target_tenant_id_unique": { + "name": "federation_trust_tenant_id_target_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "target_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tenant_domain_lower_idx": { + "name": "tenant_domain_lower_idx", + "columns": [ + { + "expression": "lower(\"domain\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_parent_id_tenant_id_fk": { + "name": "tenant_parent_id_tenant_id_fk", + "tableFrom": "tenant", + "tableTo": "tenant", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_slug_unique": { + "name": "tenant_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "tenant_slug_dns_label_check": { + "name": "tenant_slug_dns_label_check", + "value": "\"tenant\".\"slug\" ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$' and length(\"tenant\".\"slug\") <= 63" + } + }, + "isRLSEnabled": false + }, + "public.principal": { + "name": "principal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_tenant_id_tenant_id_fk": { + "name": "principal_tenant_id_tenant_id_fk", + "tableFrom": "principal", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_tenant_id_kind_ref_id_unique": { + "name": "principal_tenant_id_kind_ref_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "ref_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_key": { + "name": "principal_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_key_one_active": { + "name": "principal_key_one_active", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"principal_key\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_key_principal_id_principal_id_fk": { + "name": "principal_key_principal_id_principal_id_fk", + "tableFrom": "principal_key", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_key_public_key_unique": { + "name": "principal_key_public_key_unique", + "nullsNotDistinct": false, + "columns": ["public_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_role": { + "name": "agent_role", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_role_agent_id_workflow_definition_id_fk": { + "name": "agent_role_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_role", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_role_role_id_role_id_fk": { + "name": "agent_role_role_id_role_id_fk", + "tableFrom": "agent_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_role_agent_id_role_id_pk": { + "name": "agent_role_agent_id_role_id_pk", + "columns": ["agent_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_role": { + "name": "principal_role", + "schema": "", + "columns": { + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_role_principal_id_principal_id_fk": { + "name": "principal_role_principal_id_principal_id_fk", + "tableFrom": "principal_role", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "principal_role_role_id_role_id_fk": { + "name": "principal_role_role_id_role_id_fk", + "tableFrom": "principal_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "principal_role_principal_id_role_id_pk": { + "name": "principal_role_principal_id_role_id_pk", + "columns": ["principal_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_tenant_id_tenant_id_fk": { + "name": "role_tenant_id_tenant_id_fk", + "tableFrom": "role", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grant": { + "name": "grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grant_invoker_allow_principal_idx": { + "name": "grant_invoker_allow_principal_idx", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"grant\".\"origin\" = 'invoker' and \"grant\".\"effect\" = 'allow'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grant_tenant_id_tenant_id_fk": { + "name": "grant_tenant_id_tenant_id_fk", + "tableFrom": "grant", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_role_id_role_id_fk": { + "name": "grant_role_id_role_id_fk", + "tableFrom": "grant", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_principal_id_principal_id_fk": { + "name": "grant_principal_id_principal_id_fk", + "tableFrom": "grant", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grant_target_exactly_one": { + "name": "grant_target_exactly_one", + "value": "num_nonnulls(\"grant\".\"principal_id\", \"grant\".\"role_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.approval": { + "name": "approval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_definition": { + "name": "tool_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_arguments": { + "name": "tool_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_tenant_status_idx": { + "name": "approval_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_anchor_run_idx": { + "name": "approval_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_run_idx": { + "name": "approval_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_tenant_id_tenant_id_fk": { + "name": "approval_tenant_id_tenant_id_fk", + "tableFrom": "approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_anchor_run_id_workflow_run_id_fk": { + "name": "approval_anchor_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_run_id_workflow_run_id_fk": { + "name": "approval_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "approval_correlation_id_unique": { + "name": "approval_correlation_id_unique", + "nullsNotDistinct": false, + "columns": ["correlation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signal_correlation": { + "name": "signal_correlation", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_name": { + "name": "signal_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_id": { + "name": "signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signal_correlation_tenant_id_tenant_id_fk": { + "name": "signal_correlation_tenant_id_tenant_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_anchor_run_id_workflow_run_id_fk": { + "name": "signal_correlation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_run_id_workflow_run_id_fk": { + "name": "signal_correlation_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider": { + "name": "provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_info_url": { + "name": "user_info_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_tenant_id_tenant_id_fk": { + "name": "provider_tenant_id_tenant_id_fk", + "tableFrom": "provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_tenant_name": { + "name": "provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "default_scopes": { + "name": "default_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_tenant_id_tenant_id_fk": { + "name": "oauth_client_tenant_id_tenant_id_fk", + "tableFrom": "oauth_client", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_provider_id_provider_id_fk": { + "name": "oauth_client_provider_id_provider_id_fk", + "tableFrom": "oauth_client", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_tenant_provider": { + "name": "oauth_client_tenant_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_secret": { + "name": "refresh_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_tenant_id_tenant_id_fk": { + "name": "credential_tenant_id_tenant_id_fk", + "tableFrom": "credential", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_principal_id_principal_id_fk": { + "name": "credential_principal_id_principal_id_fk", + "tableFrom": "credential", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "credential_provider_id_provider_id_fk": { + "name": "credential_provider_id_provider_id_fk", + "tableFrom": "credential", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_oauth_client_id_oauth_client_id_fk": { + "name": "credential_oauth_client_id_oauth_client_id_fk", + "tableFrom": "credential", + "tableTo": "oauth_client", + "columnsFrom": ["oauth_client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_tenant_name": { + "name": "credential_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "asset_tenant_id_tenant_id_fk": { + "name": "asset_tenant_id_tenant_id_fk", + "tableFrom": "asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_creator_principal_id_principal_id_fk": { + "name": "asset_creator_principal_id_principal_id_fk", + "tableFrom": "asset", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_tenant_kind_name": { + "name": "asset_tenant_kind_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_wallet_id_wallet_id_fk": { + "name": "transaction_wallet_id_wallet_id_fk", + "tableFrom": "transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_run_id_workflow_run_id_fk": { + "name": "transaction_run_id_workflow_run_id_fk", + "tableFrom": "transaction", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backend_type": { + "name": "backend_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_tenant_id_tenant_id_fk": { + "name": "wallet_tenant_id_tenant_id_fk", + "tableFrom": "wallet", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.offering": { + "name": "offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "offering_agent_id_workflow_definition_id_fk": { + "name": "offering_agent_id_workflow_definition_id_fk", + "tableFrom": "offering", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "offering_tenant_id_tenant_id_fk": { + "name": "offering_tenant_id_tenant_id_fk", + "tableFrom": "offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model": { + "name": "model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_name": { + "name": "canonical_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_tenant_id_tenant_id_fk": { + "name": "model_tenant_id_tenant_id_fk", + "tableFrom": "model", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_tenant_canonical_name": { + "name": "model_tenant_canonical_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "canonical_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_offering": { + "name": "model_offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deployment_tags": { + "name": "deployment_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "quirks": { + "name": "quirks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_offering_tenant_id_tenant_id_fk": { + "name": "model_offering_tenant_id_tenant_id_fk", + "tableFrom": "model_offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_model_id_model_id_fk": { + "name": "model_offering_model_id_model_id_fk", + "tableFrom": "model_offering", + "tableTo": "model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_provider_id_model_provider_id_fk": { + "name": "model_offering_provider_id_model_provider_id_fk", + "tableFrom": "model_offering", + "tableTo": "model_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_offering_tenant_model_provider": { + "name": "model_offering_tenant_model_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "model_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_pricing": { + "name": "model_pricing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offering_id": { + "name": "offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_token_price": { + "name": "input_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_token_price": { + "name": "output_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_read_token_price": { + "name": "cache_read_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_write_token_price": { + "name": "cache_write_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_token_price": { + "name": "thinking_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_request_fee": { + "name": "per_request_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_image_fee": { + "name": "per_image_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_audio_fee": { + "name": "per_audio_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_pricing_tenant_id_tenant_id_fk": { + "name": "model_pricing_tenant_id_tenant_id_fk", + "tableFrom": "model_pricing", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_pricing_offering_id_model_offering_id_fk": { + "name": "model_pricing_offering_id_model_offering_id_fk", + "tableFrom": "model_pricing", + "tableTo": "model_offering", + "columnsFrom": ["offering_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_pricing_offering_currency_effective_from": { + "name": "model_pricing_offering_currency_effective_from", + "nullsNotDistinct": false, + "columns": ["offering_id", "currency", "effective_from"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_provider": { + "name": "model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_provider_tenant_id_tenant_id_fk": { + "name": "model_provider_tenant_id_tenant_id_fk", + "tableFrom": "model_provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_provider_credential_id_credential_id_fk": { + "name": "model_provider_credential_id_credential_id_fk", + "tableFrom": "model_provider", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "model_provider_wallet_id_wallet_id_fk": { + "name": "model_provider_wallet_id_wallet_id_fk", + "tableFrom": "model_provider", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_provider_tenant_name": { + "name": "model_provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": { + "model_provider_auth_xor": { + "name": "model_provider_auth_xor", + "value": "(\"model_provider\".\"credential_id\" is not null) <> (\"model_provider\".\"wallet_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.sidecar": { + "name": "sidecar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reconnect_window_ended_at": { + "name": "reconnect_window_ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sidecar_token_hash_sha256_unique": { + "name": "sidecar_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sidecar_allocation": { + "name": "sidecar_allocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ensure_accepted_generation": { + "name": "ensure_accepted_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_id": { + "name": "reconciliation_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_expires_at": { + "name": "reconciliation_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "initialization_lease_id": { + "name": "initialization_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ensure_attempts": { + "name": "ensure_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "destroy_attempts": { + "name": "destroy_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deployment_cleanup_confirmed": { + "name": "deployment_cleanup_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_hold_released": { + "name": "provider_hold_released", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "connect_deadline": { + "name": "connect_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "max_disconnected_ms": { + "name": "max_disconnected_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "first_deploy_failed_at": { + "name": "first_deploy_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sidecar_allocation_anchor_run_idx": { + "name": "sidecar_allocation_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_sidecar_idx": { + "name": "sidecar_allocation_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_reconciliation_idx": { + "name": "sidecar_allocation_reconciliation_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing') and \"sidecar_allocation\".\"next_attempt_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_dispatchable_anchor_idx": { + "name": "sidecar_allocation_dispatchable_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sidecar_allocation_anchor_run_id_workflow_run_id_fk": { + "name": "sidecar_allocation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sidecar_allocation_tenant_id_tenant_id_fk": { + "name": "sidecar_allocation_tenant_id_tenant_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sidecar_allocation_sidecar_id_sidecar_id_fk": { + "name": "sidecar_allocation_sidecar_id_sidecar_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sidecar_allocation_status_check": { + "name": "sidecar_allocation_status_check", + "value": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'destroy_failed', 'released', 'failed')" + }, + "sidecar_allocation_generation_check": { + "name": "sidecar_allocation_generation_check", + "value": "\"sidecar_allocation\".\"generation\" >= 0" + }, + "sidecar_allocation_max_disconnected_check": { + "name": "sidecar_allocation_max_disconnected_check", + "value": "\"sidecar_allocation\".\"max_disconnected_ms\" > 0" + }, + "sidecar_allocation_accepted_generation_check": { + "name": "sidecar_allocation_accepted_generation_check", + "value": "\"sidecar_allocation\".\"ensure_accepted_generation\" is null or \"sidecar_allocation\".\"ensure_accepted_generation\" <= \"sidecar_allocation\".\"generation\"" + }, + "sidecar_allocation_initialization_check": { + "name": "sidecar_allocation_initialization_check", + "value": "\"sidecar_allocation\".\"initialization_lease_id\" is null or (\"sidecar_allocation\".\"status\" = 'allocated' and \"sidecar_allocation\".\"ensure_accepted_generation\" is not null and \"sidecar_allocation\".\"ensure_accepted_generation\" = \"sidecar_allocation\".\"generation\")" + } + }, + "isRLSEnabled": false + }, + "public.agent_session": { + "name": "agent_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_session_tenant_id_tenant_id_fk": { + "name": "agent_session_tenant_id_tenant_id_fk", + "tableFrom": "agent_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_agent_id_workflow_definition_id_fk": { + "name": "agent_session_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_session", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agent_session_principal_id_principal_id_fk": { + "name": "agent_session_principal_id_principal_id_fk", + "tableFrom": "agent_session", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_asset": { + "name": "session_asset", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_pack_sha": { + "name": "asset_pack_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_commit_sha": { + "name": "source_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "materialized_at": { + "name": "materialized_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_asset_pack_sha_idx": { + "name": "session_asset_pack_sha_idx", + "columns": [ + { + "expression": "asset_pack_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_asset_instance_id_mount_path_pk": { + "name": "session_asset_instance_id_mount_path_pk", + "columns": ["instance_id", "mount_path"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inference_turn": { + "name": "inference_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inference_turn_instance_id_started_at_idx": { + "name": "inference_turn_instance_id_started_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inference_turn_session_id_agent_session_id_fk": { + "name": "inference_turn_session_id_agent_session_id_fk", + "tableFrom": "inference_turn", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inference_turn_tenant_id_tenant_id_fk": { + "name": "inference_turn_tenant_id_tenant_id_fk", + "tableFrom": "inference_turn", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_mail": { + "name": "session_mail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "raw": { + "name": "raw", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_mail_instance_id_created_at_idx": { + "name": "session_mail_instance_id_created_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_mail_session_id_created_at_idx": { + "name": "session_mail_session_id_created_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_mail_session_id_agent_session_id_fk": { + "name": "session_mail_session_id_agent_session_id_fk", + "tableFrom": "session_mail", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_mail_tenant_id_tenant_id_fk": { + "name": "session_mail_tenant_id_tenant_id_fk", + "tableFrom": "session_mail", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.turn_part": { + "name": "turn_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "turn_part_turn_id_inference_turn_id_fk": { + "name": "turn_part_turn_id_inference_turn_id_fk", + "tableFrom": "turn_part", + "tableTo": "inference_turn", + "columnsFrom": ["turn_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_part_session_id_agent_session_id_fk": { + "name": "turn_part_session_id_agent_session_id_fk", + "tableFrom": "turn_part", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_token": { + "name": "git_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_pattern": { + "name": "ref_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "git_token_user_id_name_active_idx": { + "name": "git_token_user_id_name_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"git_token\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "git_token_tenant_id_tenant_id_fk": { + "name": "git_token_tenant_id_tenant_id_fk", + "tableFrom": "git_token", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "git_token_user_id_user_id_fk": { + "name": "git_token_user_id_user_id_fk", + "tableFrom": "git_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_token_principal_id_principal_id_fk": { + "name": "git_token_principal_id_principal_id_fk", + "tableFrom": "git_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_token_token_hash_sha256_unique": { + "name": "git_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_token": { + "name": "run_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sealed_token": { + "name": "sealed_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_token_principal_consumer_idx": { + "name": "run_token_principal_consumer_idx", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_token_principal_id_principal_id_fk": { + "name": "run_token_principal_id_principal_id_fk", + "tableFrom": "run_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "run_token_token_hash_sha256_unique": { + "name": "run_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition": { + "name": "workflow_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wire_hash": { + "name": "wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_requirements": { + "name": "grant_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_requirements": { + "name": "model_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_bindings": { + "name": "credential_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deployed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_tenant_idx": { + "name": "workflow_definition_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_definition_asset_wire_hash_idx": { + "name": "workflow_definition_asset_wire_hash_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wire_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_tenant_id_tenant_id_fk": { + "name": "workflow_definition_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_definition_creator_principal_id_principal_id_fk": { + "name": "workflow_definition_creator_principal_id_principal_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_definition_asset_id_asset_id_fk": { + "name": "workflow_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition_version": { + "name": "workflow_definition_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_wire_hash": { + "name": "approved_wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_version_definition_idx": { + "name": "workflow_definition_version_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_version_definition_id_workflow_definition_id_fk": { + "name": "workflow_definition_version_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_definition_version", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_definition_version_definition_version": { + "name": "workflow_definition_version_definition_version", + "nullsNotDistinct": false, + "columns": ["definition_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run": { + "name": "workflow_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel_id": { + "name": "kernel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_preferences": { + "name": "model_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_refs": { + "name": "credential_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_at": { + "name": "cancellation_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "operator_cancellation_requested_at": { + "name": "operator_cancellation_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancellation_deadline": { + "name": "cancellation_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_release_at": { + "name": "capacity_release_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "infrastructure_failed_at": { + "name": "infrastructure_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_mail_accept": { + "name": "legacy_mail_accept", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_definition_idx": { + "name": "workflow_run_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_address_idx": { + "name": "workflow_run_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_run\".\"address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_anchor_idx": { + "name": "workflow_run_anchor_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"id\" = \"workflow_run\".\"anchor_run_id\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_live_anchor_idx": { + "name": "workflow_run_live_anchor_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"id\" = \"workflow_run\".\"anchor_run_id\" and \"workflow_run\".\"status\" in ('deployed', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_infrastructure_failed_idx": { + "name": "workflow_run_infrastructure_failed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"infrastructure_failed_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_sidecar_idx": { + "name": "workflow_run_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"sidecar_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_definition_id_workflow_definition_id_fk": { + "name": "workflow_run_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_tenant_id_tenant_id_fk": { + "name": "workflow_run_tenant_id_tenant_id_fk", + "tableFrom": "workflow_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_principal_id_principal_id_fk": { + "name": "workflow_run_principal_id_principal_id_fk", + "tableFrom": "workflow_run", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_run_sidecar_id_sidecar_id_fk": { + "name": "workflow_run_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_run", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_legacy_mail_accept_check": { + "name": "workflow_run_legacy_mail_accept_check", + "value": "\"workflow_run\".\"legacy_mail_accept\" is null or \"workflow_run\".\"legacy_mail_accept\" in ('invoker-correspondent', 'correspondent')" + } + }, + "isRLSEnabled": false + }, + "public.workflow_run_dispatch": { + "name": "workflow_run_dispatch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mail'" + }, + "sender_address": { + "name": "sender_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_message": { + "name": "raw_message", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "step_grants": { + "name": "step_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "acknowledged_generation": { + "name": "acknowledged_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivery_lease_id": { + "name": "delivery_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_lease_expires_at": { + "name": "delivery_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_dispatch_anchor_message_idx": { + "name": "workflow_run_dispatch_anchor_message_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_delivery_idx": { + "name": "workflow_run_dispatch_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_anchor_status_idx": { + "name": "workflow_run_dispatch_anchor_status_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_unresolved_anchor_idx": { + "name": "workflow_run_dispatch_unresolved_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged') or (\"workflow_run_dispatch\".\"status\" = 'abandoned' and \"workflow_run_dispatch\".\"next_attempt_at\" is not null)", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_dispatch", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_dispatch_status_check": { + "name": "workflow_run_dispatch_status_check", + "value": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged', 'abandoned', 'settled', 'failed')" + }, + "workflow_run_dispatch_kind_check": { + "name": "workflow_run_dispatch_kind_check", + "value": "\"workflow_run_dispatch\".\"kind\" in ('mail', 'signal')" + }, + "workflow_run_dispatch_attempt_count_check": { + "name": "workflow_run_dispatch_attempt_count_check", + "value": "\"workflow_run_dispatch\".\"attempt_count\" >= 0" + }, + "workflow_run_dispatch_acknowledged_generation_check": { + "name": "workflow_run_dispatch_acknowledged_generation_check", + "value": "\"workflow_run_dispatch\".\"acknowledged_generation\" is null or \"workflow_run_dispatch\".\"acknowledged_generation\" >= 0" + }, + "workflow_run_dispatch_acknowledged_state_check": { + "name": "workflow_run_dispatch_acknowledged_state_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'acknowledged' or \"workflow_run_dispatch\".\"acknowledged_generation\" is not null" + }, + "workflow_run_dispatch_pending_schedule_check": { + "name": "workflow_run_dispatch_pending_schedule_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'pending' or \"workflow_run_dispatch\".\"next_attempt_at\" is not null" + }, + "workflow_run_dispatch_mail_sender_check": { + "name": "workflow_run_dispatch_mail_sender_check", + "value": "\"workflow_run_dispatch\".\"kind\" <> 'mail' or \"workflow_run_dispatch\".\"status\" in ('abandoned', 'settled', 'failed') or \"workflow_run_dispatch\".\"sender_address\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.workflow_pending_projection": { + "name": "workflow_pending_projection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "history_required": { + "name": "history_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "workflow_pending_projection_anchor_idx": { + "name": "workflow_pending_projection_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_pending_projection_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_pending_projection_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_pending_projection", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_launch_spec": { + "name": "workflow_run_launch_spec", + "schema": "", + "columns": { + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_domain": { + "name": "deployment_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_authority_principal_id": { + "name": "source_authority_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frozen_approval_bundle": { + "name": "frozen_approval_bundle", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "definition_artifact_id": { + "name": "definition_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_offering_ids": { + "name": "source_offering_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_source_offering_id": { + "name": "default_source_offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk": { + "name": "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "principal", + "columnsFrom": ["source_authority_principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_definition_artifact_id_workflow_definition_artifact_id_fk": { + "name": "workflow_run_launch_spec_definition_artifact_id_workflow_definition_artifact_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_definition_artifact", + "columnsFrom": ["definition_artifact_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_execution": { + "name": "workflow_run_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_execution_run_id_id_idx": { + "name": "workflow_run_execution_run_id_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_execution_status_idx": { + "name": "workflow_run_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_execution_run_id_workflow_run_id_fk": { + "name": "workflow_run_execution_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_execution", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_probe": { + "name": "workflow_probe", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_asset_id": { + "name": "definition_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin": { + "name": "pin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_probe_active_idx": { + "name": "workflow_probe_active_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_probe\".\"status\" in ('pending', 'provisioning', 'probing', 'releasing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_probe_sidecar_idx": { + "name": "workflow_probe_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_probe_tenant_id_tenant_id_fk": { + "name": "workflow_probe_tenant_id_tenant_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_probe_definition_asset_id_asset_id_fk": { + "name": "workflow_probe_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "asset", + "columnsFrom": ["definition_asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_probe_sidecar_id_sidecar_id_fk": { + "name": "workflow_probe_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_probe_status_check": { + "name": "workflow_probe_status_check", + "value": "\"workflow_probe\".\"status\" in ('pending', 'provisioning', 'probing', 'releasing', 'succeeded', 'failed')" + }, + "workflow_probe_succeeded_result_check": { + "name": "workflow_probe_succeeded_result_check", + "value": "\"workflow_probe\".\"status\" <> 'succeeded' or \"workflow_probe\".\"result\" is not null" + }, + "workflow_probe_generation_check": { + "name": "workflow_probe_generation_check", + "value": "\"workflow_probe\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_definition_artifact": { + "name": "workflow_definition_artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_artifact_revision_idx": { + "name": "workflow_definition_artifact_revision_idx", + "columns": [ + { + "expression": "revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_artifact_revision_id_workflow_definition_revision_id_fk": { + "name": "workflow_definition_artifact_revision_id_workflow_definition_revision_id_fk", + "tableFrom": "workflow_definition_artifact", + "tableTo": "workflow_definition_revision", + "columnsFrom": ["revision_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_definition_artifact_state_check": { + "name": "workflow_definition_artifact_state_check", + "value": "(\"workflow_definition_artifact\".\"result\" is null and \"workflow_definition_artifact\".\"lease_owner\" is not null and \"workflow_definition_artifact\".\"lease_expires_at\" is not null) or (\"workflow_definition_artifact\".\"result\" is not null and \"workflow_definition_artifact\".\"lease_owner\" is null and \"workflow_definition_artifact\".\"lease_expires_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.workflow_definition_revision": { + "name": "workflow_definition_revision", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_commit_sha": { + "name": "asset_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "closure": { + "name": "closure", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_definition_revision_tenant_id_tenant_id_fk": { + "name": "workflow_definition_revision_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition_revision", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_definition_revision_asset_id_asset_id_fk": { + "name": "workflow_definition_revision_asset_id_asset_id_fk", + "tableFrom": "workflow_definition_revision", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 5ff4eae8d..ecb0e61e5 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -862,6 +862,13 @@ "when": 1793915609889, "tag": "0123_drop_launch_spec_tool_package_pins", "breakpoints": true + }, + { + "idx": 124, + "version": "7", + "when": 1793960501284, + "tag": "0124_mail_send_backfill", + "breakpoints": true } ] } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9fc08c301..8702f2d25 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -59,6 +59,7 @@ export { type RunTokenStore, } from "./run-token-store"; export { lookupLocalPrincipalSigner } from "./signer-identity"; +export { resolveLocalMailParty, type LocalMailParty } from "./local-mail-party"; export { resolveSenderKey, resolveFrameSenderKey, diff --git a/packages/db/src/local-mail-party.ts b/packages/db/src/local-mail-party.ts new file mode 100644 index 000000000..4d0dcc3f5 --- /dev/null +++ b/packages/db/src/local-mail-party.ts @@ -0,0 +1,174 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { parseAddress } from "@intx/types"; +import { deriveRunPrincipalId } from "@intx/hub-common"; + +import type { DBExecutor } from "./client"; +import { principal } from "./schema/principals"; +import { tenant } from "./schema/tenants"; +import { workflowDefinition } from "./schema/workflow-definitions"; +import { workflowRun } from "./schema/workflow-run"; + +const RUN_PREFIX = "run_"; + +/** + * The local party a mail address names: a deployment's run, or a user + * principal. + * + * A run carries its deployment anchor's id and tenant, the workflow asset that + * backs its definition (`null` when the definition has no asset), the run + * principal its first trigger linked (`linkedPrincipalId`, `null` before that + * trigger), and the run principal's id (`principalId`). Deploy creates the run + * principal with the id `deriveRunPrincipalId(tenantId, anchorRunId)` and the + * first trigger links that same id, so `principalId` names the run principal + * before and after the first trigger. + */ +export type LocalMailParty = + | { + kind: "run"; + anchorRunId: string; + tenantId: string; + assetId: string | null; + linkedPrincipalId: string | null; + principalId: string; + } + | { kind: "user"; principalId: string; tenantId: string }; + +/** + * Resolve the local run or user that a mail address names, or `null` when it + * names neither. The address is matched in lower case and without a trailing + * root dot on its domain, so every spelling of one address names one party. + * + * This reads no signing key and no run state: a run that has not fired, has no + * active key, or has ended still names the same party. A caller that needs a + * key or a live run checks that itself. + * + * A `run_` local part names a deployment anchor: the run whose id is the local + * part, which is its own anchor, and whose stored address normalizes to the + * same address. Any other local part names a user principal (see + * `resolveUserPrincipal`), which throws when the address is ambiguous. + */ +export async function resolveLocalMailParty( + db: DBExecutor, + address: string, +): Promise { + const normalized = normalizeAddress(address); + if (normalized === null) return null; + const { localPart, domain } = normalized; + + if (!localPart.startsWith(RUN_PREFIX)) { + const user = await resolveUserPrincipal(db, domain, localPart); + return user === null ? null : { kind: "user", ...user }; + } + + // Run ids are lower case, so the local part is the anchor's primary key. + const [anchor] = await db + .select({ + id: workflowRun.id, + anchorRunId: workflowRun.anchorRunId, + address: workflowRun.address, + tenantId: workflowRun.tenantId, + linkedPrincipalId: workflowRun.principalId, + assetId: workflowDefinition.assetId, + }) + .from(workflowRun) + .innerJoin( + workflowDefinition, + eq(workflowRun.definitionId, workflowDefinition.id), + ) + .where(eq(workflowRun.id, localPart)) + .limit(1); + const stored = + anchor?.address == null ? null : normalizeAddress(anchor.address); + if ( + anchor === undefined || + anchor.anchorRunId !== anchor.id || + stored?.localPart !== localPart || + stored.domain !== domain + ) { + return null; + } + return { + kind: "run", + anchorRunId: anchor.id, + tenantId: anchor.tenantId, + assetId: anchor.assetId, + linkedPrincipalId: anchor.linkedPrincipalId, + principalId: + anchor.linkedPrincipalId ?? + (await deriveRunPrincipalId(anchor.tenantId, anchor.id)), + }; +} + +// An address in lower case, split at its `@`, without a trailing root dot on +// its domain. +function normalizeAddress( + address: string, +): { localPart: string; domain: string } | null { + const parsed = parseAddress(address.toLowerCase()); + if (parsed === null) return null; + const domain = parsed.domain.endsWith(".") + ? parsed.domain.slice(0, -1) + : parsed.domain; + if (domain.length === 0) return null; + return { localPart: parsed.localPart, domain }; +} + +/** + * Resolve the user principal a normalized `@` address names, + * or `null` when none matches. `lower(domain)` is unique, so the domain selects + * at most one tenant; within it, prefer the principal whose `refId` is exactly + * the normalized localPart (the canonical lowercase refId). The domain is + * matched case-insensitively in both queries because an existing tenant's stored + * domain may be mixed-case (creation lowercases new ones, but legacy rows are + * left as stored). Only when no exact refId exists does it fall back to a + * case-insensitive refId match, which finds a mixed-case-stored refId. A + * non-unique case-insensitive match with no exact row means two principals in + * the tenant hold case-variant refIds; that is genuinely ambiguous, so it throws + * rather than return an arbitrary one and attribute the address to the wrong + * principal. + */ +async function resolveUserPrincipal( + db: DBExecutor, + domain: string, + localPart: string, +): Promise<{ principalId: string; tenantId: string } | null> { + const columns = { principalId: principal.id, tenantId: principal.tenantId }; + + const [exact] = await db + .select(columns) + .from(principal) + .innerJoin(tenant, eq(principal.tenantId, tenant.id)) + .where( + and( + eq(sql`lower(${tenant.domain})`, domain), + eq(principal.kind, "user"), + eq(principal.refId, localPart), + ), + ) + .limit(1); + if (exact !== undefined) return exact; + + const caseInsensitive = await db + .select(columns) + .from(principal) + .innerJoin(tenant, eq(principal.tenantId, tenant.id)) + .where( + and( + eq(sql`lower(${tenant.domain})`, domain), + eq(principal.kind, "user"), + eq(sql`lower(${principal.refId})`, localPart), + ), + ) + .limit(2); + if (caseInsensitive.length === 0) return null; + if (caseInsensitive.length > 1) { + throw new Error( + `user address ${localPart}@${domain} is ambiguous; it matches ` + + `multiple principals with case-variant refIds and no canonical ` + + `row -- the colliding principal refIds must be reconciled`, + ); + } + const [only] = caseInsensitive; + return only === undefined ? null : only; +} diff --git a/packages/db/src/sender-key-resolver.ts b/packages/db/src/sender-key-resolver.ts index b55bf2caf..1cf0db374 100644 --- a/packages/db/src/sender-key-resolver.ts +++ b/packages/db/src/sender-key-resolver.ts @@ -1,10 +1,10 @@ -import { and, eq, isNotNull, or, sql } from "drizzle-orm"; +import { and, eq, isNotNull, or } from "drizzle-orm"; -import { parseAddress } from "@intx/types"; import type { MailAcceptCoordinate } from "@intx/authz"; import { getLogger } from "@intx/log"; import type { DBExecutor } from "./client"; +import { resolveLocalMailParty } from "./local-mail-party"; import type { PrincipalKeyStore } from "./principal-key-store"; import { loadRunPrincipalActiveKey, @@ -12,11 +12,8 @@ import { } from "./run-principal-key"; import { principal } from "./schema/principals"; import { tenant } from "./schema/tenants"; -import { workflowDefinition } from "./schema/workflow-definitions"; import { workflowRun } from "./schema/workflow-run"; -const RUN_PREFIX = "run_"; - const logger = getLogger(["db", "sender-key-resolver"]); /** @@ -118,9 +115,10 @@ export function senderCoordinates( * coordinate set. A keyless USER principal is different: it is an invariant * break, so `getPublicKey` throws rather than defaulting (see below). * - * Addresses are matched case-insensitively. Inbound `From` addresses are - * lowercased when parsed (see `@intx/mime` `extractAddrSpec`), so the address is - * normalized here and stored values are compared under `lower(...)`. Tenant + * The address names its sender through `resolveLocalMailParty`. Addresses are + * matched case-insensitively and without a trailing root dot. Inbound `From` + * addresses are lowercased when parsed (see `@intx/mime` `extractAddrSpec`), so + * the address is normalized and stored values are compared in lower case. Tenant * domains are `lower(domain)`-unique (`tenant_domain_lower_idx`), so a * normalized domain matches at most one tenant. A user `refId` is a * case-sensitively-unique betterAuth id that can carry uppercase; lowercasing it @@ -136,72 +134,51 @@ export async function resolveSenderKey( principalKeyStore: PrincipalKeyStore, address: string, ): Promise { - const normalized = address.toLowerCase(); - const parsed = parseAddress(normalized); - if (parsed === null) return null; - const { localPart, domain } = parsed; + const party = await resolveLocalMailParty(db, address); + if (party === null) return null; - if (localPart.startsWith(RUN_PREFIX)) { + if (party.kind === "run") { // A run sender resolves to the sidecar-minted key the hub recorded as the // active key of the run's principal when the deployment's initialization - // completed. `workflow_run.address` is unique among the runs that set it, - // so there is at most one row. A run whose principal holds no active key - // has not finished initializing, or its sidecar was replaced and the - // replacement has not recorded a key yet -- an expected state -- so it - // resolves to nothing rather than erroring, unlike the keyless-principal - // invariant break below. A retired key no longer resolves, so the hub - // vouches for no key for mail its replaced sidecar signed. The principal's - // status is not read: a terminal run's in-flight mail still verifies. - const [row] = await db - .select({ - id: workflowRun.id, - principalId: workflowRun.principalId, - assetId: workflowDefinition.assetId, - tenantId: workflowRun.tenantId, - }) - .from(workflowRun) - .innerJoin( - workflowDefinition, - eq(workflowRun.definitionId, workflowDefinition.id), - ) - .where(eq(sql`lower(${workflowRun.address})`, normalized)) - .limit(1); - if (row === undefined) return null; - const publicKey = await loadRunPrincipalActiveKey(db, row.tenantId, row.id); + // completed. A run whose principal holds no active key has not finished + // initializing, or its sidecar was replaced and the replacement has not + // recorded a key yet -- an expected state -- so it resolves to nothing + // rather than erroring, unlike the keyless-principal invariant break + // below. A retired key no longer resolves, so the hub vouches for no key + // for mail its replaced sidecar signed. The principal's status is not + // read: a terminal run's in-flight mail still verifies. + const publicKey = await loadRunPrincipalActiveKey( + db, + party.tenantId, + party.anchorRunId, + ); if (publicKey === null) return null; // `principalId` may be null: an acked anchor holds a signing key before its // first trigger links its run principal, so it CAN sign yet has no linked // principal. The key still resolves (the send path is unaffected); // `senderCoordinates` fails closed on the null principal for admission. - // `tenantId` is a non-null column. `assetId` is null when the definition - // has no asset. + // `assetId` is null when the definition has no asset. return { source: "run", publicKey, - principalId: row.principalId, - assetId: row.assetId, - tenantId: row.tenantId, + principalId: party.linkedPrincipalId, + assetId: party.assetId, + tenantId: party.tenantId, }; } // A user sender resolves to its hub-custodied principal key, keyed by the - // `(tenant domain, user refId)` its address carries. - const resolved = await resolveUserPrincipal(db, domain, localPart); - if (resolved === null) return null; - // The principal exists; a user principal with no active key violates the - // invariant that every user principal is minted with one, so `getPublicKey` - // throws rather than defaulting. Do not soften that to null -- unlike the - // pre-ack run key above, a keyless principal is a real breakage that must - // surface. - const publicKey = await principalKeyStore.getPublicKey( - resolved.principalId, - db, - ); + // `(tenant domain, user refId)` its address carries. The principal exists; a + // user principal with no active key violates the invariant that every user + // principal is minted with one, so `getPublicKey` throws rather than + // defaulting. Do not soften that to null -- unlike the pre-ack run key + // above, a keyless principal is a real breakage that must surface. + const publicKey = await principalKeyStore.getPublicKey(party.principalId, db); return { source: "user", publicKey, - principalId: resolved.principalId, - tenantId: resolved.tenantId, + principalId: party.principalId, + tenantId: party.tenantId, }; } @@ -283,65 +260,6 @@ export async function resolveFrameSenderKey( } } -/** - * Resolve the user principal a normalized `@` address names, - * or `null` when none matches. `lower(domain)` is unique, so the domain selects - * at most one tenant; within it, prefer the principal whose `refId` is exactly - * the normalized localPart (the canonical lowercase refId). The domain is - * matched case-insensitively in both queries because an existing tenant's stored - * domain may be mixed-case (creation lowercases new ones, but legacy rows are - * left as stored). Only when no exact refId exists does it fall back to a - * case-insensitive refId match, which finds a mixed-case-stored refId. A - * non-unique case-insensitive match with no exact row means two principals in - * the tenant hold case-variant refIds; that is genuinely ambiguous, so it throws - * rather than return an arbitrary one and attribute the sender to the wrong - * principal. - */ -async function resolveUserPrincipal( - db: DBExecutor, - domain: string, - localPart: string, -): Promise<{ principalId: string; tenantId: string } | null> { - const columns = { principalId: principal.id, tenantId: principal.tenantId }; - - const [exact] = await db - .select(columns) - .from(principal) - .innerJoin(tenant, eq(principal.tenantId, tenant.id)) - .where( - and( - eq(sql`lower(${tenant.domain})`, domain), - eq(principal.kind, "user"), - eq(principal.refId, localPart), - ), - ) - .limit(1); - if (exact !== undefined) return exact; - - const caseInsensitive = await db - .select(columns) - .from(principal) - .innerJoin(tenant, eq(principal.tenantId, tenant.id)) - .where( - and( - eq(sql`lower(${tenant.domain})`, domain), - eq(principal.kind, "user"), - eq(sql`lower(${principal.refId})`, localPart), - ), - ) - .limit(2); - if (caseInsensitive.length === 0) return null; - if (caseInsensitive.length > 1) { - throw new Error( - `resolveSenderKey: user sender ${localPart}@${domain} is ambiguous; it ` + - `matches multiple principals with case-variant refIds and no canonical ` + - `row -- the colliding principal refIds must be reconciled`, - ); - } - const [only] = caseInsensitive; - return only === undefined ? null : only; -} - /** * The outcome of sweeping every sender that could sign a piece of mail: how many * of each kind were checked, and any whose address did not resolve to a durable diff --git a/packages/hub-agent/package.json b/packages/hub-agent/package.json index 20e922aa2..cb4145dac 100644 --- a/packages/hub-agent/package.json +++ b/packages/hub-agent/package.json @@ -23,6 +23,7 @@ "isomorphic-git": "catalog:" }, "devDependencies": { + "@intx/authz": "workspace:*", "@intx/crypto": "workspace:*", "@intx/hub-sessions": "workspace:*", "hono": "catalog:" diff --git a/packages/hub-agent/src/ws/hub-link.test.ts b/packages/hub-agent/src/ws/hub-link.test.ts index 9b0e59905..add18e140 100644 --- a/packages/hub-agent/src/ws/hub-link.test.ts +++ b/packages/hub-agent/src/ws/hub-link.test.ts @@ -9,6 +9,7 @@ import { type SidecarRouterConfig, type WsHandle, } from "@intx/hub-sessions"; +import { permitAllMailSend } from "@intx/authz/testing"; import { createInMemoryTransport } from "@intx/mail-memory"; import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; import { base64Encode } from "@intx/types"; @@ -250,7 +251,10 @@ function startTestServer(): TestEnv { ...acceptAnySidecar, validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, - lookups: { readWorkflowRunRefTips: async () => STOPPED_REF_TIPS }, + lookups: { + readWorkflowRunRefTips: async () => STOPPED_REF_TIPS, + authorizeMailSend: permitAllMailSend, + }, }); router.events.on("agent.event", ({ agentAddress, sessionId, event }) => { agentEvents.push({ addr: agentAddress, sid: sessionId, event }); @@ -1547,6 +1551,7 @@ describe("sidecar↔hub integration", () => { validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, lookups: { + authorizeMailSend: permitAllMailSend, async receiveWorkflowRunPack(_repoId, _pack, _ref, _commitSha) { receiveCount += 1; const isA = receiveCount === 1; diff --git a/packages/hub-api/src/app.ts b/packages/hub-api/src/app.ts index 604458dc4..45314e473 100644 --- a/packages/hub-api/src/app.ts +++ b/packages/hub-api/src/app.ts @@ -2,7 +2,7 @@ import type { Handler, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { openAPIRouteHandler } from "hono-openapi"; -import { HUB_CONDITION_REGISTRY } from "@intx/authz"; +import { HUB_CONDITION_REGISTRY, type MailSendAuthorizer } from "@intx/authz"; import { honoLogger, type HonoContext } from "@intx/log/hono"; import { createNoopCredentialCipher } from "@intx/crypto"; import { getLogger } from "@intx/log"; @@ -48,6 +48,7 @@ import { createPrincipalRoutes, createInviteRoutes } from "./routes/principals"; import { createRoleRoutes, createRoleAssignRoutes } from "./routes/roles"; import { createGrantRoutes, createEvaluateRoutes } from "./routes/grants"; import { createRunRoutes } from "./routes/runs"; +import { createMailSendAuthorizer } from "./mail-send-authorization"; import { createWorkflowRoutes } from "./routes/workflows"; import { createWorkflowDefinitionRoutes } from "./routes/workflow-definitions"; import { @@ -168,6 +169,13 @@ export type MountHubRoutesDeps = { */ grantStoreFor?: GrantStoreFactory; conditionRegistry?: ConditionRegistry; + /** + * Decides whether the caller may send the mail that starts a run. Optional: + * when omitted, the database-backed authorizer over `grantStoreFor` is + * used. It evaluates conditions with its own narrower registry, not + * `conditionRegistry`. A database-free test supplies its own. + */ + authorizeMailSend?: MailSendAuthorizer; approvalStore?: ApprovalStore; signalCorrelationStore?: SignalCorrelationStore; readRunLifecycles?: ReadRunLifecycles; @@ -242,6 +250,12 @@ export function mountHubRoutes( grantStore, conditionRegistry, }); + const authorizeMailSend = + opts.authorizeMailSend ?? + createMailSendAuthorizer({ + db, + grantStoreFor, + }); const approvalStore = opts.approvalStore ?? createApprovalStore(db); const signalCorrelationStore = opts.signalCorrelationStore ?? createSignalCorrelationStore(db); @@ -366,6 +380,7 @@ export function mountHubRoutes( : {}), grantStore, grantStoreFor, + authorizeMailSend, conditionRegistry, requireGrant, approvalStore, @@ -407,6 +422,7 @@ export function mountHubRoutes( sidecarRouter, repoStore, grantStoreFor, + authorizeMailSend, authorizeGrant, requireGrant, }), @@ -568,6 +584,8 @@ export type CreateAppOpts = { principalKeyStore?: PrincipalKeyStore; grantStore?: GrantStore; grantStoreFor?: GrantStoreFactory; + /** See `authorizeMailSend` on the route mount options. */ + authorizeMailSend?: MailSendAuthorizer; approvalStore?: ApprovalStore; signalCorrelationStore?: SignalCorrelationStore; readRunLifecycles?: ReadRunLifecycles; @@ -596,6 +614,7 @@ export function createApp({ principalKeyStore, grantStore, grantStoreFor, + authorizeMailSend, approvalStore, signalCorrelationStore, readRunLifecycles, @@ -638,6 +657,7 @@ export function createApp({ maxTarballBytes, ...(grantStore ? { grantStore } : {}), ...(grantStoreFor ? { grantStoreFor } : {}), + ...(authorizeMailSend ? { authorizeMailSend } : {}), ...(approvalStore ? { approvalStore } : {}), ...(signalCorrelationStore ? { signalCorrelationStore } : {}), ...(readRunLifecycles ? { readRunLifecycles } : {}), diff --git a/packages/hub-api/src/index.ts b/packages/hub-api/src/index.ts index 4947150d4..227459588 100644 --- a/packages/hub-api/src/index.ts +++ b/packages/hub-api/src/index.ts @@ -30,11 +30,16 @@ export { createMailTriggeredRunGrantsMaterializer, createCorrespondentGrantMinter, createRunGrantsLoader, + deriveRunMailSendGrantRow, lockTenantRunDelegatedGrants, setRunToolGrantEffect, type MailTriggeredRunGrantsDeps, type CorrespondentGrantMinterDeps, } from "./run-grant-materialization"; +export { + createMailSendAuthorizer, + type MailSendAuthorizerDeps, +} from "./mail-send-authorization"; export { findDelegatedAnchorRunIds, rederiveRunDelegatedGrants, diff --git a/packages/hub-api/src/mail-send-authorization.ts b/packages/hub-api/src/mail-send-authorization.ts new file mode 100644 index 000000000..baf6b3be5 --- /dev/null +++ b/packages/hub-api/src/mail-send-authorization.ts @@ -0,0 +1,140 @@ +import { and, eq } from "drizzle-orm"; + +import { + evaluateMailSend, + MAIL_SEND_CONDITION_REGISTRY, + mailAddressDomain, + type MailSendAuthorizer, + type MailSendCoordinate, + type MailSendDecision, + type MailSendSender, +} from "@intx/authz"; +import type { DB, GrantStoreFactory } from "@intx/db"; +import { resolveLocalMailParty, type LocalMailParty } from "@intx/db"; +import { principal } from "@intx/db/schema"; +import { getLogger } from "@intx/log"; + +const logger = getLogger(["hub-api", "mail-send-authorization"]); + +export type MailSendAuthorizerDeps = { + db: DB["db"]; + grantStoreFor: GrantStoreFactory; +}; + +/** + * Decide, for each recipient, whether the sender may send it mail: the + * sender's `mail.send` grants (direct and role, unexpired, in its tenant) + * against the recipient's coordinates. A recipient whose domain is a host + * name has a `domain` coordinate; a local run or user also has its principal + * and tenant coordinates, and a run its definition's asset. A recipient's coordinates do + * not depend on its signing key or run state, so a deny on a recipient always + * reaches it. + * + * A sender address that names no deployment with a run principal is + * `sender_unresolved`, and no recipient is decided. The sender's principal + * status is not read: a run deactivates its principal when it ends, and mail + * it sent before then, held or still in flight, is decided by its grants. To + * stop a run's sends, deny or remove its send grant. + * + * A recipient whose coordinates cannot be resolved (an ambiguous address, a + * query fault) gets an `unresolved` decision and is logged; the other + * recipients are still decided. A recipient with no coordinate at all -- not + * a local party, and no domain that is a host name, such as an address + * literal -- matches no grant, so it is refused. + * + * The send check has no consumer, so it evaluates conditions with + * `MAIL_SEND_CONDITION_REGISTRY` and not the hub's full registry. A stored + * grant with a condition outside it throws, and the send fails closed. + * + * A fault while resolving the sender, reading its grants or evaluating a + * grant rejects the returned promise. The caller refuses every recipient + * then. + */ +export function createMailSendAuthorizer( + deps: MailSendAuthorizerDeps, +): MailSendAuthorizer { + return async ({ sender, recipients }) => { + const resolved = await resolveSender(deps.db, sender); + if (!resolved.ok) return resolved; + + const senderGrants = await deps + .grantStoreFor(deps.db) + .collectGrants(resolved.principalId, resolved.tenantId); + + const decisions: MailSendDecision[] = []; + for (const recipient of recipients) { + let coordinates: MailSendCoordinate[]; + try { + coordinates = await recipientCoordinates(deps.db, recipient); + } catch (cause) { + logger.error`Refused mail to ${recipient}: resolving its coordinates failed: ${cause instanceof Error ? cause.message : String(cause)}`; + decisions.push({ recipient, outcome: "unresolved", allowed: false }); + continue; + } + const result = await evaluateMailSend({ + recipientCoordinates: coordinates, + senderGrants, + registry: MAIL_SEND_CONDITION_REGISTRY, + }); + decisions.push({ recipient, outcome: "evaluated", ...result }); + } + return { ok: true, decisions }; + }; +} + +async function resolveSender( + db: DB["db"], + sender: MailSendSender, +): Promise< + | { ok: true; principalId: string; tenantId: string } + | { ok: false; reason: "sender_unresolved" } +> { + if (sender.kind === "principal") { + return { + ok: true, + principalId: sender.principalId, + tenantId: sender.tenantId, + }; + } + + const party = await resolveLocalMailParty(db, sender.address); + if (party === null || party.kind !== "run") { + return { ok: false, reason: "sender_unresolved" }; + } + // Deploy creates the run principal, so it exists before the first trigger. + const [row] = await db + .select({ id: principal.id }) + .from(principal) + .where( + and( + eq(principal.id, party.principalId), + eq(principal.tenantId, party.tenantId), + ), + ) + .limit(1); + if (row === undefined) return { ok: false, reason: "sender_unresolved" }; + return { ok: true, principalId: party.principalId, tenantId: party.tenantId }; +} + +async function recipientCoordinates( + db: DB["db"], + recipient: string, +): Promise { + const coordinates: MailSendCoordinate[] = []; + const domain = mailAddressDomain(recipient); + if (domain !== null) coordinates.push({ coordType: "domain", id: domain }); + const party = await resolveLocalMailParty(db, recipient); + if (party !== null) coordinates.push(...partyCoordinates(party)); + return coordinates; +} + +function partyCoordinates(party: LocalMailParty): MailSendCoordinate[] { + const coordinates: MailSendCoordinate[] = [ + { coordType: "principal", id: party.principalId }, + { coordType: "tenant", id: party.tenantId }, + ]; + if (party.kind === "run" && party.assetId !== null) { + coordinates.push({ coordType: "asset", id: party.assetId }); + } + return coordinates; +} diff --git a/packages/hub-api/src/routes/grants.ts b/packages/hub-api/src/routes/grants.ts index 8f1e39956..6916746b3 100644 --- a/packages/hub-api/src/routes/grants.ts +++ b/packages/hub-api/src/routes/grants.ts @@ -2,7 +2,14 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; import { describeRoute, validator } from "hono-openapi"; -import { authorize, validateGrantConditions } from "@intx/authz"; +import { + authorize, + MAIL_SEND_ACTION, + mailSendResourceProblem, + matchPattern, + validateGrantConditions, +} from "@intx/authz"; +import { isMailSendNamespaceResource } from "@intx/types/mail-send"; import { grant, principal, workflowRun } from "@intx/db/schema"; import { parseGrantRow } from "@intx/db"; import type { DB, DBExecutor, GrantStoreFactory } from "@intx/db"; @@ -278,7 +285,10 @@ export function createGrantRoutes({ "Creates a grant targeting either a role or a principal directly. Exactly one of roleId or principalId must be provided.", responses: { 201: jsonResponse("Grant created", GrantResponse), - 400: jsonResponse(CONDITIONS_400_DESCRIPTION, ErrorResponse), + 400: jsonResponse( + `${CONDITIONS_400_DESCRIPTION} A \`mail.send\` resource that can match no recipient coordinate, a domain pattern that is not in lower case, a \`mail.send\` grant whose action cannot match \`send\`, or a pattern outside the \`mail.send\` namespace that names part of a send resource, such as \`mail.send*:domain:\`, gets an \`ErrorResponse\` that names the problem.`, + ErrorResponse, + ), }, }), validator("json", CreateGrant), @@ -289,6 +299,25 @@ export function createGrantRoutes({ // request is a 400 before this handler runs. const body = c.req.valid("json"); + // A `mail.send` row that can match no coordinate, or whose action cannot + // match `send`, would never decide a send, so a deny of that shape would + // block nothing. The update route cannot change a resource or an + // action, so only creation checks them. + const resourceProblem = mailSendResourceProblem(body.resource); + if (resourceProblem !== null) { + return errorResponse(c, "bad_request", resourceProblem); + } + if ( + isMailSendNamespaceResource(body.resource) && + !matchPattern(body.action, MAIL_SEND_ACTION) + ) { + return errorResponse( + c, + "bad_request", + `a ${JSON.stringify(body.resource)} grant decides only sends, so its action must match "${MAIL_SEND_ACTION}"`, + ); + } + const problems = validateGrantConditions( { resource: body.resource, diff --git a/packages/hub-api/src/routes/runs.ts b/packages/hub-api/src/routes/runs.ts index 6cbc6b22c..c3d463c01 100644 --- a/packages/hub-api/src/routes/runs.ts +++ b/packages/hub-api/src/routes/runs.ts @@ -18,6 +18,7 @@ import type { } from "@intx/db"; import { runPrincipalActiveKeySql } from "@intx/db"; import { getLogger } from "@intx/log"; +import type { MailSendAuthorizer } from "@intx/authz"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { extractPartByPath } from "@intx/mime"; @@ -178,6 +179,8 @@ export type CreateRunRoutesDeps = { // Builds a grant store that reads through the given executor; the mail-send // trigger reads delegator grants with it. grantStoreFor: GrantStoreFactory; + // Decides whether the caller may send the mail that starts a run. + authorizeMailSend: MailSendAuthorizer; conditionRegistry: ConditionRegistry; requireGrant: RequireGrant; // The run approvals-list route reads the run's approval decisions here. @@ -195,6 +198,7 @@ export function createRunRoutes({ workflowLifecycleService, grantStore, grantStoreFor, + authorizeMailSend, conditionRegistry, requireGrant, approvalStore, @@ -222,6 +226,7 @@ export function createRunRoutes({ principalKeyStore, runTokenStore, grantStoreFor, + authorizeMailSend, sidecarRouter, ...(workflowDispatchService !== undefined ? { workflowDispatchService } @@ -911,6 +916,10 @@ export function createRunRoutes({ "Attachment validation error. Each variant carries a structured code (too_many_attachments, oversize_attachment, disallowed_mime_type, invalid_attachment_name, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", ErrorResponse, ), + 403: jsonResponse( + "The caller lacks `manage` on the run, (`insufficient_grants`) a delegator lacks a grant the definition requires, or (`mail_send_denied`) no `mail.send` grant of the caller allows mail to the run", + ErrorResponse, + ), 404: jsonResponse("Run not found", ErrorResponse), 409: jsonResponse( "Run address is not routable, its allocation is no longer active, the run is stopping or terminal, (`deployer_unavailable`) the definition has invoker-sourced grant requirements and the deployer is not recorded or no longer active, or (`workflow_run_principal_inactive`) the first trigger finds the run principal suspended or deactivated", diff --git a/packages/hub-api/src/routes/tenants.ts b/packages/hub-api/src/routes/tenants.ts index 28ea47f25..26e93e36b 100644 --- a/packages/hub-api/src/routes/tenants.ts +++ b/packages/hub-api/src/routes/tenants.ts @@ -4,6 +4,7 @@ import { Hono, type Context, type MiddlewareHandler } from "hono"; import { describeRoute, validator } from "hono-openapi"; import { getLogger } from "@intx/log"; +import { MAIL_SEND_ACTION, mailSendResource } from "@intx/authz"; import { tenant, role, principalRole, grant } from "@intx/db/schema"; import { createPrincipalStore, @@ -222,19 +223,37 @@ export function createTenantRoutes({ createdAt: now, }); - // Grant owner role full access - await tx.insert(grant).values({ + // Owner and admin may send mail to any recipient in the tenant. + const tenantSendGrant = (roleId: string) => ({ id: generateId("grant"), tenantId, - roleId: ownerRoleId, - resource: "*", - action: "*", - effect: "allow", - origin: "system", + roleId, + resource: mailSendResource("tenant", tenantId), + action: MAIL_SEND_ACTION, + effect: "allow" as const, + origin: "system" as const, createdAt: now, updatedAt: now, }); + // Grant owner role every action on every resource. The `*` grant does + // not reach the `mail.send` namespace, so the owner also needs the + // send row. + await tx.insert(grant).values([ + { + id: generateId("grant"), + tenantId, + roleId: ownerRoleId, + resource: "*", + action: "*", + effect: "allow", + origin: "system", + createdAt: now, + updatedAt: now, + }, + tenantSendGrant(ownerRoleId), + ]); + // Grant admin role broad management access const adminRoleId = roleIds["admin"]; if (adminRoleId) { @@ -272,6 +291,7 @@ export function createTenantRoutes({ createdAt: now, updatedAt: now, }, + tenantSendGrant(adminRoleId), ]); } diff --git a/packages/hub-api/src/routes/workflows.test.ts b/packages/hub-api/src/routes/workflows.test.ts index 4967ba075..a0fbd90c7 100644 --- a/packages/hub-api/src/routes/workflows.test.ts +++ b/packages/hub-api/src/routes/workflows.test.ts @@ -9,7 +9,9 @@ import { createInMemoryGrantStore, evaluateGrants, toolConsumer, + type MailSendAuthorizer, } from "@intx/authz"; +import { permitAllMailSend } from "@intx/authz/testing"; import { TenantConfigInvalidError, WorkflowRunDispatchPayloadConflictError, @@ -892,6 +894,9 @@ type TestAppOpts = { tenantConfig?: unknown; repoDirById?: Map; runLifecycle?: WorkflowRunLifecycle | (() => WorkflowRunLifecycle); + // The trigger's send check. Unset, it allows every recipient; the + // database-backed authorizer is tested against a real database. + authorizeMailSend?: MailSendAuthorizer; }; // Project a workflow envelope into the deploy-approved grant-walk snapshot the @@ -939,6 +944,7 @@ function createTestApp(opts: TestAppOpts = {}) { principalKeyStore: opts.principalKeyStore ?? createMockPrincipalKeyStore(), grantStore, grantStoreFor: () => grantStore, + authorizeMailSend: opts.authorizeMailSend ?? permitAllMailSend, sidecarRouter: createMockSidecarRouter( opts.signalCalls ?? [], opts.routeMailCalls ?? [], @@ -3204,6 +3210,121 @@ describe("POST /workflows/:anchorRunId/mail", () => { expect(routeMailCalls).toHaveLength(0); }); + describe("the send check", () => { + function refusing( + effect: "deny" | null, + checks: Parameters[0][], + ): MailSendAuthorizer { + return async (args) => { + checks.push(args); + return { + ok: true, + decisions: args.recipients.map((recipient) => ({ + recipient, + outcome: "evaluated", + allowed: false, + effect, + grant: null, + })), + }; + }; + } + + test.each([ + ["a deny", "deny" as const], + ["no grant", null], + ])( + "%s refuses a first mail before anything is written or sent", + async (_label, effect) => { + const checks: Parameters[0][] = []; + const runGrantsCalls: RunGrantsCall[] = []; + const routeMailCalls: RouteMailCall[] = []; + const inserts: InsertRecord[] = []; + const app = createTestApp({ + grants: [manageGrant()], + runGrantsCalls, + routeMailCalls, + db: { deploymentRow, assetRow: workflowAssetRow, inserts }, + workflowJson: WORKFLOW_JSON_WITH_TOOLS, + authorizeMailSend: refusing(effect, checks), + }); + + const res = await app.fetch( + authedPost(`${base()}/${DEPLOYMENT_ID}/mail`, { + content: "kick off", + }), + ); + + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("mail_send_denied"); + expect(checks).toEqual([ + { + sender: { + kind: "principal", + // The caller's grants decide, not the deployer's or the run's. + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + }, + recipients: [`${DEPLOYMENT_ID}@${DOMAIN}`], + }, + ]); + // No run principal is linked, no grant is committed, nothing is sent. + expect(inserts).toEqual([]); + expect(runGrantsCalls).toEqual([]); + expect(routeMailCalls).toEqual([]); + }, + ); + + // A fault while resolving the run's coordinates is not a refusal, so the + // caller sees a server error it may retry, not `mail_send_denied`. + test("an unresolved run address fails the request before anything is written", async () => { + const runGrantsCalls: RunGrantsCall[] = []; + const routeMailCalls: RouteMailCall[] = []; + const inserts: InsertRecord[] = []; + const app = createTestApp({ + grants: [manageGrant()], + runGrantsCalls, + routeMailCalls, + db: { deploymentRow, assetRow: workflowAssetRow, inserts }, + workflowJson: WORKFLOW_JSON_WITH_TOOLS, + authorizeMailSend: async ({ recipients }) => ({ + ok: true, + decisions: recipients.map((recipient) => ({ + recipient, + outcome: "unresolved", + allowed: false, + })), + }), + }); + + const res = await app.fetch( + authedPost(`${base()}/${DEPLOYMENT_ID}/mail`, { content: "kick off" }), + ); + + expect(res.status).toBe(500); + expect(inserts).toEqual([]); + expect(runGrantsCalls).toEqual([]); + expect(routeMailCalls).toEqual([]); + }); + + test("an allow lets the mail through", async () => { + const routeMailCalls: RouteMailCall[] = []; + const app = createTestApp({ + grants: [manageGrant()], + routeMailCalls, + db: { deploymentRow, assetRow: workflowAssetRow, inserts: [] }, + workflowJson: WORKFLOW_JSON_WITH_TOOLS, + }); + + const res = await app.fetch( + authedPost(`${base()}/${DEPLOYMENT_ID}/mail`, { content: "kick off" }), + ); + + expect(res.status).toBe(202); + expect(routeMailCalls).toHaveLength(1); + }); + }); + test("mints a run principal, anchors the run, and sends run.grants before the mail", async () => { const runGrantsCalls: RunGrantsCall[] = []; const routeMailCalls: RouteMailCall[] = []; @@ -3233,8 +3354,8 @@ describe("POST /workflows/:anchorRunId/mail", () => { expect(sendOrder.map((s) => s.kind)).toEqual(["run.grants", "mail"]); // The run.grants frame carries the run id (the local part of the - // deployment's mail address) and the definition-pure tool grants the - // capability walk lifted. + // deployment's mail address), the definition-pure tool grants the + // capability walk lifted, and the run's send grant. expect(runGrantsCalls).toHaveLength(1); const grantsCall = runGrantsCalls[0]; if (grantsCall === undefined) throw new Error("missing run.grants call"); @@ -3245,10 +3366,13 @@ describe("POST /workflows/:anchorRunId/mail", () => { grantsCall.stepGrants.map((g) => [g.resource, g]), ); expect([...byResource.keys()].sort()).toEqual([ + "mail.send:*:*", "tool:read_file", "tool:run_shell", ]); + expect(byResource.get("mail.send:*:*")?.action).toBe("send"); for (const g of grantsCall.stepGrants) { + if (g.resource === "mail.send:*:*") continue; expect(g.action).toBe("invoke"); expect(g.origin).toBe("creator"); expect(g.principalId).not.toBeNull(); @@ -3286,8 +3410,9 @@ describe("POST /workflows/:anchorRunId/mail", () => { status: "running", }); + // Two tool grants and the run's send grant. const grantInserts = inserts.filter((i) => i.table === grantTable); - expect(grantInserts).toHaveLength(2); + expect(grantInserts).toHaveLength(3); // The committed grant principal matches the minted run principal. for (const gi of grantInserts) { const grantRow = assertBody(GrantInsert, gi.values); diff --git a/packages/hub-api/src/routes/workflows.ts b/packages/hub-api/src/routes/workflows.ts index 1ebef4b4f..aad1c4325 100644 --- a/packages/hub-api/src/routes/workflows.ts +++ b/packages/hub-api/src/routes/workflows.ts @@ -21,6 +21,7 @@ import { type PrincipalKeyStore, type RunTokenStore, } from "@intx/db"; +import type { MailSendAuthorizer } from "@intx/authz"; import { correlationIdFromSignalName, ErrorResponse, @@ -236,6 +237,8 @@ export type CreateWorkflowRoutesDeps = { // Builds a grant store that reads through the given executor; the trigger // reads delegator grants with it. grantStoreFor: GrantStoreFactory; + // Decides whether the caller may send the mail that starts a run. + authorizeMailSend: MailSendAuthorizer; authorizeGrant: AuthorizeGrant; requireGrant: RequireGrant; }; @@ -249,6 +252,7 @@ export function createWorkflowRoutes({ sidecarRouter, repoStore, grantStoreFor, + authorizeMailSend, authorizeGrant, requireGrant, }: CreateWorkflowRoutesDeps): Hono { @@ -259,6 +263,7 @@ export function createWorkflowRoutes({ principalKeyStore, runTokenStore, grantStoreFor, + authorizeMailSend, sidecarRouter, ...(workflowDispatchService !== undefined ? { workflowDispatchService } @@ -818,6 +823,10 @@ export function createWorkflowRoutes({ "Attachment validation error. Each variant carries a structured code (too_many_attachments, oversize_attachment, disallowed_mime_type, invalid_attachment_name, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", ErrorResponse, ), + 403: jsonResponse( + "The caller lacks `manage` on the deployment, (`insufficient_grants`) a delegator lacks a grant the definition requires, or (`mail_send_denied`) no `mail.send` grant of the caller allows mail to the deployment", + ErrorResponse, + ), 404: jsonResponse("Workflow deployment not found", ErrorResponse), 409: jsonResponse( "Deployment address is not routable, its allocation is no longer active, its top-level run is stopping or terminal, (`deployer_unavailable`) the definition has invoker-sourced grant requirements and the deployer is not recorded or no longer active, or (`workflow_run_principal_inactive`) the first trigger finds the run principal suspended or deactivated", diff --git a/packages/hub-api/src/run-delegated-grants.ts b/packages/hub-api/src/run-delegated-grants.ts index cda8a450f..5764fd254 100644 --- a/packages/hub-api/src/run-delegated-grants.ts +++ b/packages/hub-api/src/run-delegated-grants.ts @@ -22,7 +22,6 @@ import { type GrantStoreFactory, } from "@intx/db"; import type { GrantEffect, GrantOrigin } from "@intx/types"; -import { isMailAcceptNamespaceResource } from "@intx/types/mail-accept"; import { canonicalJsonStringify } from "@intx/types/wire-definition-hash"; import type { GrantRule } from "@intx/types/authz"; import { runtimeGrantRules } from "@intx/workflow-deploy/capabilities"; @@ -36,7 +35,7 @@ import { } from "./grant-materialization"; import { approvalToolName, - bindSelfRunRequirements, + delegatedRunRequirements, collectCreatorGrants, loadActiveDeployerId, loadAssetCreatorPrincipalId, @@ -407,7 +406,7 @@ async function rederiveRun( ); } // The deployment's top-level run id is its anchor id. - const requirements = bindSelfRunRequirements( + const requirements = delegatedRunRequirements( snapshot.grantRequirements, anchorRunId, ); @@ -472,17 +471,7 @@ async function rederiveRun( // Two requirements that differ only in effect share a key. The first one // decides whether a row exists: after a lapse and a regain, a remaining // `deny` row stands for the key and the `allow` is not added again. - // A `mail.accept` row comes from the snapshot's accept markers or from a - // correspondent the run mailed, never from a delegator. The deploy gate - // refuses a requirement in that namespace; this keeps a snapshot that - // carries one from deleting those rows. - if ( - seen.has(key) || - runtimeKeys.has(key) || - isMailAcceptNamespaceResource(req.resource) - ) { - continue; - } + if (seen.has(key) || runtimeKeys.has(key)) continue; seen.add(key); const ofKey = rows.filter( (row) => diff --git a/packages/hub-api/src/run-grant-materialization.test.ts b/packages/hub-api/src/run-grant-materialization.test.ts index 40dc0a193..1cd9a27bc 100644 --- a/packages/hub-api/src/run-grant-materialization.test.ts +++ b/packages/hub-api/src/run-grant-materialization.test.ts @@ -1,9 +1,21 @@ import { describe, test, expect } from "bun:test"; -import { createInMemoryGrantStore, toolConsumer } from "@intx/authz"; +import { + createInMemoryGrantStore, + evaluateGrants, + evaluateMailAdmission, + evaluateMailSend, + HUB_CONDITION_REGISTRY, + toolConsumer, +} from "@intx/authz"; import type { MailAcceptCoordinate } from "@intx/authz"; +import { filterGrantsToDeclaredResources } from "@intx/workflow-deploy"; import type { GrantRule } from "@intx/types/authz"; -import { SELF_RUN_RESOURCE, type GrantWalkSnapshot } from "@intx/types"; +import { + SELF_RUN_RESOURCE, + type GrantRequirement, + type GrantWalkSnapshot, +} from "@intx/types"; import { grant as grantTable, principal as principalTable, @@ -12,16 +24,17 @@ import { workflowRun as workflowRunTable, workflowRunLaunchSpec as workflowRunLaunchSpecTable, } from "@intx/db/schema"; -import type { PrincipalKeyStore, RunTokenStore } from "@intx/db"; +import type { RunTokenStore } from "@intx/db"; import { makeGrantRow } from "./grant-materialization"; import { - bindSelfRunRequirements, createCorrespondentGrantMinter, + delegatedRunRequirements, createMailTriggeredRunGrantsMaterializer, credentialResolutionUnrecorded, deriveCredentialUseGrantRows, deriveMailAcceptGrantRows, + deriveRunMailSendGrantRow, grantRowsWithLockedCredentialResolution, stageRunGrantsFromSnapshot, type CommittedRunGrants, @@ -328,9 +341,24 @@ function creatorGrant(): GrantRule { }; } -describe("bindSelfRunRequirements", () => { +describe("delegatedRunRequirements", () => { + test("drops a requirement in a reserved namespace", () => { + const delegated = delegatedRunRequirements( + [ + { + resource: "mail.accept:tenant:t1", + action: "accept", + source: "creator", + }, + { resource: "wallet:w1", action: "spend", source: "creator" }, + ], + "run_abc", + ); + expect(delegated.map((r) => r.resource)).toEqual(["wallet:w1"]); + }); + test("binds only the placeholder resource to the run id", () => { - const bound = bindSelfRunRequirements( + const bound = delegatedRunRequirements( [ { resource: SELF_RUN_RESOURCE, action: "read", source: "creator" }, { resource: "workflow-run:*", action: "read", source: "invoker" }, @@ -354,7 +382,12 @@ describe("stageRunGrantsFromSnapshot with the run's own resource", () => { return { ...creatorGrant(), resource, action: "read" }; } - function stage(creatorGrants: GrantRule[]) { + function stage( + creatorGrants: GrantRule[], + grantRequirements: GrantRequirement[] = [ + { resource: SELF_RUN_RESOURCE, action: "read", source: "creator" }, + ], + ) { return stageRunGrantsFromSnapshot({ snapshot: { perStep: [], grantRequirements: [] }, tenantId: TENANT_ID, @@ -367,17 +400,52 @@ describe("stageRunGrantsFromSnapshot with the run's own resource", () => { legacyMailAccept: null, invokerGrants: [], creatorGrants, - grantRequirements: [ - { resource: SELF_RUN_RESOURCE, action: "read", source: "creator" }, - ], + grantRequirements, credentialBindings: [], }); } + // The deploy gate refuses such a requirement, but a snapshot stored before + // its namespace was reserved can carry one. + test("stages no delegated row for a reserved-namespace requirement", async () => { + const staged = await stage( + [{ ...creatorGrant(), resource: "*", action: "*" }], + [ + { + resource: `mail.accept:tenant:${TENANT_ID}`, + action: "accept", + source: "creator", + }, + ], + ); + if (!staged.ok) throw new Error("expected staging to succeed"); + expect(staged.grantRows.map((g) => g.resource)).toEqual(["mail.send:*:*"]); + }); + + // A delegated `mail.send` row would never lapse with its delegator, and a + // narrow allow would outrank an operator deny of `mail.send:*:*`. + test("stages only the run's own send grant for a mail.send requirement", async () => { + const staged = await stage( + [{ ...creatorGrant(), resource: "*", action: "*" }], + [ + { + resource: "mail.send:domain:evil.example", + action: "send", + source: "creator", + }, + ], + ); + if (!staged.ok) throw new Error("expected staging to succeed"); + expect( + staged.grantRows.map((g) => ({ resource: g.resource, origin: g.origin })), + ).toEqual([{ resource: "mail.send:*:*", origin: "creator" }]); + }); + test("binds the placeholder to the run's own id", async () => { const staged = await stage([delegator("workflow-run:*")]); if (!staged.ok) throw new Error("expected the requirement to resolve"); expect(staged.grantRows.map((g) => g.resource)).toEqual([ + "mail.send:*:*", `workflow-run:${STAGED_RUN_ID}`, ]); }); @@ -859,11 +927,13 @@ describe("createMailTriggeredRunGrantsMaterializer staging", () => { const resources = result.stepGrants .map((g) => `${g.resource}/${g.action}`) .sort(); - // The tool grant, the invoker requirement, and the resolved accept rows - // all materialize; `mail.accept:invoker` names the deployer. + // The tool grant, the invoker requirement, the resolved accept rows and + // the run's send grant all materialize; `mail.accept:invoker` names the + // deployer. expect(resources).toEqual([ `mail.accept:principal:${DEPLOYER_PRINCIPAL_ID}/accept`, `mail.accept:tenant:${TENANT_ID}/accept`, + "mail.send:*:*/send", "secret:other/use", "tool:read_file/invoke", ]); @@ -1491,6 +1561,89 @@ describe("createMailTriggeredRunGrantsMaterializer admission gate", () => { }); }); +// The run's send grant rides the same rows as its tool grants, so it must +// decide only `mail.send` checks: never a tool, effect, credential or +// admission check, and never past a child's grant cap. +describe("deriveRunMailSendGrantRow", () => { + const row = deriveRunMailSendGrantRow( + "tnt_send", + "prn_run_send", + new Date("2026-01-01T00:00:00.000Z"), + ); + const rule: GrantRule = { ...row, roleId: null }; + + test("is one creator allow on every recipient", () => { + expect(row).toMatchObject({ + tenantId: "tnt_send", + principalId: "prn_run_send", + resource: "mail.send:*:*", + action: "send", + effect: "allow", + origin: "creator", + conditions: null, + expiresAt: null, + }); + }); + + test("allows no tool, effect or credential request", async () => { + for (const [resource, action] of [ + ["tool:mail_send", "invoke"], + ["tool:mail_reply", "invoke"], + ["effect:push", "invoke"], + ["credential:crd_1", "use"], + ["workflow-run:run_1", "manage"], + ] as const) { + const result = await evaluateGrants([rule], resource, action, { + conditions: "pessimistic", + }); + expect(result.effect).not.toBe("allow"); + } + }); + + test("admits no inbound mail", async () => { + const admission = await evaluateMailAdmission({ + senderCoordinates: [{ coordType: "tenant", id: "tnt_send" }], + recipientGrants: [rule], + }); + expect(admission.admit).toBe(false); + }); + + test("does not pass a child's grant cap", () => { + expect( + filterGrantsToDeclaredResources( + [rule], + new Set(["tool:mail_send", "tool:mail_reply"]), + new Set(["mail_send"]), + ), + ).toEqual([]); + }); + + test("a deny row narrows it and a narrower allow row does not", async () => { + const other = [{ coordType: "domain" as const, id: "other.example" }]; + const decide = (extra: GrantRule) => + evaluateMailSend({ + recipientCoordinates: other, + senderGrants: [rule, extra], + registry: HUB_CONDITION_REGISTRY, + }); + + const narrowerAllow = await decide({ + ...rule, + id: "grt_allow_domain", + resource: "mail.send:domain:example.com", + }); + const deny = await decide({ + ...rule, + id: "grt_deny_domain", + resource: "mail.send:domain:other.example", + effect: "deny", + }); + + expect(narrowerAllow.allowed).toBe(true); + expect(deny.allowed).toBe(false); + }); +}); + describe("deriveMailAcceptGrantRows", () => { const CTX: MailAcceptGrantContext = { invokerPrincipalId: "prn_invoker", @@ -1728,20 +1881,13 @@ describe("stageRunGrantsFromSnapshot for an HTTP launch", () => { staged.stepGrants.map((g) => `${g.resource}/${g.action}`).sort(), ).toEqual([ `mail.accept:principal:${DEPLOYER_PRINCIPAL_ID}/accept`, + "mail.send:*:*/send", "tool:mail_send/invoke", ]); }); }); describe("createCorrespondentGrantMinter", () => { - // The minter resolves correspondents through the key store; these cases - // mint nothing, so a no-op stub suffices. - const stubPrincipalKeyStore: PrincipalKeyStore = { - generate: async () => "pky_stub", - sign: async () => new Uint8Array(64), - getPublicKey: async () => "pky_stub_public", - }; - // A DB stand-in for the minter's unlocked rule check. It serves the sending // anchor and the frozen snapshot of its definition, and counts the // transactions the minter opens. The locked path itself is covered by the DB @@ -1783,10 +1929,7 @@ describe("createCorrespondentGrantMinter", () => { function mintFrom( db: Parameters[0]["db"], ) { - return createCorrespondentGrantMinter({ - db, - principalKeyStore: stubPrincipalKeyStore, - })({ + return createCorrespondentGrantMinter({ db })({ senderAddress: WORKFLOW_ADDRESS, recipientAddresses: ["vendor@partner.example"], }); diff --git a/packages/hub-api/src/run-grant-materialization.ts b/packages/hub-api/src/run-grant-materialization.ts index 216f7a06c..47d8f46a0 100644 --- a/packages/hub-api/src/run-grant-materialization.ts +++ b/packages/hub-api/src/run-grant-materialization.ts @@ -39,7 +39,6 @@ import type { DB, DBExecutor, GrantStoreFactory, - PrincipalKeyStore, RunTokenStore, } from "@intx/db"; import { @@ -48,7 +47,7 @@ import { loadRunPrincipalActiveKey, workflowRunExecutability, loadDeploymentGrantSnapshot, - resolveSenderPrincipal, + resolveLocalMailParty, } from "@intx/db"; import { getLogger } from "@intx/log"; import type { @@ -58,13 +57,14 @@ import type { } from "@intx/types/authz"; import { hubCredentialId } from "@intx/types/credential-delivery"; import { - deriveWorkflowRunId, isSidecarAllocationDispatchable, SELF_RUN_RESOURCE, type GrantRequirement, type GrantWalkSnapshot, } from "@intx/types"; import { isMailAcceptNamespaceResource } from "@intx/types/mail-accept"; +import { MAIL_SEND_NAMESPACE } from "@intx/types/mail-send"; +import { reservedGrantNamespace } from "@intx/types/reserved-namespaces"; import { RunGrantsFrame } from "@intx/types/sidecar"; import { ToolDefinition } from "@intx/types/runtime"; import { @@ -79,6 +79,7 @@ import { deriveRunPrincipalId } from "@intx/hub-common"; import { evaluateMailAdmission, MAIL_ACCEPT_ACTION, + MAIL_SEND_ACTION, mailAcceptRelationToken, mailAcceptResource, parseMailAcceptResource, @@ -93,6 +94,8 @@ import { type MaterializedGrantRow, } from "./grant-materialization"; +const log = getLogger(["hub-api", "run-grant-materialization"]); + export const TOOL_GRANT_PREFIX = "tool:"; /** @@ -409,6 +412,39 @@ export function deriveMailAcceptGrantRows( ); } +/** The resource of a run principal's send grant: every recipient. */ +export const RUN_MAIL_SEND_RESOURCE = `${MAIL_SEND_NAMESPACE}:*:*`; + +/** + * The run principal's send grant: one creator-origin `mail.send:*:*` / + * `send` / `allow` row, staged for every deployment at first trigger. It + * allows sends to every recipient that has a coordinate: a local party, or an + * address whose domain is a host name. It does not decide whether a step may + * send mail at all; the sidecar's `tool:mail_send` and `tool:mail_reply` + * grants do. + * A `deny` row narrows it. A narrower `allow` row does not, because this row + * still allows every recipient that the narrower row does not name. To allow + * only some recipients, an operator removes this row and adds narrower + * `allow` rows. + */ +export function deriveRunMailSendGrantRow( + tenantId: string, + runPrincipalId: string, + now: Date, +): MaterializedGrantRow { + return makeGrantRow({ + tenantId, + principalId: runPrincipalId, + resource: RUN_MAIL_SEND_RESOURCE, + action: MAIL_SEND_ACTION, + effect: "allow", + conditions: null, + origin: "creator", + expiresAt: null, + now, + }); +} + /** * Whether the frozen grant-walk snapshot belongs to a definition with a mail * trigger. The capability walk emits a `mail.address:
` grant for each @@ -442,19 +478,35 @@ export function runGrantToWire( } /** - * Bind each requirement on the literal `SELF_RUN_RESOURCE` placeholder to - * `workflow-run:`. The operator approved the placeholder; binding it - * before the authority check makes the delegator cover the concrete run id. + * The requirements a run's delegated rows come from, for both first-trigger + * staging and re-derivation. Each requirement on the literal + * `SELF_RUN_RESOURCE` placeholder binds to `workflow-run:`: the + * operator approved the placeholder, and binding it before the authority + * check makes the delegator cover the concrete run id. + * + * A requirement in a reserved namespace is dropped. Rows in such a namespace + * never come from a delegator, and re-derivation could never lapse one. The + * deploy gate refuses such a requirement, but a snapshot stored before the + * namespace was reserved can still carry one. */ -export function bindSelfRunRequirements( +export function delegatedRunRequirements( grantRequirements: readonly GrantRequirement[], runId: string, ): GrantRequirement[] { - return grantRequirements.map((req) => - req.resource === SELF_RUN_RESOURCE - ? { ...req, resource: `workflow-run:${runId}` } - : req, - ); + const delegated: GrantRequirement[] = []; + for (const req of grantRequirements) { + const namespace = reservedGrantNamespace(req.resource); + if (namespace !== null) { + log.warn`Run ${runId} skips its grant requirement ${req.resource} / ${req.action}: ${namespace.namespace} is a reserved namespace`; + continue; + } + delegated.push( + req.resource === SELF_RUN_RESOURCE + ? { ...req, resource: `workflow-run:${runId}` } + : req, + ); + } + return delegated; } export type StageRunGrantsFromSnapshotArgs = { @@ -525,9 +577,11 @@ export type StageRunGrantsResult = * Stage a run's grant rows from the deploy-approved grant-walk snapshot, the * resolved declared requirements, and the anchor's resolved credential * bindings. The snapshot's per-step grants project the run's runtime - * `tool:`/`effect:` rows. Each distinct credential and consumer becomes one - * system-origin `credential:{id}` / `use` row. The mail-triggered - * materializer and the external trigger route both drive this one tail. + * `tool:`/`effect:` rows and its `mail.accept` rows. Every run gets one + * `mail.send:*:*` / `send` row (`deriveRunMailSendGrantRow`). Each distinct + * credential and consumer becomes one system-origin `credential:{id}` / `use` + * row. The mail-triggered materializer and the external trigger route both + * drive this one tail. * Returns the staged rows and their wire projection, or a rejection when a * declared requirement's authority is insufficient. No database write happens * here -- `commitRunGrants` performs it once the caller has accepted delivery. @@ -560,7 +614,7 @@ export async function stageRunGrantsFromSnapshot( const materialization = await resolveGrantMaterialization({ tenantId: args.tenantId, targetPrincipalId: args.runPrincipalId, - grantRequirements: bindSelfRunRequirements( + grantRequirements: delegatedRunRequirements( args.grantRequirements, args.runId, ), @@ -583,6 +637,7 @@ export async function stageRunGrantsFromSnapshot( const grantRows = [ ...runtimeGrantRows, ...mailAcceptGrantRows, + deriveRunMailSendGrantRow(args.tenantId, args.runPrincipalId, args.now), ...materialization.grantRows, ...credentialGrantRows, ]; @@ -1610,18 +1665,13 @@ export function createMailTriggeredRunGrantsMaterializer( const correspondentLogger = getLogger(["hub-api", "correspondent-grant"]); /** - * Resolve the principal a correspondent's reply will present. A party with a - * durable identity resolves through the same `resolveSenderPrincipal` the reply - * resolves through. A local deployment that `resolveSenderPrincipal` cannot - * resolve -- one that has not fired yet (the first fire links its principal to - * the anchor), or one whose signing key is retired while its sidecar is - * replaced -- resolves from its anchor row instead: the linked principal when - * there is one, else `deriveRunPrincipalId(tenantId, runId)` with the runId - * taken from the deployment address. Deploy creates the run principal with - * that same derived id, and the mail seam and the trigger route derive it the - * same way, so a first fire that commits concurrently cannot make the two - * disagree. The anchor is matched on its exact stored address, as the mail - * materializer matches it. + * Resolve the principal a correspondent's reply will present, through + * `resolveLocalMailParty`, which reads no signing key. A user resolves to its + * principal. A local deployment resolves to its run principal: the linked + * principal when there is one, else `deriveRunPrincipalId(tenantId, runId)`. + * Deploy creates the run principal with that same derived id, and the mail + * seam and the trigger route derive it the same way, so a first fire that + * commits concurrently cannot make the two disagree. * * Only an executable anchor qualifies: a stopping or terminal deployment never * fires or replies, so a row for it would be dead weight. An anchor without a @@ -1630,37 +1680,29 @@ const correspondentLogger = getLogger(["hub-api", "correspondent-grant"]); */ async function resolveCorrespondentPrincipalId( tx: DBExecutor, - principalKeyStore: PrincipalKeyStore, address: string, now: Date, ): Promise { - const resolved = await resolveSenderPrincipal(tx, principalKeyStore, address); - if (resolved !== null) return resolved.principalId; + const party = await resolveLocalMailParty(tx, address); + if (party === null) return null; + if (party.kind === "user") return party.principalId; const [anchor] = await tx .select({ - id: workflowRun.id, - anchorRunId: workflowRun.anchorRunId, - tenantId: workflowRun.tenantId, - principalId: workflowRun.principalId, status: workflowRun.status, expiresAt: workflowRun.expiresAt, cancellationRequestedAt: workflowRun.cancellationRequestedAt, }) .from(workflowRun) - .where(eq(workflowRun.address, address)) + .where(eq(workflowRun.id, party.anchorRunId)) .limit(1); if ( anchor === undefined || - anchor.anchorRunId !== anchor.id || workflowRunExecutability(anchor, now) !== "executable" ) { return null; } - return ( - anchor.principalId ?? - deriveRunPrincipalId(anchor.tenantId, deriveWorkflowRunId(address)) - ); + return party.principalId; } /** @@ -1691,7 +1733,6 @@ async function holdsCorrespondentRule( export type CorrespondentGrantMinterDeps = { db: DB["db"]; - principalKeyStore: PrincipalKeyStore; }; /** @@ -1793,15 +1834,14 @@ export function createCorrespondentGrantMinter( for (const recipientAddress of args.recipientAddresses) { // Each recipient runs in its own savepoint. A recipient that fails to - // resolve or insert (an ambiguous user address, a keyless principal, - // a query fault) rolls back only its own work, so it never costs a + // resolve or insert (an ambiguous user address, a query fault) rolls + // back only its own work, so it never costs a // co-recipient of the same mail its grant. try { await tx.transaction(async (sp) => { const recipientPrincipalId = await resolveCorrespondentPrincipalId( sp, - deps.principalKeyStore, recipientAddress, now, ); diff --git a/packages/hub-api/src/workflow-run-trigger.test.ts b/packages/hub-api/src/workflow-run-trigger.test.ts index 84f65c724..50f333544 100644 --- a/packages/hub-api/src/workflow-run-trigger.test.ts +++ b/packages/hub-api/src/workflow-run-trigger.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; +import { permitAllMailSend } from "@intx/authz/testing"; import { MAX_MAIL_OUTBOUND_BODY_BYTES } from "@intx/types/sidecar"; import { @@ -62,6 +63,7 @@ function makeTrigger() { principalKeyStore: unusedDep("principalKeyStore"), runTokenStore: unusedDep("runTokenStore"), grantStoreFor: unusedDep("grantStoreFor"), + authorizeMailSend: unusedDep("authorizeMailSend"), sidecarRouter: unusedDep("sidecarRouter"), repoStore: unusedDep("repoStore"), }); @@ -170,6 +172,7 @@ describe("triggerWorkflowRun definition asset lookup", () => { principalKeyStore: unusedDep("principalKeyStore"), runTokenStore: unusedDep("runTokenStore"), grantStoreFor: unusedDep("grantStoreFor"), + authorizeMailSend: permitAllMailSend, sidecarRouter: unusedDep("sidecarRouter"), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- this branch only opens committed reads and treats a null result as an absent lifecycle repoStore: repoStore as unknown as TriggerWorkflowRunDeps["repoStore"], @@ -310,6 +313,7 @@ describe("triggerWorkflowRun first-trigger staging", () => { } as unknown as TriggerWorkflowRunDeps["principalKeyStore"], runTokenStore: unusedDep("runTokenStore"), grantStoreFor: opts.grantStoreFor, + authorizeMailSend: permitAllMailSend, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the direct path only routes the trigger mail sidecarRouter: { routeMail: opts.routeMail ?? (async () => true), diff --git a/packages/hub-api/src/workflow-run-trigger.ts b/packages/hub-api/src/workflow-run-trigger.ts index b3204c8fb..cad32a946 100644 --- a/packages/hub-api/src/workflow-run-trigger.ts +++ b/packages/hub-api/src/workflow-run-trigger.ts @@ -15,6 +15,7 @@ import { and, eq, isNotNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { type } from "arktype"; +import type { MailSendAuthorizer } from "@intx/authz"; import { asset, hubConsumersOf, @@ -97,11 +98,16 @@ export const WorkflowRunTriggerResponse = type({ messageId: "string", }); +/** The error code of a run mail the caller's `mail.send` grants refuse. */ +export const MAIL_SEND_DENIED_CODE = "mail_send_denied"; + export type TriggerWorkflowRunDeps = { db: DB["db"]; principalKeyStore: PrincipalKeyStore; runTokenStore: RunTokenStore; grantStoreFor: GrantStoreFactory; + // Decides whether the caller may send the mail that starts the run. + authorizeMailSend: MailSendAuthorizer; sidecarRouter: SidecarRouter; workflowDispatchService?: WorkflowDispatchService; repoStore: RepoStore; @@ -167,6 +173,7 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { principalKeyStore, runTokenStore, grantStoreFor, + authorizeMailSend, sidecarRouter, workflowDispatchService, repoStore, @@ -280,6 +287,44 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { }, }; } + // The caller's `mail.send` grants decide whether it may mail this run. + // The check runs before the trigger links the run principal, commits + // grants or dispatches, so a refused first mail leaves the deployment as + // it was. The route's `manage` check decides who may start a run; this + // one decides what the caller may send. + const sendAuthorization = await authorizeMailSend({ + sender: { + kind: "principal", + principalId: principal.id, + tenantId: tenant.id, + }, + recipients: [address], + }); + // A fault while resolving the run's coordinates is not a refusal: it + // fails the request as a fault while resolving the sender does. + if ( + sendAuthorization.ok && + sendAuthorization.decisions[0]?.outcome === "unresolved" + ) { + throw new Error( + `the send check could not resolve the coordinates of ${address}`, + ); + } + if ( + !sendAuthorization.ok || + sendAuthorization.decisions[0]?.allowed !== true + ) { + return { + ok: false, + status: 403, + body: { + error: { + code: MAIL_SEND_DENIED_CODE, + message: "No mail.send grant allows mail to this workflow run", + }, + }, + }; + } const durableLifecycle = await readRunLifecycle( anchorRunId, tenant.domain, diff --git a/packages/hub-sessions/src/hub-session-lookups.ts b/packages/hub-sessions/src/hub-session-lookups.ts index 0a8ced5d6..97942c7c8 100644 --- a/packages/hub-sessions/src/hub-session-lookups.ts +++ b/packages/hub-sessions/src/hub-session-lookups.ts @@ -107,6 +107,7 @@ export function createHubSessionLookups( SidecarLookups, | "materializeMailTriggeredRunGrants" | "mintCorrespondentGrant" + | "authorizeMailSend" | "resyncCredentials" | "loadRunCredentials" | "loadRunGrants" diff --git a/packages/hub-sessions/src/session-service.test.ts b/packages/hub-sessions/src/session-service.test.ts index 6f5a633e7..c7b047632 100644 --- a/packages/hub-sessions/src/session-service.test.ts +++ b/packages/hub-sessions/src/session-service.test.ts @@ -2689,7 +2689,6 @@ describe("deployCodeSourcedWorkflow", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${DEPLOY_ADDRESS}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); if (!approval.ok) throw new Error("expected approval"); @@ -2745,7 +2744,6 @@ describe("deployCodeSourcedWorkflow", () => { const { approval, projection, closure } = await makeBodyApproveOutput([ "director:@intx/agent/default", `mail.address:${DEPLOY_ADDRESS}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); if (!approval.ok) throw new Error("expected approval"); @@ -2786,7 +2784,6 @@ describe("deployCodeSourcedWorkflow", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${DEPLOY_ADDRESS}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ], ); if (!approval.ok) throw new Error("expected approval"); @@ -2842,7 +2839,6 @@ describe("deployCodeSourcedWorkflow", () => { await makeEmptyAgentBodyApproveOutput([ "director:@intx/agent/default", `mail.address:${DEPLOY_ADDRESS}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); if (!approval.ok) throw new Error("expected approval"); diff --git a/packages/hub-sessions/src/workflow-probe-gate.mail-accept.test.ts b/packages/hub-sessions/src/workflow-probe-gate.mail-accept.test.ts index 13d01e2e4..814927854 100644 --- a/packages/hub-sessions/src/workflow-probe-gate.mail-accept.test.ts +++ b/packages/hub-sessions/src/workflow-probe-gate.mail-accept.test.ts @@ -73,8 +73,8 @@ describe("gateAndFreezeProbeResult mail.accept grant requirements", () => { expect(result).toEqual({ ok: false, - reason: "mail_accept_grant_requirements", - mailAcceptGrantRequirements: [requirement], + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements: [requirement], }); }); diff --git a/packages/hub-sessions/src/workflow-probe-gate.mail-send.test.ts b/packages/hub-sessions/src/workflow-probe-gate.mail-send.test.ts new file mode 100644 index 000000000..cf53e78a9 --- /dev/null +++ b/packages/hub-sessions/src/workflow-probe-gate.mail-send.test.ts @@ -0,0 +1,109 @@ +// Gate tests for the `mail.send` namespace in a probe answer. A grant +// requirement inside it is refused; the per-step grants and approvals are not +// checked, so a stored bundle whose walk reports a `mail.send:` +// approval string freezes. + +import { describe, test, expect } from "bun:test"; + +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; +import type { GrantRequirement } from "@intx/types"; +import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { action, defineWorkflow, projectLiveToInert } from "@intx/workflow"; + +import { + describeProbeGateRefusal, + gateAndFreezeProbeResult, + type PersistFrozenApprovalFn, +} from "./workflow-probe-gate"; +import type { WorkflowProbeResult } from "./ws/sidecar-handler"; + +const DEFINITION_SOURCE: WorkflowDefinitionSource = { + kind: "asset", + assetId: "asset-1", + package: { + format: "source", + commitSha: "0123456789abcdef0123456789abcdef01234567", + }, +}; + +// A probe answer for a one-action workflow whose shipped hash matches the hub +// recompute. The step grants and the requirements ride the grant-walk +// snapshot directly, as an untrusted sidecar could send them. +async function probeWith(args: { + grants: readonly string[]; + grantRequirements: readonly GrantRequirement[]; +}): Promise { + const projection = WorkflowProjectionDefinition.assert( + projectLiveToInert( + defineWorkflow({ + id: "wf-under-test", + trigger: { type: "manual" }, + steps: { push: action({ handler: "push" }) }, + }), + ), + ); + return { + projection, + grants: [...args.grants], + grantWalkSnapshot: { + perStep: [{ stepId: "push", grants: [...args.grants], grantEffects: {} }], + grantRequirements: [...args.grantRequirements], + }, + wireHash: await computeWireDefinitionHash(projection), + }; +} + +describe("gateAndFreezeProbeResult mail.send namespace", () => { + test("refuses a requirement inside the namespace and freezes nothing", async () => { + const persist: PersistFrozenApprovalFn = async () => { + throw new Error("freeze must not run when the gate rejects"); + }; + const requirement: GrantRequirement = { + resource: "mail.send:*:*", + action: "send", + source: "creator", + }; + + const result = await gateAndFreezeProbeResult({ + assetId: "asset-1", + source: DEFINITION_SOURCE, + probeResult: await probeWith({ + grants: [], + grantRequirements: [requirement], + }), + approvals: { kind: "approve-probed" }, + persist, + }); + + expect(result).toEqual({ + ok: false, + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements: [requirement], + }); + if (result.ok) throw new Error("expected a refusal"); + expect(describeProbeGateRefusal(result)).toContain( + "send grants come only from the hub at a run's first trigger", + ); + }); + + test("freezes a bundle whose step grants carry a domain approval string", async () => { + let persisted = 0; + const result = await gateAndFreezeProbeResult({ + assetId: "asset-1", + source: DEFINITION_SOURCE, + probeResult: await probeWith({ + grants: ["mail.send:example.com"], + grantRequirements: [], + }), + approvals: { kind: "approve-probed" }, + persist: async () => { + persisted += 1; + return { definitionId: "def-1" }; + }, + }); + + expect(result.ok).toBe(true); + expect(persisted).toBe(1); + }); +}); diff --git a/packages/hub-sessions/src/workflow-probe-gate.refusal.test.ts b/packages/hub-sessions/src/workflow-probe-gate.refusal.test.ts index 3dda2e254..5bf942bf2 100644 --- a/packages/hub-sessions/src/workflow-probe-gate.refusal.test.ts +++ b/packages/hub-sessions/src/workflow-probe-gate.refusal.test.ts @@ -74,13 +74,45 @@ describe("describeProbeGateRefusal", () => { [ { ok: false, - reason: "mail_accept_grant_requirements", - mailAcceptGrantRequirements: [ + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements: [ { resource: "mail.accept:*", action: "accept", source: "creator" }, ], }, - "mail_accept_grant_requirements: accept-rules are declared only " + - "through mailAccept; declared: accept on mail.accept:* (creator)", + "reserved_namespace_grant_requirements: accept-rules are declared " + + "only through mailAccept; declared: accept on mail.accept:* (creator)", + ], + [ + { + ok: false, + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements: [ + { resource: "mail.accept:*", action: "accept", source: "creator" }, + { + resource: "mail.accept:tenant:*", + action: "accept", + source: "invoker", + }, + ], + }, + "reserved_namespace_grant_requirements: accept-rules are declared " + + "only through mailAccept; declared: accept on mail.accept:* " + + "(creator), accept on mail.accept:tenant:* (invoker)", + ], + [ + { + ok: false, + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements: [ + { resource: "mail.send:*:*", action: "send", source: "creator" }, + { resource: "mail.accept:*", action: "accept", source: "creator" }, + ], + }, + "reserved_namespace_grant_requirements: accept-rules are declared " + + "only through mailAccept; declared: accept on mail.accept:* " + + "(creator); send grants come only from the hub at a run's first " + + "trigger and from operator grants; declared: send on " + + "mail.send:*:* (creator)", ], [ { diff --git a/packages/hub-sessions/src/workflow-probe-gate.ts b/packages/hub-sessions/src/workflow-probe-gate.ts index 798d79fb4..5479065b4 100644 --- a/packages/hub-sessions/src/workflow-probe-gate.ts +++ b/packages/hub-sessions/src/workflow-probe-gate.ts @@ -15,9 +15,11 @@ // 4. Reject a projection declaring a trigger type the runtime does not // implement, so a deployment that could only ever sit inert never gets // approved. -// 5. Reject a grant requirement inside the `mail.accept` namespace, and a -// `mail.accept` walk marker the projection's `mailAccept` does not -// produce, so the accept surface comes only from the hashed `mailAccept`. +// 5. Reject a grant requirement inside a reserved grant namespace (such +// as `mail.accept`), so that namespace gets rows only from its fixed +// sources, and a `mail.accept` walk marker the projection's +// `mailAccept` does not produce, so the accept surface comes only from +// the hashed `mailAccept`. // 6. Reject a declared grant requirement whose conditions a run cannot // evaluate, so the frozen requirements only mint grant rows that // authorization can evaluate. @@ -54,6 +56,10 @@ import type { PackumentFetcher, RegistryConfig } from "@intx/tool-packaging"; import type { WorkflowProjectionDefinition } from "@intx/types/sidecar"; import type { ToolPackageManifest } from "@intx/types/tool-packages"; import { isMailAcceptNamespaceResource } from "@intx/types/mail-accept"; +import { + RESERVED_GRANT_NAMESPACES, + reservedGrantNamespace, +} from "@intx/types/reserved-namespaces"; import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import type { WorkflowDefinitionAssetSource, @@ -137,7 +143,7 @@ export type PersistFrozenApprovalFn = ( * tarball, a shipped hash that does not match the hub recompute * (tamper-evidence), advisory grants the operator did not approve, declared * grant requirements the operator did not approve, a trigger type the runtime - * does not implement, a grant requirement inside the `mail.accept` namespace, a + * does not implement, a grant requirement inside a reserved namespace, a * `mail.accept` walk marker the projection's `mailAccept` does not produce, * credential bindings that cannot be delivered, a declared grant requirement * whose conditions a run cannot evaluate, and an executable step the grant @@ -191,9 +197,9 @@ export type ProbeGateResult = } | { readonly ok: false; - readonly reason: "mail_accept_grant_requirements"; - /** The declared requirements inside the `mail.accept` namespace. */ - readonly mailAcceptGrantRequirements: readonly GrantRequirement[]; + readonly reason: "reserved_namespace_grant_requirements"; + /** The declared requirements inside a reserved grant namespace. */ + readonly reservedNamespaceGrantRequirements: readonly GrantRequirement[]; } | { readonly ok: false; @@ -283,6 +289,24 @@ function describeGrantRequirement(requirement: GrantRequirement): string { return `${requirement.action} on ${requirement.resource} (${requirement.source})`; } +// One clause for each reserved namespace the requirements fall in: the +// namespace's source of rows, then the requirements declared inside it. +function describeReservedGrantRequirements( + requirements: readonly GrantRequirement[], +): string { + return RESERVED_GRANT_NAMESPACES.flatMap((reserved) => { + const declared = requirements.filter( + (requirement) => + reservedGrantNamespace(requirement.resource) === reserved, + ); + return declared.length === 0 + ? [] + : [ + `${reserved.source}; declared: ${declared.map(describeGrantRequirement).join(", ")}`, + ]; + }).join("; "); +} + /** * Name why the gate refused a definition and what it refused, for the error * the deploy caller receives. Every refusal starts with its reason code. @@ -299,8 +323,8 @@ export function describeProbeGateRefusal(refusal: ProbeGateRefusal): string { return `creator_requirements_unsupported_for_package_registry_tarball: a workflow from a package-registry tarball cannot declare creator-sourced grant requirements, because the hub does not know who published the package; declared: ${refusal.creatorGrantRequirements.map(describeGrantRequirement).join("; ")}`; case "unimplemented_trigger": return `unimplemented_trigger: ${refusal.unimplementedTriggerTypes.join(", ")}`; - case "mail_accept_grant_requirements": - return `mail_accept_grant_requirements: accept-rules are declared only through mailAccept; declared: ${refusal.mailAcceptGrantRequirements.map(describeGrantRequirement).join("; ")}`; + case "reserved_namespace_grant_requirements": + return `reserved_namespace_grant_requirements: ${describeReservedGrantRequirements(refusal.reservedNamespaceGrantRequirements)}`; case "mail_accept_markers_undeclared": return `mail_accept_markers_undeclared: the projection's mailAccept does not produce ${refusal.undeclaredMailAcceptMarkers.join(", ")}`; case "credential_bindings_invalid": @@ -625,22 +649,24 @@ export async function gateAndFreezeProbeResult( }; } - // Refuse a grant requirement inside the `mail.accept` namespace. `mailAccept` - // is the only source of accept-rules; such a requirement would materialize - // an accept-grant outside the approved accept surface (a creator holding `*` - // satisfies `mail.accept:*`, which admits every sender). Like the trigger - // check this is an author-definition error an operator cannot approve away, - // and it runs here because `defineWorkflow`'s own check rides the pinned - // closure, which a stale or untrusted sidecar can skip. - const mailAcceptGrantRequirements = - probeResult.grantWalkSnapshot.grantRequirements.filter((requirement) => - isMailAcceptNamespaceResource(requirement.resource), + // Refuse a grant requirement inside a reserved namespace. Such a namespace + // gets rows only from fixed sources (for `mail.accept`, the approved + // `mailAccept` relations); a requirement would materialize a grant outside + // them (a creator holding `*` satisfies `mail.accept:*`, which admits every + // sender). Like the trigger check this is an author-definition error an + // operator cannot approve away, and it runs here because `defineWorkflow`'s + // own check rides the pinned closure, which a stale or untrusted sidecar + // can skip. It reads only the grant requirements, not the per-step grants + // or approvals. + const reservedNamespaceGrantRequirements = + probeResult.grantWalkSnapshot.grantRequirements.filter( + (requirement) => reservedGrantNamespace(requirement.resource) !== null, ); - if (mailAcceptGrantRequirements.length > 0) { + if (reservedNamespaceGrantRequirements.length > 0) { return { ok: false, - reason: "mail_accept_grant_requirements", - mailAcceptGrantRequirements, + reason: "reserved_namespace_grant_requirements", + reservedNamespaceGrantRequirements, }; } diff --git a/packages/hub-sessions/src/ws/sidecar-events.ts b/packages/hub-sessions/src/ws/sidecar-events.ts index 9303a05cb..ee76bcb4c 100644 --- a/packages/hub-sessions/src/ws/sidecar-events.ts +++ b/packages/hub-sessions/src/ws/sidecar-events.ts @@ -18,6 +18,7 @@ import type { ConnectorThreadState, } from "@intx/types/runtime"; import type { SignalKind } from "@intx/types"; +import type { MailSendAuthorizer } from "@intx/authz"; import type { MailAcceptCoordinate, ResolvedSenderPrincipal } from "@intx/db"; import { getLogger } from "@intx/log"; @@ -370,6 +371,14 @@ export type SidecarLookups = { recipientAddresses: string[]; }) => Promise; + /** + * Decides which recipients of a relayed mail its sender may send to, from + * the sender's `mail.send` grants. The relay routes, parks, relays and + * mints correspondent grants only for the recipients it allows. Without it + * the relay refuses every recipient. + */ + authorizeMailSend?: MailSendAuthorizer; + /** Ingests a received workflow-run pack and returns whether the wire * layer should ack or reject the pack to the sidecar. `source` is derived * from the authenticated socket, never from the frame. The lookup must diff --git a/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts index efe2541ed..e111b6cf5 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { configureSync, getConfig } from "@intx/log"; import { connectAllocated, @@ -17,6 +26,7 @@ import { createSidecarRouter, type SidecarAuthIdentity, } from "./sidecar-handler"; +import type { SidecarLookups } from "./sidecar-events"; import { MAX_MAIL_OUTBOUND_BODY_BYTES } from "@intx/types/sidecar"; import { waitUntil } from "@intx/types/testing"; @@ -2431,3 +2441,488 @@ describe("SidecarRouter mail.outbound body cap", () => { expect(framesOfType(ws, "run.grants")).toHaveLength(0); }); }); + +describe("SidecarRouter mail send check", () => { + const rawMessage = btoa( + "From: sender@example.test\r\nTo: someone@example.test\r\nMessage-ID: \r\n\r\nbody", + ); + const SENDER = TEST_IDENTITY.workflowRunAddress; + const ALLOWED = "allowed@example.test"; + const REFUSED = "refused@example.test"; + + // The relay logs why it refused a recipient; a test checks the reason here. + const logLines: string[] = []; + let savedLogConfig: ReturnType; + + beforeAll(() => { + savedLogConfig = getConfig(); + configureSync({ + reset: true, + sinks: { + capture: (record) => { + logLines.push(record.message.join("")); + }, + }, + loggers: [ + { category: [], lowestLevel: "debug", sinks: ["capture"] }, + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["capture"], + }, + ], + }); + }); + + afterAll(() => { + if (!savedLogConfig) { + throw new Error( + "no logging configuration was captured before this suite replaced it", + ); + } + configureSync({ reset: true, ...savedLogConfig }); + }); + + beforeEach(() => { + logLines.length = 0; + }); + + function decide( + allowed: (recipient: string) => boolean, + ): NonNullable { + return async ({ recipients }) => ({ + ok: true, + decisions: recipients.map((recipient) => + allowed(recipient) + ? { + recipient, + outcome: "evaluated", + allowed: true, + effect: "allow", + grant: null, + } + : { + recipient, + outcome: "evaluated", + allowed: false, + effect: "deny", + grant: { + id: "grt_deny_refused", + resource: `mail.send:domain:example.test`, + action: "send", + effect: "deny", + origin: "role", + specificity: 0, + }, + }, + ), + }); + } + + function send( + router: AllocatedRouter, + ws: Parameters[0], + recipients: string[], + ): void { + router.handleMessage( + ws, + JSON.stringify({ + type: "mail.outbound", + senderAddress: SENDER, + generation: 1, + rawMessage, + recipients, + }), + ); + } + + // The router handles one socket's `mail.outbound` frames in order, so an + // audit copy sent after a mail is persisted only once the relay has finished + // with that mail. A test awaits the persist before it asserts what the relay + // did not do. + function persistBarrier(): { + persistMail: NonNullable; + persisted: Promise; + } { + const persisted = Promise.withResolvers(); + return { + persisted: persisted.promise, + persistMail: async () => { + persisted.resolve(undefined); + return []; + }, + }; + } + + function sendAuditCopy( + router: AllocatedRouter, + ws: Parameters[0], + ): void { + router.handleMessage( + ws, + JSON.stringify({ + type: "mail.outbound", + senderAddress: SENDER, + generation: 1, + rawMessage, + recipients: [ALLOWED], + delivered: true, + }), + ); + } + + test("routes, mints and materializes only the recipients it allows", async () => { + const calls: unknown[] = []; + const undelivered: string[][] = []; + const router = createAllocatedRouter({ + lookups: { + authorizeMailSend: decide((recipient) => recipient === ALLOWED), + async materializeMailTriggeredRunGrants(args) { + calls.push({ materialize: args.agentAddress }); + return { outcome: "materialized", stepGrants: [] }; + }, + async mintCorrespondentGrant(args) { + calls.push({ mint: args.recipientAddresses }); + }, + }, + }); + const done = Promise.withResolvers(); + router.events.on("mail.outbound.undelivered", ({ recipients }) => { + undelivered.push(recipients); + done.resolve(undefined); + }); + const ws = await connectAllocated(router, [SENDER]); + + send(router, ws, [SENDER, ALLOWED, REFUSED]); + await done.promise; + + expect(undelivered).toEqual([[ALLOWED]]); + expect(calls).toEqual([{ mint: [ALLOWED] }]); + expect(framesOfType(ws, "mail.inbound")).toHaveLength(0); + expect( + logLines.filter((line) => line.startsWith("Refused mail from")), + ).toEqual([ + `Refused mail from ${SENDER} to ${SENDER}: grant grt_deny_refused (deny) decides it`, + `Refused mail from ${SENDER} to ${REFUSED}: grant grt_deny_refused (deny) decides it`, + ]); + }); + + test("delivers to an allowed run recipient", async () => { + const router = createAllocatedRouter({ + lookups: { + authorizeMailSend: decide(() => true), + async materializeMailTriggeredRunGrants() { + return { outcome: "materialized", stepGrants: [] }; + }, + }, + }); + const ws = await connectAllocated(router, [SENDER]); + + send(router, ws, [SENDER]); + await ws.awaitSent((sent) => + sent.some((raw) => raw.includes('"mail.inbound"')), + ); + + expect(framesOfType(ws, "mail.inbound")).toHaveLength(1); + }); + + test("refuses a recipient no grant allows", async () => { + const barrier = persistBarrier(); + const calls: unknown[] = []; + const router = createAllocatedRouter({ + lookups: { + async authorizeMailSend({ recipients }) { + return { + ok: true, + decisions: recipients.map((recipient) => ({ + recipient, + outcome: "evaluated", + allowed: false, + effect: null, + grant: null, + })), + }; + }, + persistMail: barrier.persistMail, + async materializeMailTriggeredRunGrants(args) { + calls.push({ materialize: args.agentAddress }); + return { outcome: "materialized", stepGrants: [] }; + }, + async mintCorrespondentGrant(args) { + calls.push({ mint: args.recipientAddresses }); + }, + }, + }); + const undelivered: unknown[] = []; + router.events.on("mail.outbound.undelivered", (event) => { + undelivered.push(event); + }); + const ws = await connectAllocated(router, [SENDER]); + + send(router, ws, [SENDER, REFUSED]); + sendAuditCopy(router, ws); + await barrier.persisted; + + expect(undelivered).toEqual([]); + expect(calls).toEqual([]); + expect(framesOfType(ws, "mail.inbound")).toHaveLength(0); + expect(logLines).toContain( + `Refused mail from ${SENDER} to ${REFUSED}: no send grant allows it`, + ); + }); + + test.each([ + { + name: "records only the recipients it allows in the audit copy", + allowed: (recipient: string) => recipient === ALLOWED, + stored: [ALLOWED], + }, + { + name: "keeps only the sender's record of an audit copy it refuses in full", + allowed: () => false, + stored: [], + }, + ])("$name", async ({ allowed, stored }) => { + const persisted = Promise.withResolvers(); + const router = createAllocatedRouter({ + lookups: { + authorizeMailSend: decide(allowed), + async persistMail({ recipients }) { + persisted.resolve(recipients); + return []; + }, + }, + }); + const ws = await connectAllocated(router, [SENDER]); + + router.handleMessage( + ws, + JSON.stringify({ + type: "mail.outbound", + senderAddress: SENDER, + generation: 1, + rawMessage, + recipients: [ALLOWED, REFUSED], + delivered: true, + }), + ); + + expect(await persisted.promise).toEqual([...stored]); + expect(logLines).toContain( + `Refused mail from ${SENDER} to ${REFUSED}: grant grt_deny_refused (deny) decides it`, + ); + }); + + test("refuses every recipient without an authorizer", async () => { + const barrier = persistBarrier(); + const router = createSidecarRouter({ + withExecutableWorkflowRun: async (_target, deliver) => deliver(), + ...sidecarAuth(() => [TEST_IDENTITY]), + validateSidecarIdentity: async () => true, + requestTimeoutMs: 500, + lookups: { persistMail: barrier.persistMail }, + }); + router.fenceAllocation(TEST_TARGET.allocationId, TEST_TARGET.generation); + const undelivered: unknown[] = []; + router.events.on("mail.outbound.undelivered", (event) => { + undelivered.push(event); + }); + const ws = await connectAllocated(router, [SENDER]); + + send(router, ws, [ALLOWED]); + sendAuditCopy(router, ws); + await barrier.persisted; + + expect(undelivered).toEqual([]); + expect( + logLines.some((line) => + line.includes("No mail send authorizer is wired"), + ), + ).toBe(true); + }); + + test("refuses only a recipient whose coordinates could not be resolved", async () => { + const persisted = Promise.withResolvers(); + const router = createAllocatedRouter({ + lookups: { + async authorizeMailSend({ recipients }) { + return { + ok: true, + decisions: recipients.map((recipient) => + recipient === REFUSED + ? { recipient, outcome: "unresolved", allowed: false } + : { + recipient, + outcome: "evaluated", + allowed: true, + effect: "allow", + grant: null, + }, + ), + }; + }, + async persistMail({ recipients }) { + persisted.resolve(recipients); + return []; + }, + }, + }); + const ws = await connectAllocated(router, [SENDER]); + + router.handleMessage( + ws, + JSON.stringify({ + type: "mail.outbound", + senderAddress: SENDER, + generation: 1, + rawMessage, + recipients: [ALLOWED, REFUSED], + delivered: true, + }), + ); + + expect(await persisted.promise).toEqual([ALLOWED]); + expect(logLines).toContain( + `Refused mail from ${SENDER} to ${REFUSED}: its coordinates could not be resolved`, + ); + }); + + test("refuses every recipient of an unresolved sender or a fault", async () => { + const outcomes: (() => ReturnType< + NonNullable + >)[] = [ + async () => ({ ok: false, reason: "sender_unresolved" }), + async () => { + throw new Error("grant store down"); + }, + ]; + let call = 0; + const barrier = persistBarrier(); + const router = createAllocatedRouter({ + lookups: { + async authorizeMailSend() { + const outcome = outcomes[call]; + call += 1; + if (outcome === undefined) throw new Error("unexpected call"); + return outcome(); + }, + persistMail: barrier.persistMail, + }, + }); + const undelivered: unknown[] = []; + router.events.on("mail.outbound.undelivered", (event) => { + undelivered.push(event); + }); + const ws = await connectAllocated(router, [SENDER]); + + send(router, ws, [ALLOWED]); + send(router, ws, [ALLOWED]); + sendAuditCopy(router, ws); + await barrier.persisted; + + expect(undelivered).toEqual([]); + for (const reason of ["it names no deployment", "grant store down"]) { + expect(logLines.some((line) => line.includes(reason))).toBe(true); + } + }); + + test("a parked mail whose sender fails to deploy is reported without its refused recipients", async () => { + const parked = Promise.withResolvers(); + const router = createAllocatedRouter({ + lookups: { + authorizeMailSend: decide((recipient) => recipient !== REFUSED), + async materializeMailTriggeredRunGrants() { + return { outcome: "materialized", stepGrants: [] }; + }, + // The relay parks the mail before it resolves the key, so this call + // means the mail is parked. + async resolveSenderKey() { + parked.resolve(undefined); + return null; + }, + }, + }); + const undelivered = Promise.withResolvers(); + router.events.on("mail.outbound.undelivered", ({ recipients }) => { + undelivered.resolve(recipients); + }); + const ws = await connectAllocated(router, [SENDER]); + const attempt = { ...TEST_TARGET, leaseId: "send-check-park" }; + router.noteSenderDeployStarted(SENDER, attempt); + + // The run recipient makes the mail park while the sender's key is + // unresolved; the refused external recipient is dropped before that. + send(router, ws, [SENDER, ALLOWED, REFUSED]); + await parked.promise; + router.noteSenderDeploySettled(attempt, { failed: "deploy timed out" }); + + expect(await undelivered.promise).toEqual([SENDER, ALLOWED]); + }); + + test("a grant revoked while the mail is parked refuses it on the re-drive", async () => { + let revoked = false; + const parked = Promise.withResolvers(); + const materialized: string[] = []; + const router = createAllocatedRouter({ + lookups: { + // The run recipient parks the mail and loses its grant while parked. + // The external recipient stays allowed, so its undelivered report + // marks the end of the re-drive. + authorizeMailSend: decide( + (recipient) => recipient === ALLOWED || !revoked, + ), + async materializeMailTriggeredRunGrants(args) { + materialized.push(args.agentAddress); + return { outcome: "materialized", stepGrants: [] }; + }, + async resolveSenderKey() { + parked.resolve(undefined); + return null; + }, + }, + }); + const undelivered = Promise.withResolvers(); + router.events.on("mail.outbound.undelivered", ({ recipients }) => { + undelivered.resolve(recipients); + }); + const ws = await connectAllocated(router, [SENDER]); + const attempt = { ...TEST_TARGET, leaseId: "send-check-revoke" }; + router.noteSenderDeployStarted(SENDER, attempt); + + send(router, ws, [SENDER, ALLOWED]); + await parked.promise; + revoked = true; + router.noteSenderDeploySettled(attempt, { recorded: "ab".repeat(32) }); + + expect(await undelivered.promise).toEqual([ALLOWED]); + expect(materialized).toEqual([]); + expect(framesOfType(ws, "mail.inbound")).toHaveLength(0); + }); + + test("a refused run recipient does not hold an allowed external recipient", async () => { + const router = createAllocatedRouter({ + lookups: { + authorizeMailSend: decide((recipient) => recipient === ALLOWED), + async materializeMailTriggeredRunGrants() { + return { outcome: "materialized", stepGrants: [] }; + }, + async resolveSenderKey() { + return null; + }, + }, + }); + const undelivered = Promise.withResolvers(); + router.events.on("mail.outbound.undelivered", ({ recipients }) => { + undelivered.resolve(recipients); + }); + const ws = await connectAllocated(router, [SENDER]); + router.noteSenderDeployStarted(SENDER, { + ...TEST_TARGET, + leaseId: "send-check-no-park", + }); + + send(router, ws, [SENDER, ALLOWED]); + + expect(await undelivered.promise).toEqual([ALLOWED]); + }); +}); diff --git a/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts b/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts index 9f187425a..ab21814eb 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts @@ -1,3 +1,4 @@ +import { permitAllMailSend } from "@intx/authz/testing"; import type { HarnessConfig } from "@intx/types/runtime"; import type { AgentDeployFrame, HostedIncarnation } from "@intx/types/sidecar"; @@ -129,6 +130,7 @@ export function createAllocatedRouter( ...config, lookups: { readWorkflowRunRefTips: async () => TEST_REF_TIPS, + authorizeMailSend: permitAllMailSend, ...config.lookups, }, }); diff --git a/packages/hub-sessions/src/ws/sidecar-handler.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.test.ts index 69199f31c..010779c74 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.test.ts @@ -21,6 +21,7 @@ import { type SidecarAuthIdentity, type WsHandle, } from "./sidecar-handler"; +import { permitAllMailSend } from "@intx/authz/testing"; import type { SidecarLookups } from "./sidecar-events"; import { deployReply, @@ -1412,6 +1413,7 @@ describe("SidecarRouter pre-ack sender-key interlock", () => { lookups: { resolveSenderKey, materializeMailTriggeredRunGrants, + authorizeMailSend: permitAllMailSend, }, }); router.fenceAllocation("alloc-sender", 1); diff --git a/packages/hub-sessions/src/ws/sidecar-handler.ts b/packages/hub-sessions/src/ws/sidecar-handler.ts index a7062e8cd..e3138b8ab 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.ts @@ -4,6 +4,7 @@ // table of agentAddress → sidecar connection, and dispatches frames between // sidecars and the hub's internal systems. +import type { MailSendAuthorization } from "@intx/authz"; import { getLogger } from "@intx/log"; import { WorkflowRunNotExecutableError, @@ -3456,6 +3457,74 @@ export function createSidecarRouter( } } + // The send check runs before anything holds or relays the mail: a parked + // message whose sender never initializes is reported undelivered with its + // recipients, and the host can relay that report. Both re-drives of a + // parked message enter here again, so a grant revoked while the message + // waited still refuses it. + const permitted = await permittedRecipients( + authenticatedSender, + recipients, + ); + if (permitted.length === 0) return; + await routePermittedMail( + rawMessage, + authenticatedSender, + permitted, + recordedSenderKey, + ); + } + + // The recipients of a relayed mail that its sender may send to. A refused + // recipient is logged and dropped. With no authorizer wired, an unresolved + // sender, or an authorizer fault, every recipient is refused. + async function permittedRecipients( + authenticatedSender: string, + recipients: string[], + ): Promise { + const authorize = lookups.authorizeMailSend; + if (authorize === undefined) { + logger.error`No mail send authorizer is wired; refused mail from ${authenticatedSender} to ${recipients.join(", ")}`; + return []; + } + let authorization: MailSendAuthorization; + try { + authorization = await authorize({ + sender: { kind: "address", address: authenticatedSender }, + recipients, + }); + } catch (err) { + logger.error`Authorizing mail from ${authenticatedSender} failed; refused it to ${recipients.join(", ")}: ${err instanceof Error ? err.message : String(err)}`; + return []; + } + if (!authorization.ok) { + logger.error`Refused mail from ${authenticatedSender}: it names no deployment with a run principal`; + return []; + } + const permitted: string[] = []; + for (const decision of authorization.decisions) { + if (decision.allowed) { + permitted.push(decision.recipient); + continue; + } + const decidedBy = + decision.outcome === "unresolved" + ? "its coordinates could not be resolved" + : decision.grant === null + ? "no send grant allows it" + : `grant ${decision.grant.id} (${decision.grant.effect}) decides it`; + logger.info`Refused mail from ${authenticatedSender} to ${decision.recipient}: ${decidedBy}`; + } + return permitted; + } + + // Route a relayed mail to the recipients its sender may send to. + async function routePermittedMail( + rawMessage: string, + authenticatedSender: string, + recipients: string[], + recordedSenderKey: string | null | undefined, + ): Promise { // Resolve the sender's hub-held key ONCE for the whole message, ahead of the // recipient fan-out and any grant materialization, so every recipient in // this fan-out binds the same key snapshot. A run-address sender may be @@ -3725,13 +3794,18 @@ export function createSidecarRouter( senderAddress: string, recipients: string[], ): Promise { + // The audit copy stores the mail as delivered in each recipient's session + // and pushes it to that recipient's subscribers, so it is a delivery too + // and gets the same send check. The sender's own record stays when the + // check refuses every recipient. + const permitted = await permittedRecipients(senderAddress, recipients); let results: SidecarMailPersistedRow[]; let raw: Uint8Array; try { raw = Uint8Array.from(atob(rawMessage), (c) => c.charCodeAt(0)); results = await persist({ senderAddress, - recipients, + recipients: permitted, raw, }); } catch (err) { diff --git a/packages/types/package.json b/packages/types/package.json index d781c9540..f31f2dbe3 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -80,6 +80,16 @@ "types": "./dist/mail-accept.d.ts", "default": "./dist/mail-accept.js" }, + "./reserved-namespaces": { + "intx-src": "./src/reserved-namespaces.ts", + "types": "./dist/reserved-namespaces.d.ts", + "default": "./dist/reserved-namespaces.js" + }, + "./mail-send": { + "intx-src": "./src/mail-send.ts", + "types": "./dist/mail-send.d.ts", + "default": "./dist/mail-send.js" + }, "./workflow-definition": { "intx-src": "./src/workflow-definition.ts", "types": "./dist/workflow-definition.d.ts", diff --git a/packages/types/src/authz.ts b/packages/types/src/authz.ts index e1d472a8d..7c4500041 100644 --- a/packages/types/src/authz.ts +++ b/packages/types/src/authz.ts @@ -54,3 +54,17 @@ export type ConditionEvaluator = { }; export type ConditionRegistry = Record; + +/** + * Whether a grant resource lies inside `namespace`: the bare namespace or any + * `:<...>` resource. A broader grant such as `*` or `mail.*` + * matches a resource in the namespace under pattern matching but is not in + * the namespace itself, so a gate that evaluates only this set never lets + * such a grant decide. + */ +export function isNamespaceResource( + namespace: string, + resource: string, +): boolean { + return resource === namespace || resource.startsWith(`${namespace}:`); +} diff --git a/packages/types/src/mail-accept.ts b/packages/types/src/mail-accept.ts index 699ee451b..1cf237517 100644 --- a/packages/types/src/mail-accept.ts +++ b/packages/types/src/mail-accept.ts @@ -1,30 +1,27 @@ import { type } from "arktype"; +import { isNamespaceResource } from "./authz"; + export const MAIL_ACCEPT_NAMESPACE = "mail.accept"; /** - * Whether a grant resource lies inside the `mail.accept` namespace: the bare - * namespace or any `mail.accept:<...>` resource. This is exactly the set of - * grants the inbound admission gate evaluates; a broader grant such as `*` or - * `mail.*` matches `mail.accept:...` under pattern matching but falls outside - * this set, so the gate never lets it admit mail. + * Whether a grant resource lies inside the `mail.accept` namespace. This is + * exactly the set of grants the inbound admission gate evaluates. */ export function isMailAcceptNamespaceResource(resource: string): boolean { - return ( - resource === MAIL_ACCEPT_NAMESPACE || - resource.startsWith(`${MAIL_ACCEPT_NAMESPACE}:`) - ); + return isNamespaceResource(MAIL_ACCEPT_NAMESPACE, resource); } /** - * A `mail.accept` coordinate id is valid when it is a non-empty string with no - * `:` and no `*`. The colon is the segment separator of the - * `mail.accept::` resource, so an id that carries one would split - * into extra segments and make the shape ambiguous. The `*` is the wildcard of - * grant matching, so an id that carries one would be a pattern that admits - * many senders, not the one sender it names. This owns that rule: the resource - * builder in `@intx/authz` imports it, and the `MailAccept` schema narrows on - * it, so the authoring boundary and the resource builder share one definition. + * A coordinate id is valid when it is a non-empty string with no `:` and no + * `*`. The colon is the segment separator of a `::` + * coordinate resource, so an id that carries one would split into extra + * segments and make the shape ambiguous. The `*` is the wildcard of grant + * matching, so an id that carries one would be a pattern that matches many + * targets, not one concrete target. This owns that rule for every + * coordinate namespace: the resource builder in `@intx/authz` imports it, and + * the `MailAccept` schema narrows on it, so the authoring boundary and the + * resource builder share one definition. */ export function isValidCoordId(id: string): boolean { return id.length > 0 && !id.includes(":") && !id.includes("*"); diff --git a/packages/types/src/mail-send.test.ts b/packages/types/src/mail-send.test.ts new file mode 100644 index 000000000..d93eb5db5 --- /dev/null +++ b/packages/types/src/mail-send.test.ts @@ -0,0 +1,20 @@ +import { describe, test, expect } from "bun:test"; + +import { isMailSendNamespaceResource } from "./mail-send"; + +describe("isMailSendNamespaceResource", () => { + test("holds for the bare namespace and its resources", () => { + expect(isMailSendNamespaceResource("mail.send")).toBe(true); + expect(isMailSendNamespaceResource("mail.send:*:*")).toBe(true); + expect(isMailSendNamespaceResource("mail.send:domain:example.com")).toBe( + true, + ); + }); + + test("does not hold for broader or neighbouring resources", () => { + expect(isMailSendNamespaceResource("*")).toBe(false); + expect(isMailSendNamespaceResource("mail.*")).toBe(false); + expect(isMailSendNamespaceResource("mail.sender:x")).toBe(false); + expect(isMailSendNamespaceResource("mail.accept:*:*")).toBe(false); + }); +}); diff --git a/packages/types/src/mail-send.ts b/packages/types/src/mail-send.ts new file mode 100644 index 000000000..69c41c754 --- /dev/null +++ b/packages/types/src/mail-send.ts @@ -0,0 +1,11 @@ +import { isNamespaceResource } from "./authz"; + +export const MAIL_SEND_NAMESPACE = "mail.send"; + +/** + * Whether a grant resource lies inside the `mail.send` namespace. This is + * exactly the set of grants the outbound send gate evaluates. + */ +export function isMailSendNamespaceResource(resource: string): boolean { + return isNamespaceResource(MAIL_SEND_NAMESPACE, resource); +} diff --git a/packages/types/src/reserved-namespaces.test.ts b/packages/types/src/reserved-namespaces.test.ts new file mode 100644 index 000000000..6a6776f42 --- /dev/null +++ b/packages/types/src/reserved-namespaces.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from "bun:test"; + +import { reservedGrantNamespace } from "./reserved-namespaces"; + +describe("reservedGrantNamespace", () => { + test("names the reserved namespace a resource lies in", () => { + expect(reservedGrantNamespace("mail.accept")?.namespace).toBe( + "mail.accept", + ); + expect(reservedGrantNamespace("mail.accept:*:*")?.namespace).toBe( + "mail.accept", + ); + expect(reservedGrantNamespace("mail.send")?.namespace).toBe("mail.send"); + expect(reservedGrantNamespace("mail.send:*:*")?.namespace).toBe( + "mail.send", + ); + }); + + test("returns null for a resource outside every reserved namespace", () => { + expect(reservedGrantNamespace("*")).toBeNull(); + expect(reservedGrantNamespace("mail.*")).toBeNull(); + expect(reservedGrantNamespace("mail.acceptor:x")).toBeNull(); + expect(reservedGrantNamespace("mail.accept*")).toBeNull(); + expect(reservedGrantNamespace("mail.send*")).toBeNull(); + expect(reservedGrantNamespace("mail.sender:x")).toBeNull(); + expect(reservedGrantNamespace("tool:mail_send")).toBeNull(); + }); +}); diff --git a/packages/types/src/reserved-namespaces.ts b/packages/types/src/reserved-namespaces.ts new file mode 100644 index 000000000..f08065e60 --- /dev/null +++ b/packages/types/src/reserved-namespaces.ts @@ -0,0 +1,46 @@ +import { isNamespaceResource } from "./authz"; +import { MAIL_ACCEPT_NAMESPACE } from "./mail-accept"; +import { MAIL_SEND_NAMESPACE } from "./mail-send"; + +/** + * A grant namespace whose rows come only from fixed sources, never from a + * workflow's grant requirements. `source` names those sources, for a refusal + * to tell the author where such rows come from. + */ +export type ReservedGrantNamespace = { + readonly namespace: string; + readonly source: string; +}; + +/** + * Grant namespaces a workflow may not declare a grant requirement in. A + * requirement materializes a delegated grant, and a delegator that holds `*` + * satisfies any requirement, so a requirement inside one of these namespaces + * would create rows outside the sources that the namespace allows. + */ +export const RESERVED_GRANT_NAMESPACES: readonly ReservedGrantNamespace[] = [ + { + namespace: MAIL_ACCEPT_NAMESPACE, + source: "accept-rules are declared only through mailAccept", + }, + { + namespace: MAIL_SEND_NAMESPACE, + source: + "send grants come only from the hub at a run's first trigger and " + + "from operator grants", + }, +]; + +/** + * The reserved namespace that `resource` lies in, or `null` when it lies in + * none of them. + */ +export function reservedGrantNamespace( + resource: string, +): ReservedGrantNamespace | null { + return ( + RESERVED_GRANT_NAMESPACES.find((reserved) => + isNamespaceResource(reserved.namespace, resource), + ) ?? null + ); +} diff --git a/packages/workflow-deploy/README.md b/packages/workflow-deploy/README.md index 3f6d1a7d3..c16e3817c 100644 --- a/packages/workflow-deploy/README.md +++ b/packages/workflow-deploy/README.md @@ -134,11 +134,11 @@ Public surface: — the pure address and id derivation helpers. The capability walk emits the v1 grant-shape vocabulary: `tool:`, -`director:`, `capability:`, `inference.source:`, `mail.address:`, -`mail.send:`. The shapes are deliberately uniform with what the -existing agent-deploy already enforces implicitly — the parity test -in this package's test suite is the structural-identity check that -backs the capability-surface uniformity claim. +`director:`, `capability:`, `inference.source:`, `mail.address:`. The +shapes are deliberately uniform with what the existing agent-deploy +already enforces implicitly — the parity test in this package's test +suite is the structural-identity check that backs the +capability-surface uniformity claim. The walk also emits `mail.accept:` strings from the definition's authored `mailAccept` rules, for a definition of any trigger. diff --git a/packages/workflow-deploy/src/capability-walk.test.ts b/packages/workflow-deploy/src/capability-walk.test.ts index 1024da0eb..537d18d45 100644 --- a/packages/workflow-deploy/src/capability-walk.test.ts +++ b/packages/workflow-deploy/src/capability-walk.test.ts @@ -121,10 +121,6 @@ function computeImplicitGrants( for (const trigger of workflow.triggers) { if (trigger.type !== "mail") continue; grants.add(`mail.address:${trigger.to}`); - const at = trigger.to.lastIndexOf("@"); - if (at >= 0 && at < trigger.to.length - 1) { - grants.add(`mail.send:${trigger.to.slice(at + 1)}`); - } } // Mirror the walk's mail-accept axis: emitted for any trigger, relations // resolved through the shared default resolver, and explicit @@ -573,7 +569,6 @@ describe("walkCapabilities", () => { const grants = new Set(declarations.grants); expect(grants.has("mail.address:support@example.com")).toBe(true); - expect(grants.has("mail.send:example.com")).toBe(true); }); test("unresolved director surfaces on the result without raising", () => { @@ -625,16 +620,14 @@ describe("walkCapabilities", () => { const declarations = walk.perStep.get(stepId); if (declarations === undefined) throw new Error("missing declarations"); - expect( - declarations.grants.some( - (g) => g.startsWith("mail.address:") || g.startsWith("mail.send:"), - ), - ).toBe(false); + expect(declarations.grants.some((g) => g.startsWith("mail.address:"))).toBe( + false, + ); }); // The mail-accept axis (`mail.accept:*`) is emitted from the definition's - // authored `mailAccept` field, alongside the unchanged `mail.address:` / - // `mail.send:` axis. Relational toggles emit id-less relation tokens through + // authored `mailAccept` field, alongside the unchanged `mail.address:` + // axis. Relational toggles emit id-less relation tokens through // the shared default resolver; explicit principals/assets emit concrete // coordinates. function mailAcceptGrantsOf(workflow: WorkflowDefinition): Set { @@ -715,7 +708,7 @@ describe("walkCapabilities", () => { expect(grants.has("mail.accept:asset:ast_y")).toBe(true); }); - test("mail.accept emission leaves the mail.address/mail.send axis unchanged", () => { + test("mail.accept emission leaves the mail.address axis unchanged", () => { const workflow = defineWorkflow({ id: "wf_accept_axis", agent: makeTrivialAgent(), @@ -726,7 +719,6 @@ describe("walkCapabilities", () => { const grants = mailAcceptGrantsOf(workflow); expect(grants.has("mail.address:support@example.com")).toBe(true); - expect(grants.has("mail.send:example.com")).toBe(true); expect(grants.has("mail.accept:invoker")).toBe(true); }); @@ -982,7 +974,11 @@ describe("grant-walk projections", () => { [ "reply", { - grants: ["tool:send", "effect:push", "mail.send:example.com"], + grants: [ + "tool:send", + "effect:push", + "mail.address:reply@example.com", + ], grantEffects: new Map([["tool:send", "ask"]]), }, ], @@ -999,7 +995,11 @@ describe("grant-walk projections", () => { }, { stepId: "reply", - grants: ["tool:send", "effect:push", "mail.send:example.com"], + grants: [ + "tool:send", + "effect:push", + "mail.address:reply@example.com", + ], grantEffects: { "tool:send": "ask" }, }, ], diff --git a/packages/workflow-deploy/src/capability-walk.ts b/packages/workflow-deploy/src/capability-walk.ts index 0f8ef42d6..24051332e 100644 --- a/packages/workflow-deploy/src/capability-walk.ts +++ b/packages/workflow-deploy/src/capability-walk.ts @@ -29,9 +29,6 @@ // - `mail.address:
` -- every workflow trigger of type // `"mail"` (the deployment's // right to register and receive) -// - `mail.send:` -- the trigger address's domain -// (the deployment's right to -// send mail as that domain) // - `mail.accept:` -- a relational accept marker // (`invoker`/`self`/`tenant`/ // `correspondent`) @@ -160,11 +157,10 @@ function freezeDeclarations( * Walk a workflow definition and produce per-step grant declarations. * * The walk visits every step's agent definition once; deployment-wide - * grants (the trigger-derived `mail.address:` / `mail.send:` axis and the - * `mail.accept:*` accept axis) are attached to every step because the - * workflow's mail-receive, mail-send, and mail-accept authority is a - * deployment-wide property -- any step can be the one whose run consumes - * inbound mail or generates a reply. + * grants (the trigger-derived `mail.address:` axis and the `mail.accept:*` + * accept axis) are attached to every step because the workflow's + * mail-receive and mail-accept authority is a deployment-wide property -- + * any step can be the one whose run consumes inbound mail. */ export function walkCapabilities( workflow: WorkflowDefinition, @@ -532,10 +528,6 @@ function collectTriggerGrants(workflow: WorkflowDefinition): string[] { for (const trigger of workflow.triggers) { if (trigger.type !== "mail") continue; grants.add(`mail.address:${trigger.to}`); - const domain = extractDomain(trigger.to); - if (domain !== null) { - grants.add(`mail.send:${domain}`); - } } return [...grants]; } @@ -581,14 +573,6 @@ export function collectMailAcceptGrants( return [...grants]; } -function extractDomain(address: string): string | null { - const at = address.lastIndexOf("@"); - if (at < 0 || at === address.length - 1) { - return null; - } - return address.slice(at + 1); -} - /** * Thrown when the walk finds two tool definitions that mint the same * final `tool:` grant within a single agent -- whether from a diff --git a/packages/workflow-host/src/probe/workflow-probe-handler.test.ts b/packages/workflow-host/src/probe/workflow-probe-handler.test.ts index 315b12a89..f037a1d76 100644 --- a/packages/workflow-host/src/probe/workflow-probe-handler.test.ts +++ b/packages/workflow-host/src/probe/workflow-probe-handler.test.ts @@ -231,10 +231,7 @@ describe("createWorkflowProbeExecutor", () => { expect(result.projection.id).toBe("probe-fixture"); expect(result.projection.stepOrder).toEqual(["wait"]); - expect(result.grants).toEqual([ - "mail.address:probe@example.com", - "mail.send:example.com", - ]); + expect(result.grants).toEqual(["mail.address:probe@example.com"]); // The un-flattened snapshot preserves the per-step grouping and the // (here empty) tool-grant effect map the flattened `grants` discards. // The `wait` step carries the deployment-wide trigger grants and no @@ -244,7 +241,7 @@ describe("createWorkflowProbeExecutor", () => { perStep: [ { stepId: "wait", - grants: ["mail.address:probe@example.com", "mail.send:example.com"], + grants: ["mail.address:probe@example.com"], grantEffects: {}, }, ], diff --git a/packages/workflow/src/definition/workflow.test.ts b/packages/workflow/src/definition/workflow.test.ts index 78ec4bda9..de3e0cc08 100644 --- a/packages/workflow/src/definition/workflow.test.ts +++ b/packages/workflow/src/definition/workflow.test.ts @@ -672,6 +672,32 @@ describe("mailAccept", () => { { resource: "*", action: "*", source: "creator" }, ]); }); + + test.each(["mail.send", "mail.send:*:*", "mail.send:domain:example.com"])( + "rejects a grant requirement on %p in the mail.send namespace", + (resource) => { + expect(() => + defineWorkflow({ + id: "w", + trigger: { type: "mail", to: "s@x.example" }, + steps: { a: step({ agent: makeAgent("a") }) }, + grantRequirements: [{ resource, action: "send", source: "creator" }], + }), + ).toThrow(/send grants come only from the hub at a run's first trigger/); + }, + ); + + test("keeps a mail.* grant requirement, which lies in no reserved namespace", () => { + const def = defineWorkflow({ + id: "w", + trigger: { type: "mail", to: "s@x.example" }, + steps: { a: step({ agent: makeAgent("a") }) }, + grantRequirements: [ + { resource: "mail.*", action: "*", source: "creator" }, + ], + }); + expect(def.grantRequirements).toHaveLength(1); + }); }); describe("acyclicity validation", () => { diff --git a/packages/workflow/src/definition/workflow.ts b/packages/workflow/src/definition/workflow.ts index 3efbc2d01..a0ec17b68 100644 --- a/packages/workflow/src/definition/workflow.ts +++ b/packages/workflow/src/definition/workflow.ts @@ -17,10 +17,8 @@ import type { SidecarCapabilityPolicy, } from "@intx/types"; import type { InboundMailPolicy } from "@intx/types/inbound-mail-policy"; -import { - isMailAcceptNamespaceResource, - MailAccept, -} from "@intx/types/mail-accept"; +import { MailAccept } from "@intx/types/mail-accept"; +import { reservedGrantNamespace } from "@intx/types/reserved-namespaces"; import { normalizeSingularShorthand } from "./shorthand"; import { @@ -235,17 +233,17 @@ function normalize(config: WorkflowConfig): WorkflowDefinition { } } - // `mailAccept` is the only source of accept-rules: its relations are what the - // operator approves at deploy. A grant requirement inside the `mail.accept` - // namespace would materialize an accept-grant outside that surface (a creator - // holding `*` satisfies `mail.accept:*`, which admits every sender), so reject - // it here rather than let it widen admission. + // A reserved namespace gets rows only from fixed sources (for `mail.accept`, + // the `mailAccept` relations the operator approves at deploy). A grant + // requirement inside it would materialize a grant outside those sources (a + // creator holding `*` satisfies `mail.accept:*`, which admits every sender), + // so reject it here rather than let it widen authority. for (const requirement of config.grantRequirements ?? []) { - if (isMailAcceptNamespaceResource(requirement.resource)) { + const reserved = reservedGrantNamespace(requirement.resource); + if (reserved !== null) { throw new Error( `defineWorkflow ${config.id} declares a grant requirement on ` + - `${JSON.stringify(requirement.resource)}; accept-rules are declared ` + - `only through mailAccept`, + `${JSON.stringify(requirement.resource)}; ${reserved.source}`, ); } } diff --git a/packages/workflow/src/live-inert-projector.test.ts b/packages/workflow/src/live-inert-projector.test.ts index 7cae56069..be4885343 100644 --- a/packages/workflow/src/live-inert-projector.test.ts +++ b/packages/workflow/src/live-inert-projector.test.ts @@ -106,7 +106,7 @@ function mkAgent( const OPENAI: InferencePreference = { provider: "openai", model: "gpt-4o" }; -const baseCapabilities = ["mail.send:acme.test"] as const; +const baseCapabilities = ["web.fetch:acme.test"] as const; function mkSingleStep(agent: AgentDefinition): WorkflowDefinition { return defineWorkflow({ @@ -354,12 +354,12 @@ describe("hash binds to the grant surface", () => { await expectGrantMutation(mkSingleStep(mkPluginAgent()), LSP_TOOLS); }); - test("adding a mail.send domain changes projection and hash", async () => { + test("adding a capability changes projection and hash", async () => { await expectGrantMutation( mkSingleStep( mkAgent( [alphaTool, betaTool], - ["mail.send:acme.test", "mail.send:beta.test"], + ["web.fetch:acme.test", "web.fetch:beta.test"], [OPENAI], ), ), diff --git a/tests/db/local-mail-party.test.ts b/tests/db/local-mail-party.test.ts new file mode 100644 index 000000000..afce59568 --- /dev/null +++ b/tests/db/local-mail-party.test.ts @@ -0,0 +1,191 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { resolveLocalMailParty } from "@intx/db"; +import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; +import { deriveRunPrincipalId } from "@intx/hub-common"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { + seedAsset, + seedPrincipal, + seedWorkflowRun, + tenantSlugFromId, +} from "@intx/test-harness/seed"; + +const TENANT = "tnt_party"; +const DOMAIN = "party.localhost"; + +describe.skipIf(!harnessDbEnvAvailable())( + "resolveLocalMailParty (real DB)", + () => { + let h: TestDb; + + beforeAll(async () => { + h = await createTestDb(); + }); + + afterAll(async () => { + await h.close(); + }); + + beforeEach(async () => { + await h.reset(); + await h.db.insert(tenantTable).values({ + id: TENANT, + name: TENANT, + slug: tenantSlugFromId(TENANT), + domain: DOMAIN, + parentId: null, + }); + }); + + async function seedAnchor(args: { + id: string; + status?: "deployed" | "running" | "completed" | "cancelled"; + principalId?: string; + definitionId?: string; + }) { + await seedWorkflowRun(h.db, { + id: args.id, + tenantId: TENANT, + anchorRunId: args.id, + address: `${args.id}@${DOMAIN}`, + status: args.status ?? "running", + ...(args.principalId === undefined + ? {} + : { principalId: args.principalId }), + ...(args.definitionId === undefined + ? {} + : { definitionId: args.definitionId }), + }); + } + + test("names a deployment that has not fired by its derived run principal", async () => { + await seedAnchor({ id: "run_fresh", status: "deployed" }); + + expect(await resolveLocalMailParty(h.db, `run_fresh@${DOMAIN}`)).toEqual({ + kind: "run", + anchorRunId: "run_fresh", + tenantId: TENANT, + assetId: null, + linkedPrincipalId: null, + principalId: await deriveRunPrincipalId(TENANT, "run_fresh"), + }); + }); + + test("names a fired deployment by its linked principal and asset", async () => { + await seedPrincipal(h.db, { + id: "prn_linked", + tenantId: TENANT, + kind: "workflow", + refId: "run_fired", + status: "active", + }); + await seedAsset(h.db, { + id: "ast_party", + tenantId: TENANT, + kind: "workflow", + name: "ast_party", + }); + await h.db.insert(workflowDefinition).values({ + id: "wfd_party", + tenantId: TENANT, + name: "wfd_party", + assetId: "ast_party", + }); + await seedAnchor({ + id: "run_fired", + principalId: "prn_linked", + definitionId: "wfd_party", + }); + + expect( + await resolveLocalMailParty(h.db, `run_fired@${DOMAIN}`), + ).toMatchObject({ + kind: "run", + assetId: "ast_party", + linkedPrincipalId: "prn_linked", + principalId: "prn_linked", + }); + }); + + test("matches every spelling of an address", async () => { + await seedAnchor({ id: "run_spell" }); + + for (const address of [ + `RUN_SPELL@${DOMAIN.toUpperCase()}`, + `run_spell@${DOMAIN}.`, + ]) { + expect(await resolveLocalMailParty(h.db, address)).toMatchObject({ + kind: "run", + anchorRunId: "run_spell", + }); + } + }); + + test("names an ended deployment the same way", async () => { + await seedAnchor({ id: "run_done", status: "completed" }); + + expect( + await resolveLocalMailParty(h.db, `run_done@${DOMAIN}`), + ).toMatchObject({ kind: "run", anchorRunId: "run_done" }); + }); + + test("names nothing for a run on another domain or an unknown run", async () => { + await seedAnchor({ id: "run_here" }); + + expect( + await resolveLocalMailParty(h.db, "run_here@other.localhost"), + ).toBeNull(); + expect( + await resolveLocalMailParty(h.db, `run_absent@${DOMAIN}`), + ).toBeNull(); + }); + + test("names a user principal and nothing for an unknown user", async () => { + await seedPrincipal(h.db, { + id: "prn_user", + tenantId: TENANT, + kind: "user", + refId: "usr_ann", + status: "active", + }); + + expect(await resolveLocalMailParty(h.db, `USR_ANN@${DOMAIN}.`)).toEqual({ + kind: "user", + principalId: "prn_user", + tenantId: TENANT, + }); + expect(await resolveLocalMailParty(h.db, `usr_zed@${DOMAIN}`)).toBeNull(); + }); + + test("throws for a user address that names two principals", async () => { + for (const [id, refId] of [ + ["prn_v1", "Usr_Alice"], + ["prn_v2", "USR_ALICE"], + ] as const) { + await seedPrincipal(h.db, { + id, + tenantId: TENANT, + kind: "user", + refId, + status: "active", + }); + } + + await expect( + resolveLocalMailParty(h.db, `usr_alice@${DOMAIN}`), + ).rejects.toThrow(/ambiguous/); + }); + }, +); diff --git a/tests/db/mail-accept-backfill.test.ts b/tests/db/mail-accept-backfill.test.ts index 1c0754aff..2b1cc6d3c 100644 --- a/tests/db/mail-accept-backfill.test.ts +++ b/tests/db/mail-accept-backfill.test.ts @@ -22,11 +22,7 @@ import { import { and, eq, sql } from "drizzle-orm"; -import { - createGrantStore, - createPrincipalKeyStore, - createRunTokenStore, -} from "@intx/db"; +import { createGrantStore, createRunTokenStore } from "@intx/db"; import { grant, workflowDefinition, @@ -82,6 +78,8 @@ const TENANT_ROW = `mail.accept:tenant:${TENANT}`; // A snapshot that a mail-trigger definition froze before the sender gate: the // trigger-derived `mail.address:` / `mail.send:` grants and no accept markers. +// The current walk does not emit `mail.send:`, but snapshots stored +// by an earlier walk can still carry it. function preGateMailSnapshot(): GrantWalkSnapshot { return { perStep: [ @@ -744,13 +742,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }); async function mintAndCount(): Promise { - await createCorrespondentGrantMinter({ - db: h.db, - principalKeyStore: createPrincipalKeyStore({ - db: h.db, - cipher: createTestCredentialCipher(), - }), - })({ + await createCorrespondentGrantMinter({ db: h.db })({ senderAddress: SENDING_ADDRESS, recipientAddresses: [RECIPIENT_ADDRESS], }); diff --git a/tests/db/mail-send-authorization.test.ts b/tests/db/mail-send-authorization.test.ts new file mode 100644 index 000000000..f08cb9d0e --- /dev/null +++ b/tests/db/mail-send-authorization.test.ts @@ -0,0 +1,586 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { eq } from "drizzle-orm"; + +import { createGrantStore, deactivateRunPrincipals } from "@intx/db"; +import { + principal, + principalRole, + role, + tenant as tenantTable, + workflowDefinition, +} from "@intx/db/schema"; +import { deriveRunPrincipalId } from "@intx/hub-common"; +import { createMailSendAuthorizer } from "@intx/hub-api"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { + seedAsset, + seedGrant, + seedPrincipal, + seedWorkflowRun, + tenantSlugFromId, +} from "@intx/test-harness/seed"; + +const TENANT = "tnt_send"; +const DOMAIN = "send.localhost"; +const OTHER_TENANT = "tnt_other"; +const OTHER_DOMAIN = "other.localhost"; +const SENDER_RUN = "run_sender"; +const SENDER_ADDRESS = `${SENDER_RUN}@${DOMAIN}`; +const SENDER_PRINCIPAL = "prn_sender"; + +describe.skipIf(!harnessDbEnvAvailable())( + "createMailSendAuthorizer (real DB)", + () => { + let h: TestDb; + let grantCounter = 0; + + beforeAll(async () => { + h = await createTestDb(); + }); + + afterAll(async () => { + await h.close(); + }); + + beforeEach(async () => { + await h.reset(); + for (const [id, domain] of [ + [TENANT, DOMAIN], + [OTHER_TENANT, OTHER_DOMAIN], + ] as const) { + await h.db.insert(tenantTable).values({ + id, + name: id, + slug: tenantSlugFromId(id), + domain, + parentId: null, + }); + } + await seedPrincipal(h.db, { + id: SENDER_PRINCIPAL, + tenantId: TENANT, + kind: "workflow", + refId: SENDER_RUN, + status: "active", + }); + await seedAnchor(TENANT, SENDER_RUN, { principalId: SENDER_PRINCIPAL }); + }); + + async function seedAnchor( + tenantId: string, + id: string, + opts: { + status?: "deployed" | "running" | "completed"; + principalId?: string; + definitionId?: string; + } = {}, + ) { + const domain = tenantId === TENANT ? DOMAIN : OTHER_DOMAIN; + await seedWorkflowRun(h.db, { + id, + tenantId, + anchorRunId: id, + address: `${id}@${domain}`, + status: opts.status ?? "running", + ...(opts.principalId === undefined + ? {} + : { principalId: opts.principalId }), + ...(opts.definitionId === undefined + ? {} + : { definitionId: opts.definitionId }), + }); + } + + async function grantSender( + resource: string, + effect: "allow" | "deny" = "allow", + extra: { expiresAt?: Date } = {}, + ) { + grantCounter += 1; + await seedGrant(h.db, { + id: `grt_send_${String(grantCounter)}`, + tenantId: TENANT, + principalId: SENDER_PRINCIPAL, + resource, + action: "send", + effect, + ...extra, + }); + } + + const authorize = createMailSendAuthorizer({ + get db() { + return h.db; + }, + grantStoreFor: createGrantStore, + }); + + async function decide(recipients: string[]) { + const result = await authorize({ + sender: { kind: "address", address: SENDER_ADDRESS }, + recipients, + }); + if (!result.ok) throw new Error(`sender refused: ${result.reason}`); + return result.decisions.map((d) => ({ + recipient: d.recipient, + allowed: d.allowed, + })); + } + + test("refuses an unknown sender address", async () => { + expect( + await authorize({ + sender: { kind: "address", address: `run_absent@${DOMAIN}` }, + recipients: ["x@example.com"], + }), + ).toEqual({ ok: false, reason: "sender_unresolved" }); + }); + + // A run deactivates its principal when it ends. Mail it sent before then + // can still wait in the hub, so its grants decide, not its status. + test("decides a run that has ended by its grants", async () => { + await grantSender("mail.send:*:*"); + await grantSender("mail.send:domain:evil.example", "deny"); + await deactivateRunPrincipals(h.db, TENANT, [SENDER_RUN], new Date()); + + expect(await decide(["x@example.com", "x@evil.example"])).toEqual([ + { recipient: "x@example.com", allowed: true }, + { recipient: "x@evil.example", allowed: false }, + ]); + }); + + test("decides a suspended sender by its grants", async () => { + await grantSender("mail.send:*:*"); + await h.db + .update(principal) + .set({ status: "suspended" }) + .where(eq(principal.id, SENDER_PRINCIPAL)); + + expect(await decide(["x@example.com"])).toEqual([ + { recipient: "x@example.com", allowed: true }, + ]); + }); + + test("refuses every recipient when the sender holds no send grant", async () => { + await seedAnchor(TENANT, "run_peer"); + expect(await decide(["x@example.com", `run_peer@${DOMAIN}`])).toEqual([ + { recipient: "x@example.com", allowed: false }, + { recipient: `run_peer@${DOMAIN}`, allowed: false }, + ]); + }); + + test("a namespace-wide allow allows an external recipient by its domain", async () => { + await grantSender("mail.send:*:*"); + expect(await decide(["Someone@Example.COM"])).toEqual([ + { recipient: "Someone@Example.COM", allowed: true }, + ]); + }); + + test("an operator's namespace-wide deny narrows a run's own send grant", async () => { + await grantSender("mail.send:*:*"); + await seedGrant(h.db, { + id: "grt_send_operator_deny", + tenantId: TENANT, + principalId: SENDER_PRINCIPAL, + resource: "mail.send:*", + action: "*", + effect: "deny", + }); + await seedAnchor(TENANT, "run_peer"); + expect(await decide(["x@example.com", `run_peer@${DOMAIN}`])).toEqual([ + { recipient: "x@example.com", allowed: false }, + { recipient: `run_peer@${DOMAIN}`, allowed: false }, + ]); + }); + + // The send check has no consumer, so it cannot evaluate a `tool` + // condition. A stored row that carries one throws, and the caller then + // refuses every recipient, instead of the deny refusing nothing. + test("a stored tool-conditioned deny fails the check closed", async () => { + await grantSender("mail.send:*:*"); + await seedGrant(h.db, { + id: "grt_send_tool_deny", + tenantId: TENANT, + principalId: SENDER_PRINCIPAL, + resource: "mail.send:domain:example.com", + action: "send", + effect: "deny", + conditions: { tool: "tool:pkg" }, + }); + await expect(decide(["x@example.com"])).rejects.toThrow("tool"); + }); + + test("a principal deny reaches every spelling and state of a run recipient", async () => { + await grantSender("mail.send:*:*"); + await seedAnchor(TENANT, "run_fresh", { status: "deployed" }); + await seedAnchor(TENANT, "run_done", { status: "completed" }); + for (const id of ["run_fresh", "run_done"]) { + await grantSender( + `mail.send:principal:${await deriveRunPrincipalId(TENANT, id)}`, + "deny", + ); + } + + const fresh = `RUN_FRESH@${DOMAIN.toUpperCase()}.`; + const done = `run_done@${DOMAIN}`; + const external = "x@example.com"; + expect(await decide([fresh, done, external])).toEqual([ + { recipient: fresh, allowed: false }, + { recipient: done, allowed: false }, + { recipient: external, allowed: true }, + ]); + }); + + test("an asset allow reaches a run of that asset only", async () => { + await seedAsset(h.db, { + id: "ast_send", + tenantId: TENANT, + kind: "workflow", + name: "ast_send", + }); + await h.db.insert(workflowDefinition).values({ + id: "wfd_send", + tenantId: TENANT, + name: "wfd_send", + assetId: "ast_send", + }); + await seedAnchor(TENANT, "run_asset", { definitionId: "wfd_send" }); + await seedAnchor(TENANT, "run_plain"); + await grantSender("mail.send:asset:ast_send"); + + expect( + await decide([`run_asset@${DOMAIN}`, `run_plain@${DOMAIN}`]), + ).toEqual([ + { recipient: `run_asset@${DOMAIN}`, allowed: true }, + { recipient: `run_plain@${DOMAIN}`, allowed: false }, + ]); + }); + + test("a tenant allow reaches a user and a run in another tenant", async () => { + await seedPrincipal(h.db, { + id: "prn_other_user", + tenantId: OTHER_TENANT, + kind: "user", + refId: "usr_other", + status: "active", + }); + await seedAnchor(OTHER_TENANT, "run_other"); + await grantSender(`mail.send:tenant:${OTHER_TENANT}`); + + expect( + await decide([ + `usr_other@${OTHER_DOMAIN}`, + `run_other@${OTHER_DOMAIN}`, + `run_sender@${DOMAIN}`, + ]), + ).toEqual([ + { recipient: `usr_other@${OTHER_DOMAIN}`, allowed: true }, + { recipient: `run_other@${OTHER_DOMAIN}`, allowed: true }, + { recipient: `run_sender@${DOMAIN}`, allowed: false }, + ]); + }); + + test("an ambiguous user recipient is refused and its co-recipient decided", async () => { + await grantSender("mail.send:*:*"); + for (const [id, refId] of [ + ["prn_v1", "Usr_Alice"], + ["prn_v2", "USR_ALICE"], + ] as const) { + await seedPrincipal(h.db, { + id, + tenantId: TENANT, + kind: "user", + refId, + status: "active", + }); + } + + const result = await authorize({ + sender: { kind: "address", address: SENDER_ADDRESS }, + recipients: [`usr_alice@${DOMAIN}`, "x@example.com"], + }); + if (!result.ok) throw new Error(`sender refused: ${result.reason}`); + // The ambiguous recipient is a fault, not a grant decision, so a caller + // can tell it from a refusal. + expect( + result.decisions.map((d) => ({ + recipient: d.recipient, + outcome: d.outcome, + allowed: d.allowed, + })), + ).toEqual([ + { + recipient: `usr_alice@${DOMAIN}`, + outcome: "unresolved", + allowed: false, + }, + { recipient: "x@example.com", outcome: "evaluated", allowed: true }, + ]); + }); + + test("reads role grants and ignores expired grants", async () => { + await h.db.insert(role).values({ + id: "rol_senders", + tenantId: TENANT, + name: "senders", + isSystem: false, + }); + await h.db.insert(principalRole).values({ + principalId: SENDER_PRINCIPAL, + roleId: "rol_senders", + }); + await seedGrant(h.db, { + id: "grt_role_send", + tenantId: TENANT, + roleId: "rol_senders", + resource: "mail.send:domain:example.com", + action: "send", + }); + await grantSender("mail.send:domain:expired.example", "allow", { + expiresAt: new Date("2000-01-01T00:00:00.000Z"), + }); + + expect(await decide(["a@example.com", "b@expired.example"])).toEqual([ + { recipient: "a@example.com", allowed: true }, + { recipient: "b@expired.example", allowed: false }, + ]); + }); + + test("decides for an authenticated principal sender", async () => { + await seedPrincipal(h.db, { + id: "prn_owner", + tenantId: TENANT, + kind: "user", + refId: "usr_owner", + status: "active", + }); + await seedGrant(h.db, { + id: "grt_owner_send", + tenantId: TENANT, + principalId: "prn_owner", + resource: `mail.send:tenant:${TENANT}`, + action: "send", + }); + + const result = await authorize({ + sender: { + kind: "principal", + principalId: "prn_owner", + tenantId: TENANT, + }, + recipients: [SENDER_ADDRESS], + }); + expect(result.ok && result.decisions[0]?.allowed).toBe(true); + }); + + test("a domain deny reaches every spelling of an external recipient", async () => { + await grantSender("mail.send:*:*"); + await grantSender("mail.send:domain:example.com", "deny"); + const spellings = ["a@example.com", "B@Example.COM", "c@EXAMPLE.com."]; + expect(await decide([...spellings, "d@other.example"])).toEqual([ + ...spellings.map((recipient) => ({ recipient, allowed: false })), + { recipient: "d@other.example", allowed: true }, + ]); + }); + + test("a domain deny reaches a local user and a local run recipient", async () => { + await grantSender("mail.send:*:*"); + await grantSender(`mail.send:domain:${OTHER_DOMAIN}`, "deny"); + await seedPrincipal(h.db, { + id: "prn_u", + tenantId: OTHER_TENANT, + kind: "user", + refId: "usr_u", + status: "active", + }); + await seedAnchor(OTHER_TENANT, "run_o"); + expect( + await decide([`usr_u@${OTHER_DOMAIN}`, `run_o@${OTHER_DOMAIN}`]), + ).toEqual([ + { recipient: `usr_u@${OTHER_DOMAIN}`, allowed: false }, + { recipient: `run_o@${OTHER_DOMAIN}`, allowed: false }, + ]); + }); + + test("a tenant deny reaches a user and a run of that tenant", async () => { + await grantSender("mail.send:*:*"); + await grantSender(`mail.send:tenant:${OTHER_TENANT}`, "deny"); + await seedPrincipal(h.db, { + id: "prn_u", + tenantId: OTHER_TENANT, + kind: "user", + refId: "usr_u", + status: "active", + }); + await seedAnchor(OTHER_TENANT, "run_o"); + expect( + await decide([ + `usr_u@${OTHER_DOMAIN}`, + `run_o@${OTHER_DOMAIN}`, + "x@example.com", + ]), + ).toEqual([ + { recipient: `usr_u@${OTHER_DOMAIN}`, allowed: false }, + { recipient: `run_o@${OTHER_DOMAIN}`, allowed: false }, + { recipient: "x@example.com", allowed: true }, + ]); + }); + + test("a principal deny reaches a user recipient", async () => { + await grantSender("mail.send:*:*"); + await seedPrincipal(h.db, { + id: "prn_u", + tenantId: OTHER_TENANT, + kind: "user", + refId: "usr_u", + status: "active", + }); + await grantSender("mail.send:principal:prn_u", "deny"); + expect(await decide([`USR_U@${OTHER_DOMAIN}`])).toEqual([ + { recipient: `USR_U@${OTHER_DOMAIN}`, allowed: false }, + ]); + }); + + test("a recipient with no coordinate is refused under a namespace-wide allow", async () => { + await grantSender("mail.send:*:*"); + const odd = [ + "no-at-sign", + "x@", + "x@[127.0.0.1]", + "a@b@example.com", + "Name ", + "x@example.com ", + ]; + expect(await decide(odd)).toEqual( + odd.map((recipient) => ({ recipient, allowed: false })), + ); + }); + + test("an address that names a user or a child run is not a sender", async () => { + await seedPrincipal(h.db, { + id: "prn_u", + tenantId: TENANT, + kind: "user", + refId: "usr_u", + status: "active", + }); + expect( + await authorize({ + sender: { kind: "address", address: `usr_u@${DOMAIN}` }, + recipients: ["x@example.com"], + }), + ).toEqual({ ok: false, reason: "sender_unresolved" }); + await seedWorkflowRun(h.db, { + id: "run_child", + tenantId: TENANT, + anchorRunId: SENDER_RUN, + address: `run_child@${DOMAIN}`, + status: "running", + }); + expect( + await authorize({ + sender: { kind: "address", address: `run_child@${DOMAIN}` }, + recipients: ["x@example.com"], + }), + ).toEqual({ ok: false, reason: "sender_unresolved" }); + }); + + test("a sender whose run principal row is missing is unresolved", async () => { + await seedAnchor(TENANT, "run_noprn"); + expect( + await authorize({ + sender: { kind: "address", address: `run_noprn@${DOMAIN}` }, + recipients: ["x@example.com"], + }), + ).toEqual({ ok: false, reason: "sender_unresolved" }); + }); + + test("an unfired sender uses its derived run principal grants", async () => { + const derived = await deriveRunPrincipalId(TENANT, "run_unfired"); + await seedPrincipal(h.db, { + id: derived, + tenantId: TENANT, + kind: "workflow", + refId: "run_unfired", + status: "active", + }); + await seedGrant(h.db, { + id: "grt_unfired", + tenantId: TENANT, + principalId: derived, + resource: "mail.send:domain:example.com", + action: "send", + effect: "allow", + }); + await seedAnchor(TENANT, "run_unfired", { status: "deployed" }); + const result = await authorize({ + sender: { kind: "address", address: `RUN_UNFIRED@${DOMAIN}.` }, + recipients: ["x@example.com"], + }); + expect(result.ok && result.decisions[0]?.allowed).toBe(true); + }); + + test("a sender grant row in another tenant does not count", async () => { + await seedGrant(h.db, { + id: "grt_cross", + tenantId: OTHER_TENANT, + principalId: SENDER_PRINCIPAL, + resource: "mail.send:*:*", + action: "send", + effect: "allow", + }); + expect(await decide(["x@example.com"])).toEqual([ + { recipient: "x@example.com", allowed: false }, + ]); + }); + + test("a broad star grant does not allow a send", async () => { + await grantSender("*"); + await grantSender("mail.*"); + expect(await decide(["x@example.com"])).toEqual([ + { recipient: "x@example.com", allowed: false }, + ]); + }); + + test("an ask grant does not allow a send", async () => { + await seedGrant(h.db, { + id: "grt_ask", + tenantId: TENANT, + principalId: SENDER_PRINCIPAL, + resource: "mail.send:*:*", + action: "send", + effect: "ask", + }); + expect(await decide(["x@example.com"])).toEqual([ + { recipient: "x@example.com", allowed: false }, + ]); + }); + + test("an empty recipient list gives no decisions", async () => { + expect(await decide([])).toEqual([]); + }); + + test("duplicate recipients each get a decision in order", async () => { + await grantSender("mail.send:domain:example.com"); + expect( + await decide(["x@example.com", "y@other.example", "x@example.com"]), + ).toEqual([ + { recipient: "x@example.com", allowed: true }, + { recipient: "y@other.example", allowed: false }, + { recipient: "x@example.com", allowed: true }, + ]); + }); + }, +); diff --git a/tests/db/mail-send-backfill.test.ts b/tests/db/mail-send-backfill.test.ts new file mode 100644 index 000000000..55fd2536e --- /dev/null +++ b/tests/db/mail-send-backfill.test.ts @@ -0,0 +1,586 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { and, eq, like, sql } from "drizzle-orm"; + +import { matchPattern } from "@intx/authz"; +import { grant, principalRole, role } from "@intx/db/schema"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { REPO_ROOT } from "@intx/test-harness/env"; +import { + seedGrant, + seedPrincipal, + seedTenants, + seedWorkflowRun, +} from "@intx/test-harness/seed"; + +const BACKFILL_STATEMENTS = readFileSync( + path.join(REPO_ROOT, "packages/db/migrations/0124_mail_send_backfill.sql"), + "utf-8", +) + .split("--> statement-breakpoint") + .map((statement) => statement.trim()); + +const TENANT = "tnt"; +const RUN_SEND = "mail.send:*:*"; +const TENANT_SEND = `mail.send:tenant:${TENANT}`; + +describe.skipIf(!harnessDbEnvAvailable())( + "mail.send backfill migration (real DB)", + () => { + let h: TestDb; + let grantCounter = 0; + + beforeAll(async () => { + h = await createTestDb(); + }); + + afterAll(async () => { + await h.close(); + }); + + beforeEach(async () => { + await h.reset(); + await seedTenants(h.db, [{ id: TENANT }]); + }); + + async function runBackfill(): Promise { + for (const statement of BACKFILL_STATEMENTS) { + await h.db.execute(sql.raw(statement)); + } + } + + async function seedRunPrincipal( + id: string, + status: "active" | "suspended" = "active", + ) { + await seedPrincipal(h.db, { + id, + tenantId: TENANT, + kind: "workflow", + refId: id, + status, + }); + } + + async function seedUser(id: string) { + await seedPrincipal(h.db, { + id, + tenantId: TENANT, + kind: "user", + refId: `usr_${id}`, + status: "active", + }); + } + + async function grantTo( + holder: { principalId: string } | { roleId: string }, + resource: string, + action: string, + extra: { + effect?: "allow" | "deny" | "ask"; + origin?: "system" | "role" | "creator" | "invoker"; + expiresAt?: Date; + conditions?: Record; + } = {}, + ) { + grantCounter += 1; + await seedGrant(h.db, { + id: `grt_src_${String(grantCounter)}`, + tenantId: TENANT, + resource, + action, + ...holder, + ...extra, + }); + } + + async function sendRows(resource: string) { + return h.db + .select() + .from(grant) + .where(and(eq(grant.resource, resource), eq(grant.action, "send"))); + } + + describe("run principals", () => { + test("a linked anchor of any status or principal state gets one row", async () => { + await seedRunPrincipal("prn_running"); + await seedRunPrincipal("prn_done"); + await seedRunPrincipal("prn_suspended", "suspended"); + for (const [id, principalId, status] of [ + ["run_running", "prn_running", "running"], + ["run_done", "prn_done", "completed"], + ["run_suspended", "prn_suspended", "running"], + ] as const) { + await seedWorkflowRun(h.db, { + id, + tenantId: TENANT, + anchorRunId: id, + principalId, + status, + }); + } + + await runBackfill(); + + const rows = await sendRows(RUN_SEND); + expect(rows.map((row) => row.principalId).sort()).toEqual([ + "prn_done", + "prn_running", + "prn_suspended", + ]); + for (const row of rows) { + expect(row).toMatchObject({ + effect: "allow", + origin: "creator", + conditions: null, + expiresAt: null, + roleId: null, + }); + } + }); + + test("an anchor that has not fired and a child run get no row", async () => { + await seedRunPrincipal("prn_parent"); + await seedWorkflowRun(h.db, { + id: "run_fresh", + tenantId: TENANT, + anchorRunId: "run_fresh", + status: "deployed", + }); + await seedWorkflowRun(h.db, { + id: "run_parent", + tenantId: TENANT, + anchorRunId: "run_parent", + principalId: "prn_parent", + }); + await seedWorkflowRun(h.db, { + id: "run_child", + tenantId: TENANT, + anchorRunId: "run_parent", + principalId: "prn_parent", + }); + + await runBackfill(); + + expect( + (await sendRows(RUN_SEND)).map((row) => row.principalId), + ).toEqual(["prn_parent"]); + }); + + test("a run principal that holds workflow-run manage gets no tenant row", async () => { + await seedRunPrincipal("prn_deployer_run"); + await seedWorkflowRun(h.db, { + id: "run_deployer", + tenantId: TENANT, + anchorRunId: "run_deployer", + principalId: "prn_deployer_run", + }); + for (const origin of ["creator", "invoker"] as const) { + await grantTo( + { principalId: "prn_deployer_run" }, + "workflow-run:*", + "manage", + { origin }, + ); + } + + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toEqual([]); + expect(await sendRows(RUN_SEND)).toHaveLength(1); + }); + }); + + describe("users and roles", () => { + test("a holder of a manage grant that can match a run gets a tenant row", async () => { + for (const [user, resource, action] of [ + ["prn_exact", "workflow-run:run_abc", "manage"], + ["prn_wildcard", "workflow-run:*", "manage"], + ["prn_star", "*", "*"], + ["prn_prefix", "workflow*", "manage"], + ["prn_inner", "*-run:abc", "manage"], + ] as const) { + await seedUser(user); + await grantTo({ principalId: user }, resource, action); + } + + await runBackfill(); + + expect( + (await sendRows(TENANT_SEND)).map((row) => row.principalId).sort(), + ).toEqual([ + "prn_exact", + "prn_inner", + "prn_prefix", + "prn_star", + "prn_wildcard", + ]); + }); + + test("a holder of no matching manage allow gets no row", async () => { + for (const [user, resource, action, effect] of [ + ["prn_other_ns", "wallet:*", "manage", "allow"], + ["prn_no_colon", "workflow-run", "manage", "allow"], + ["prn_read", "workflow-run:*", "read", "allow"], + ["prn_deny", "workflow-run:*", "manage", "deny"], + ["prn_ask", "workflow-run:*", "manage", "ask"], + ] as const) { + await seedUser(user); + await grantTo({ principalId: user }, resource, action, { effect }); + } + await seedUser("prn_expired"); + await grantTo({ principalId: "prn_expired" }, "*", "*", { + expiresAt: new Date("2000-01-01T00:00:00.000Z"), + }); + + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toEqual([]); + }); + + test("a role gets one row and its members none of their own", async () => { + await h.db.insert(role).values({ + id: "rol_admin", + tenantId: TENANT, + name: "admin", + isSystem: true, + }); + await seedUser("prn_member"); + await h.db + .insert(principalRole) + .values({ principalId: "prn_member", roleId: "rol_admin" }); + for (const action of ["read", "create", "manage"]) { + await grantTo({ roleId: "rol_admin" }, "*", action, { + origin: "system", + }); + } + + await runBackfill(); + + const rows = await sendRows(TENANT_SEND); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + roleId: "rol_admin", + principalId: null, + origin: "system", + }); + }); + + test("each source row keeps its origin, conditions and expiry", async () => { + await seedUser("prn_two"); + const later = new Date("2999-01-01T00:00:00.000Z"); + const conditions = { + time_window: { after: "09:00", before: "17:00", timezone: "UTC" }, + }; + await grantTo({ principalId: "prn_two" }, "*", "manage", { + origin: "role", + }); + await grantTo({ principalId: "prn_two" }, "workflow-run:*", "manage", { + origin: "creator", + expiresAt: later, + conditions, + }); + + await runBackfill(); + + const rows = await sendRows(TENANT_SEND); + expect( + rows + .map((row) => ({ + origin: row.origin, + conditions: row.conditions, + expiresAt: row.expiresAt, + })) + .sort((a, b) => a.origin.localeCompare(b.origin)), + ).toEqual([ + { origin: "creator", conditions, expiresAt: later }, + { origin: "role", conditions: null, expiresAt: null }, + ]); + }); + + // The send check cannot evaluate a `tool` condition, so a copy would + // make the holder's sends fail. + test("a source row with a tool condition gives no row", async () => { + await seedUser("prn_tool"); + await grantTo({ principalId: "prn_tool" }, "workflow-run:*", "manage", { + conditions: { tool: "tool:pkg" }, + }); + await grantTo({ principalId: "prn_tool" }, "workflow-run:*", "manage", { + conditions: { + tool: "tool:pkg", + time_window: { after: "09:00", before: "17:00", timezone: "UTC" }, + }, + }); + + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toEqual([]); + }); + + // The API stores only an object or SQL NULL, but a direct write can + // leave a jsonb scalar. Such a row must not abort the migration. + test("a source row whose conditions are not an object does not abort it", async () => { + await seedUser("prn_scalar"); + await grantTo({ principalId: "prn_scalar" }, "api:unrelated", "read"); + await grantTo( + { principalId: "prn_scalar" }, + "workflow-run:*", + "manage", + ); + await grantTo( + { principalId: "prn_scalar" }, + "workflow-run:a*", + "manage", + ); + await h.db.execute( + sql`UPDATE "grant" SET "conditions" = '5'::jsonb WHERE "resource" IN ('api:unrelated', 'workflow-run:a*')`, + ); + await h.db.execute( + sql`UPDATE "grant" SET "conditions" = 'null'::jsonb WHERE "resource" = 'workflow-run:*'`, + ); + + await runBackfill(); + + // The JSON-null row holds no condition and gives a row; the scalar + // row is skipped. + expect(await sendRows(TENANT_SEND)).toHaveLength(1); + }); + }); + + describe("rows that already exist", () => { + test("a send row with empty conditions counts as the row", async () => { + await seedRunPrincipal("prn_empty_conditions"); + await seedWorkflowRun(h.db, { + id: "run_empty_conditions", + tenantId: TENANT, + anchorRunId: "run_empty_conditions", + principalId: "prn_empty_conditions", + }); + await grantTo( + { principalId: "prn_empty_conditions" }, + RUN_SEND, + "send", + { conditions: {} }, + ); + + await runBackfill(); + + expect(await sendRows(RUN_SEND)).toHaveLength(1); + }); + + test("a run principal with only an expired or conditioned send row gets the row", async () => { + await seedRunPrincipal("prn_expired_row"); + await seedRunPrincipal("prn_conditioned_row"); + for (const id of ["prn_expired_row", "prn_conditioned_row"]) { + await seedWorkflowRun(h.db, { + id: `run_${id}`, + tenantId: TENANT, + anchorRunId: `run_${id}`, + principalId: id, + }); + } + await grantTo({ principalId: "prn_expired_row" }, RUN_SEND, "send", { + expiresAt: new Date("2000-01-01T00:00:00.000Z"), + }); + await grantTo( + { principalId: "prn_conditioned_row" }, + RUN_SEND, + "send", + { + conditions: { + time_window: { after: "09:00", before: "17:00", timezone: "UTC" }, + }, + }, + ); + + await runBackfill(); + + const usable = (await sendRows(RUN_SEND)).filter( + (row) => row.conditions === null && row.expiresAt === null, + ); + expect(usable.map((row) => row.principalId).sort()).toEqual([ + "prn_conditioned_row", + "prn_expired_row", + ]); + }); + + test("a tenant whose system roles got their send rows at creation gets none", async () => { + for (const id of ["rol_owner", "rol_admin"]) { + await h.db + .insert(role) + .values({ id, tenantId: TENANT, name: id, isSystem: true }); + } + await grantTo({ roleId: "rol_owner" }, "*", "*", { origin: "system" }); + await grantTo({ roleId: "rol_admin" }, "*", "manage", { + origin: "system", + }); + for (const roleId of ["rol_owner", "rol_admin"]) { + await grantTo({ roleId }, TENANT_SEND, "send", { origin: "system" }); + } + const before = (await sendRows(TENANT_SEND)).map((row) => row.id); + + await runBackfill(); + + expect( + (await sendRows(TENANT_SEND)).map((row) => row.id).sort(), + ).toEqual(before.sort()); + }); + }); + + // Each action is a grant on `workflow-run:*`; the statement gives its holder + // a row exactly when the hub's own matcher says the action matches + // `manage`, including LIKE metacharacters and several `*`. + test("the action rule agrees with matchPattern", async () => { + const actions = [ + "*", + "manage", + "man*", + "*age", + "m*e", + "*an*", + "**", + "m_n*", + "%*", + "ma%*", + "\\*", + "m\\*", + "manag_*", + "*manage*", + "manage*", + "m*x", + "_anage", + "%", + "Manage", + "*Manage", + "m*a*n*a*g*e", + "m*ee", + "ma*age*", + "man*age*e", + ]; + for (const [index, action] of actions.entries()) { + await seedUser(`prn_action_${String(index)}`); + await grantTo( + { principalId: `prn_action_${String(index)}` }, + "workflow-run:*", + action, + ); + } + + await runBackfill(); + + const holders = new Set( + (await sendRows(TENANT_SEND)).map((row) => row.principalId), + ); + expect( + actions.filter((_action, index) => + holders.has(`prn_action_${String(index)}`), + ), + ).toEqual(actions.filter((action) => matchPattern(action, "manage"))); + }); + + test("a user whose manage grant has origin invoker gets a row", async () => { + await seedUser("prn_invoker_origin"); + await grantTo( + { principalId: "prn_invoker_origin" }, + "workflow-run:*", + "manage", + { origin: "invoker" }, + ); + + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toMatchObject([ + { principalId: "prn_invoker_origin", origin: "invoker" }, + ]); + }); + + test("an action glob that matches manage gives a row", async () => { + await seedUser("prn_glob"); + await seedUser("prn_other_glob"); + await grantTo({ principalId: "prn_glob" }, "workflow-run:*", "man*"); + await grantTo({ principalId: "prn_other_glob" }, "workflow-run:*", "re*"); + + await runBackfill(); + + expect( + (await sendRows(TENANT_SEND)).map((row) => row.principalId), + ).toEqual(["prn_glob"]); + }); + + test("source rows that agree on holder fields give one row", async () => { + await seedUser("prn_two_runs"); + await grantTo( + { principalId: "prn_two_runs" }, + "workflow-run:a", + "manage", + ); + await grantTo( + { principalId: "prn_two_runs" }, + "workflow-run:b", + "manage", + ); + + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toHaveLength(1); + }); + + test("a second run adds no row for a source with conditions and expiry", async () => { + await seedUser("prn_timed"); + await grantTo({ principalId: "prn_timed" }, "workflow-run:*", "manage", { + conditions: { + time_window: { after: "09:00", before: "17:00", timezone: "UTC" }, + }, + expiresAt: new Date("2999-01-01T00:00:00.123Z"), + }); + + await runBackfill(); + await runBackfill(); + + expect(await sendRows(TENANT_SEND)).toHaveLength(1); + }); + + test("a second run adds no rows", async () => { + await seedRunPrincipal("prn_run"); + await seedWorkflowRun(h.db, { + id: "run_once", + tenantId: TENANT, + anchorRunId: "run_once", + principalId: "prn_run", + }); + await seedUser("prn_owner"); + await grantTo({ principalId: "prn_owner" }, "*", "*", { + origin: "system", + }); + + await runBackfill(); + const first = await h.db + .select() + .from(grant) + .where(like(grant.resource, "mail.send:%")); + await runBackfill(); + const second = await h.db + .select() + .from(grant) + .where(like(grant.resource, "mail.send:%")); + + expect(first).toHaveLength(2); + expect(second.map((row) => row.id).sort()).toEqual( + first.map((row) => row.id).sort(), + ); + }); + }, +); diff --git a/tests/db/mail-send-relay.test.ts b/tests/db/mail-send-relay.test.ts new file mode 100644 index 000000000..24cef1589 --- /dev/null +++ b/tests/db/mail-send-relay.test.ts @@ -0,0 +1,216 @@ +// The relay's send check on a held mail, with the database-backed authorizer. +// +// A run's mail can wait in the hub until its sender's key is recorded. The +// hub checks the mail again when it re-drives it, so the check sees the +// grants as they are then. A run that ended while its mail waited has a +// deactivated principal; its grants still decide, so the mail still goes. A +// deny written while the mail waited still refuses it. + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { createGrantStore, deactivateRunPrincipals } from "@intx/db"; +import { tenant as tenantTable } from "@intx/db/schema"; +import { createMailSendAuthorizer } from "@intx/hub-api"; +import { + createSidecarRouter, + type SidecarAuthIdentity, +} from "@intx/hub-sessions"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { + seedGrant, + seedPrincipal, + seedWorkflowRun, + tenantSlugFromId, +} from "@intx/test-harness/seed"; +import { DEFAULT_SIDECAR_CAPACITY } from "@intx/types/sidecar"; +import { waitUntil } from "@intx/types/testing"; + +import { createMockWs } from "./sidecar-test-helpers"; + +const TENANT = "tnt_send_relay"; +const DOMAIN = "send-relay.localhost"; +const SENDER_RUN = "run_send_relay"; +const SENDER_ADDRESS = `${SENDER_RUN}@${DOMAIN}`; +const SENDER_PRINCIPAL = "prn_send_relay"; +const EXTERNAL = "x@example.com"; +const ALLOCATION = "allocation-send-relay"; + +const binding: SidecarAuthIdentity = { + kind: "allocated", + sidecarId: "sc-send-relay", + allocationId: ALLOCATION, + tenantId: TENANT, + anchorRunId: SENDER_RUN, + workflowRunAddress: SENDER_ADDRESS, + generation: 1, +}; + +describe.skipIf(!harnessDbEnvAvailable())( + "the relay's send check on a held mail (real DB)", + () => { + let h: TestDb; + + beforeAll(async () => { + h = await createTestDb(); + }); + + afterAll(async () => { + await h.close(); + }); + + beforeEach(async () => { + await h.reset(); + await h.db.insert(tenantTable).values({ + id: TENANT, + name: TENANT, + slug: tenantSlugFromId(TENANT), + domain: DOMAIN, + parentId: null, + }); + await seedPrincipal(h.db, { + id: SENDER_PRINCIPAL, + tenantId: TENANT, + kind: "workflow", + refId: SENDER_RUN, + status: "active", + }); + await seedWorkflowRun(h.db, { + id: SENDER_RUN, + tenantId: TENANT, + anchorRunId: SENDER_RUN, + address: SENDER_ADDRESS, + status: "running", + principalId: SENDER_PRINCIPAL, + }); + await grant("send:all", "mail.send:*:*", "allow"); + }); + + async function grant( + id: string, + resource: string, + effect: "allow" | "deny", + ) { + await seedGrant(h.db, { + id: `grt_${id.replace(":", "_")}`, + tenantId: TENANT, + principalId: SENDER_PRINCIPAL, + resource, + action: "send", + effect, + }); + } + + // Hold a mail from the run to itself and to an external address until + // the run's key is recorded, run `whileHeld`, then record the key so the + // hub re-drives the mail. The external recipient's undelivered report + // marks the end of the re-drive. + async function holdThenRedrive(whileHeld: () => Promise) { + const held = Promise.withResolvers(); + const router = createSidecarRouter({ + withExecutableWorkflowRun: async (_target, deliver) => deliver(), + authenticateSidecar: async ({ sidecarId }) => ({ sidecarId }), + resolveSidecarBindings: async () => [binding], + validateSidecarIdentity: async () => true, + lookups: { + authorizeMailSend: createMailSendAuthorizer({ + db: h.db, + grantStoreFor: createGrantStore, + }), + async resolveSenderKey() { + held.resolve(undefined); + return null; + }, + async materializeMailTriggeredRunGrants() { + return { outcome: "materialized", stepGrants: [] }; + }, + readWorkflowRunRefTips: async () => ({ + "refs/heads/main": "c".repeat(40), + "refs/heads/events": null, + }), + }, + }); + const undelivered = Promise.withResolvers(); + router.events.on("mail.outbound.undelivered", ({ recipients }) => { + undelivered.resolve(recipients); + }); + + const ws = createMockWs(); + router.fenceAllocation(ALLOCATION, 1); + router.handleOpen(ws); + router.handleMessage( + ws, + JSON.stringify({ + type: "hello", + sidecarId: binding.sidecarId, + token: "tok", + capacity: DEFAULT_SIDECAR_CAPACITY, + incarnations: [ + { address: SENDER_ADDRESS, generation: 1, state: "live" }, + ], + }), + ); + await waitUntil(() => + router.getRoutableAddresses().includes(SENDER_ADDRESS), + ); + + const attempt = { + allocationId: ALLOCATION, + generation: 1, + leaseId: "send-relay-hold", + }; + router.noteSenderDeployStarted(SENDER_ADDRESS, attempt); + router.handleMessage( + ws, + JSON.stringify({ + type: "mail.outbound", + senderAddress: SENDER_ADDRESS, + generation: 1, + rawMessage: btoa( + `From: ${SENDER_ADDRESS}\r\nTo: ${EXTERNAL}\r\nMessage-ID: \r\n\r\nbody`, + ), + recipients: [SENDER_ADDRESS, EXTERNAL], + }), + ); + await held.promise; + await whileHeld(); + router.noteSenderDeploySettled(attempt, { recorded: "ab".repeat(32) }); + + const reported = await undelivered.promise; + const routed = ws.sent.filter((raw) => raw.includes('"mail.inbound"')); + return { reported, routed }; + } + + test("delivers a held mail from a run that ended while it waited", async () => { + const { reported, routed } = await holdThenRedrive(async () => { + await deactivateRunPrincipals(h.db, TENANT, [SENDER_RUN], new Date()); + }); + + expect(reported).toEqual([EXTERNAL]); + expect(routed).toHaveLength(1); + }); + + test("refuses a held mail to a recipient denied while it waited", async () => { + const { reported, routed } = await holdThenRedrive(async () => { + await grant( + "send:self", + `mail.send:principal:${SENDER_PRINCIPAL}`, + "deny", + ); + }); + + expect(reported).toEqual([EXTERNAL]); + expect(routed).toEqual([]); + }); + }, +); diff --git a/tests/db/mail-triggered-run-grants.test.ts b/tests/db/mail-triggered-run-grants.test.ts index 101013e72..42e89f86f 100644 --- a/tests/db/mail-triggered-run-grants.test.ts +++ b/tests/db/mail-triggered-run-grants.test.ts @@ -318,6 +318,17 @@ describe.skipIf(!harnessDbEnvAvailable())( const resources = grants.map((g) => `${g.resource}/${g.action}`).sort(); expect(resources).toContain("tool:read_file/invoke"); expect(resources).toContain("secret:vault/use"); + // Every run principal gets one send grant for every recipient, whatever + // tools its snapshot holds. + const sendRows = grants.filter((g) => g.resource === "mail.send:*:*"); + expect(sendRows).toHaveLength(1); + expect(sendRows[0]).toMatchObject({ + action: "send", + effect: "allow", + origin: "creator", + conditions: null, + expiresAt: null, + }); }); test("a creator requirement row takes the creator grant's expiry", async () => { @@ -604,6 +615,9 @@ describe.skipIf(!harnessDbEnvAvailable())( const grants = await h.db.select().from(grant); expect(grants.length).toBe(firstGrantCount); + expect(grants.filter((g) => g.resource === "mail.send:*:*")).toHaveLength( + 1, + ); }); // A run that deploys a workflow through its run token is the deployment's @@ -1786,13 +1800,7 @@ describe.skipIf(!harnessDbEnvAvailable())( } function mint() { - return createCorrespondentGrantMinter({ - db: h.db, - principalKeyStore: createPrincipalKeyStore({ - db: h.db, - cipher: createTestCredentialCipher(), - }), - }); + return createCorrespondentGrantMinter({ db: h.db }); } async function correspondentRows() { @@ -2009,6 +2017,48 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(rows[0]?.principalId).toBe(SENDER_PRINCIPAL); }); + test("mints nothing for a keyed, linked recipient run that has ended", async () => { + await seedFrozenSnapshot(correspondentSnapshot()); + await makeSenderRun("running"); + await seedRecipientRun(); + await h.db + .update(workflowRun) + .set({ status: "completed" }) + .where(eq(workflowRun.id, RECIPIENT_RUN_ID)); + + await mint()({ + senderAddress: WORKFLOW_ADDRESS, + recipientAddresses: [RECIPIENT_ADDRESS], + }); + + expect(await correspondentRows()).toHaveLength(0); + }); + + test("mints for a user recipient that holds no signing key", async () => { + await seedFrozenSnapshot(correspondentSnapshot()); + await makeSenderRun("running"); + await seedPrincipal(h.db, { + id: "prn_keyless", + tenantId: TENANT, + kind: "user", + refId: "usr_keyless", + status: "active", + }); + + await mint()({ + senderAddress: WORKFLOW_ADDRESS, + recipientAddresses: ["usr_keyless@tnt.example.test"], + }); + + const rows = await h.db + .select() + .from(grant) + .where( + eq(grant.resource, mailAcceptResource("principal", "prn_keyless")), + ); + expect(rows).toHaveLength(1); + }); + test("mints nothing when the recipient does not resolve to a principal", async () => { await seedFrozenSnapshot(correspondentSnapshot()); await makeSenderRun("running"); @@ -2075,11 +2125,7 @@ describe.skipIf(!harnessDbEnvAvailable())( test("a recipient that fails to resolve does not cost a co-recipient its grant", async () => { await seedFrozenSnapshot(correspondentSnapshot()); await makeSenderRun("running"); - const keys = createPrincipalKeyStore({ - db: h.db, - cipher: createTestCredentialCipher(), - }); - // A resolvable user recipient with a signing key. + // A resolvable user recipient. await seedPrincipal(h.db, { id: "prn_bob", tenantId: TENANT, @@ -2087,7 +2133,6 @@ describe.skipIf(!harnessDbEnvAvailable())( refId: "usr_bob", status: "active", }); - await keys.generate("prn_bob"); // An ambiguous user recipient: two case variants of one user id and // no canonical row, so its resolution throws. for (const [id, refId] of [ @@ -2101,13 +2146,9 @@ describe.skipIf(!harnessDbEnvAvailable())( refId, status: "active", }); - await keys.generate(id); } - await createCorrespondentGrantMinter({ - db: h.db, - principalKeyStore: keys, - })({ + await createCorrespondentGrantMinter({ db: h.db })({ senderAddress: WORKFLOW_ADDRESS, recipientAddresses: [ "usr_alice@tnt.example.test", @@ -2128,10 +2169,6 @@ describe.skipIf(!harnessDbEnvAvailable())( test("a SQL fault on one recipient does not cost a co-recipient its grant", async () => { await seedFrozenSnapshot(correspondentSnapshot()); await makeSenderRun("running"); - const keys = createPrincipalKeyStore({ - db: h.db, - cipher: createTestCredentialCipher(), - }); for (const [id, refId] of [ ["prn_carol", "usr_carol"], ["prn_bob", "usr_bob"], @@ -2143,31 +2180,35 @@ describe.skipIf(!harnessDbEnvAvailable())( refId, status: "active", }); - await keys.generate(id); } // A statement error inside the minter's transaction, unlike a throw // in application code, aborts that transaction in Postgres. Only a - // savepoint per recipient keeps the co-recipient's insert alive. - const faultingKeys: typeof keys = { - ...keys, - async getPublicKey(principalId, tx) { - if (principalId === "prn_carol") { - await (tx ?? h.db).execute(sql`select 1 / 0`); - } - return keys.getPublicKey(principalId, tx); - }, - }; + // savepoint per recipient keeps the co-recipient's insert alive. The + // trigger fails the insert of carol's grant row. + await h.db.execute(sql` + create function fail_carol_grant() returns trigger as $$ + begin + if new.resource = 'mail.accept:principal:prn_carol' then + perform 1 / 0; + end if; + return new; + end $$ language plpgsql`); + await h.db.execute(sql` + create trigger fail_carol_grant before insert on "grant" + for each row execute function fail_carol_grant()`); - await createCorrespondentGrantMinter({ - db: h.db, - principalKeyStore: faultingKeys, - })({ - senderAddress: WORKFLOW_ADDRESS, - recipientAddresses: [ - "usr_carol@tnt.example.test", - "usr_bob@tnt.example.test", - ], - }); + try { + await createCorrespondentGrantMinter({ db: h.db })({ + senderAddress: WORKFLOW_ADDRESS, + recipientAddresses: [ + "usr_carol@tnt.example.test", + "usr_bob@tnt.example.test", + ], + }); + } finally { + await h.db.execute(sql`drop trigger fail_carol_grant on "grant"`); + await h.db.execute(sql`drop function fail_carol_grant()`); + } const rows = await h.db .select({ resource: grant.resource }) @@ -2321,6 +2362,24 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(reply.outcome).toBe("materialized"); }); + test("mints for any spelling of the recipient address", async () => { + await seedFrozenSnapshot(correspondentSnapshot()); + await makeSenderRun("running"); + await seedFreshRecipient(); + + await mint()({ + senderAddress: WORKFLOW_ADDRESS, + recipientAddresses: [`${FRESH_ADDRESS.toUpperCase()}.`], + }); + + expect(await senderAcceptRows()).toEqual([ + mailAcceptResource( + "principal", + await deriveRunPrincipalId(TENANT, FRESH_RUN_ID), + ), + ]); + }); + test("refuses the reply when the sender did not author correspondent", async () => { await seedFrozenSnapshot(snapshot("secret:vault")); await makeSenderRun("running"); diff --git a/tests/db/run-delegated-grants.test.ts b/tests/db/run-delegated-grants.test.ts index ed2e814bc..497b6fcba 100644 --- a/tests/db/run-delegated-grants.test.ts +++ b/tests/db/run-delegated-grants.test.ts @@ -318,6 +318,29 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(await runGrants(run)).toHaveLength(1); }); + // The requirement has the same key as the run's staged send row, and the + // creator holds no send authority, so only the namespace skip keeps the + // row from being adopted and deleted. + test("a requirement in the mail.send namespace leaves the run's send row alone", async () => { + const run = await seedDeployment({ + anchorRunId: "run_m", + requirements: [ + { resource: "mail.send:*:*", action: "send", source: "creator" }, + ], + }); + await seedGrant(h.db, { + id: "grt_run_send", + tenantId: TENANT, + principalId: run, + resource: "mail.send:*:*", + action: "send", + origin: "creator", + }); + + expect(await rederive([CREATOR])).toEqual([]); + expect((await runGrants(run)).map((r) => r.id)).toEqual(["grt_run_send"]); + }); + const SELF_READ: GrantRequirement = { resource: "workflow-run:self", action: "read", diff --git a/tests/hub-agent/lib/deploy-flow-env.ts b/tests/hub-agent/lib/deploy-flow-env.ts index 56a8b57a7..6157f04ca 100644 --- a/tests/hub-agent/lib/deploy-flow-env.ts +++ b/tests/hub-agent/lib/deploy-flow-env.ts @@ -40,6 +40,7 @@ import { Hono } from "hono"; import { upgradeWebSocket, websocket } from "hono/bun"; import type { Subprocess } from "bun"; +import { permitAllMailSend } from "@intx/authz/testing"; import { AgentDeployAckFrame } from "@intx/types/sidecar"; import { assembleSignedContent, @@ -791,6 +792,12 @@ export type HubEnv = { recipients: string[]; raw: Uint8Array; }[]; + /** + * Relayed mail the hub routed to no local address, in arrival order. It + * holds only the recipients the relay's send check allowed. The hub checks + * the sidecar's audit copy in `outboundMail` apart from this relay. + */ + undeliveredMail: { rawMessage: string; recipients: string[] }[]; hubDataDir: string; /** * Every server-side `WsHandle` currently open against this hub. Added on @@ -837,6 +844,7 @@ export async function startHub( materializeMailTriggeredRunGrants?: SidecarLookups["materializeMailTriggeredRunGrants"]; loadRunCredentials?: SidecarLookups["loadRunCredentials"]; loadRunGrants?: SidecarLookups["loadRunGrants"]; + authorizeMailSend?: SidecarLookups["authorizeMailSend"]; senderKeyResolution?: { db: DBExecutor; principalKeyStore: PrincipalKeyStore; @@ -1017,6 +1025,11 @@ export async function startHub( ...(opts.loadRunGrants !== undefined ? { loadRunGrants: opts.loadRunGrants } : {}), + // The relay refuses every recipient without a send authorizer. Most of + // these tests run no database to hold send grants and are not about the + // send check, so they allow every recipient; a test about the check + // supplies its own authorizer. + authorizeMailSend: opts.authorizeMailSend ?? permitAllMailSend, // Resolve a signed mail sender's durable public key so the materializer // path co-delivers it on the recipient run's grants barrier, exactly as // production wires it (apps/hub/src/server.ts). Without this, the @@ -1064,6 +1077,13 @@ export async function startHub( router.events.on("agent.event", ({ agentAddress, sessionId, event }) => { agentEvents.push({ addr: agentAddress, sid: sessionId, event }); }); + const undeliveredMail: HubEnv["undeliveredMail"] = []; + router.events.on( + "mail.outbound.undelivered", + ({ rawMessage, recipients }) => { + undeliveredMail.push({ rawMessage, recipients }); + }, + ); // Record each deployment's key as its sidecar's deploy acknowledgement // reports it. function recordDeployAck(data: string): void { @@ -1171,6 +1191,7 @@ export async function startHub( deployAcks, workflowRunPackReceiveFailures, outboundMail, + undeliveredMail, hubDataDir, liveHandles, workflowRunPackReceipts, @@ -1578,6 +1599,11 @@ export type StartDeployFlowEnvOpts = { * committed. */ loadRunGrants?: SidecarLookups["loadRunGrants"]; + /** + * The relay's send check, wired into the mock hub's sidecar router as the + * `authorizeMailSend` lookup. Unset, the hub allows every recipient. + */ + authorizeMailSend?: SidecarLookups["authorizeMailSend"]; /** * A db + principal key store the mock hub uses to resolve a signed mail * sender's durable public key, wired into the sidecar router as the @@ -1627,6 +1653,9 @@ export async function startDeployFlowEnv( ...(opts.loadRunGrants !== undefined ? { loadRunGrants: opts.loadRunGrants } : {}), + ...(opts.authorizeMailSend !== undefined + ? { authorizeMailSend: opts.authorizeMailSend } + : {}), ...(opts.senderKeyResolution !== undefined ? { senderKeyResolution: opts.senderKeyResolution } : {}), diff --git a/tests/hub-api/grant-routes.test.ts b/tests/hub-api/grant-routes.test.ts index 02011ea3f..288063f72 100644 --- a/tests/hub-api/grant-routes.test.ts +++ b/tests/hub-api/grant-routes.test.ts @@ -33,6 +33,10 @@ import { seedPrincipal, seedWorkflowRun } from "@intx/test-harness/seed"; // Conditions: create and update check `conditions` against the hub condition // registry (unit-tested in packages/authz), so a grant that authorization // could not evaluate is a 400 naming the problem, and is never stored. +// +// Mail send resources: create refuses a `mail.send` resource that can match +// no recipient coordinate (unit-tested in packages/authz), so a deny row the +// send check would never apply is a 400, and is never stored. function mockGetSession(userId: string): GetSession { const now = new Date("2026-01-01"); @@ -255,6 +259,145 @@ describe.skipIf(!harnessDbEnvAvailable())( action: "read", }); }); + + describe("mail.send resources", () => { + async function postSend( + slug: string, + resource: string, + effect: "allow" | "deny" = "deny", + action = "send", + ) { + const app = makeApp(); + const { tenantId, roleId } = await bootstrapTenant(app, slug); + const before = (await h.db.query.grant.findMany()).length; + const res = await postGrant(app, tenantId, { + roleId, + resource, + action, + effect, + origin: "creator", + }); + const stored = (await h.db.query.grant.findMany()).length - before; + return { res, stored }; + } + + test("POST rejects a domain deny that could never match and stores nothing", async () => { + const { res, stored } = await postSend( + "send-upper", + "mail.send:domain:Evil.COM", + ); + + expect(res.status).toBe(400); + const { error } = ErrorBody.assert(await res.json()); + expect(error.code).toBe("bad_request"); + expect(error.message).toContain("mail.send:domain:Evil.COM"); + expect(stored).toBe(0); + }); + + test("POST rejects an allow of an unknown coord type", async () => { + const { res, stored } = await postSend( + "send-type", + "mail.send:domian:evil.com", + "allow", + ); + expect(res.status).toBe(400); + const { error } = ErrorBody.assert(await res.json()); + expect(error.code).toBe("bad_request"); + expect(error.message).toContain("mail.send:domian:evil.com"); + expect(stored).toBe(0); + }); + + test("POST rejects a deny whose action cannot match send", async () => { + const { res, stored } = await postSend( + "send-action", + "mail.send:domain:evil.com", + "deny", + "read", + ); + expect(res.status).toBe(400); + const { error } = ErrorBody.assert(await res.json()); + expect(error.code).toBe("bad_request"); + expect(error.message).toContain('must match "send"'); + expect(stored).toBe(0); + }); + + test("POST stores a send grant whose action is a pattern that matches send", async () => { + const { res, stored } = await postSend( + "send-action-glob", + "mail.send:domain:evil.com", + "deny", + "*", + ); + expect(res.status).toBe(201); + expect(stored).toBe(1); + }); + + // The send check has no consumer, so a `tool` condition never holds + // there and such a deny would refuse nothing. + test("POST and PATCH refuse a tool condition on a send deny", async () => { + const app = makeApp(); + const { tenantId, roleId } = await bootstrapTenant(app, "send-tool"); + const body = { + roleId, + resource: "mail.send:domain:evil.com", + action: "send", + effect: "deny", + origin: "creator", + }; + + const conditioned = await postGrant(app, tenantId, { + ...body, + conditions: { tool: "tool:pkg" }, + }); + expect(conditioned.status).toBe(400); + expect( + ErrorBody.assert(await conditioned.json()).error.message, + ).toContain("a mail send cannot evaluate this grant"); + + const created = await postGrant(app, tenantId, body); + expect(created.status).toBe(201); + const { id } = type({ id: "string" }).assert(await created.json()); + const patched = await patchGrant(app, tenantId, id, { + conditions: { tool: "tool:pkg" }, + }); + expect(patched.status).toBe(400); + expect(await storedConditions(id)).toBeNull(); + }); + + test.each(["mail.send*:domain:evil.com", "mail.*:domain:evil.com"])( + "POST rejects the near miss %p, which never decides a send", + async (resource) => { + const { res, stored } = await postSend( + `send-near-${resource.length}`, + resource, + ); + expect(res.status).toBe(400); + expect(stored).toBe(0); + }, + ); + + test.each(["*", "mail.*"])( + "POST stores the broad deny %p", + async (resource) => { + const { res, stored } = await postSend( + `send-broad-${resource.length}`, + resource, + ); + expect(res.status).toBe(201); + expect(stored).toBe(1); + }, + ); + + test("POST stores a lower-case domain deny", async () => { + const { res, stored } = await postSend( + "send-ok", + "mail.send:domain:evil.com", + ); + expect(res.status).toBe(201); + expect(stored).toBe(1); + }); + }); + describe("conditions", () => { async function setup(slug: string) { const app = makeApp(); diff --git a/tests/hub-api/lib/membership-fixtures.ts b/tests/hub-api/lib/membership-fixtures.ts index aaa92bcec..6ab6cc91f 100644 --- a/tests/hub-api/lib/membership-fixtures.ts +++ b/tests/hub-api/lib/membership-fixtures.ts @@ -82,3 +82,30 @@ export async function addMembership( await sql.end(); } } + +export type RoleGrantRow = { + roleName: string; + resource: string; + action: string; + effect: string; + origin: string; +}; + +// Lists the grants every role of a tenant holds, for a test that checks the +// defaults tenant creation gives its system roles. +export async function listRoleGrants( + schema: string, + tenantId: string, +): Promise { + const sql = connect(schema); + try { + const rows = await sql` + select r.name as "roleName", g.resource, g.action, g.effect, g.origin + from "grant" g join role r on r.id = g.role_id + where r.tenant_id = ${tenantId} + order by r.name, g.resource, g.action`; + return [...rows]; + } finally { + await sql.end(); + } +} diff --git a/tests/hub-api/tenant-create-system-roles.test.ts b/tests/hub-api/tenant-create-system-roles.test.ts new file mode 100644 index 000000000..14663f60b --- /dev/null +++ b/tests/hub-api/tenant-create-system-roles.test.ts @@ -0,0 +1,76 @@ +// Tenant creation gives the owner and admin system roles a send grant for +// every recipient in the new tenant. The owner's `*` grant does not reach the +// `mail.send` namespace, so it needs its own row; the member role gets none. +// Driven against a real spawned hub through the production HTTP route. + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + harnessHubEnvAvailable, + startHub, + type HubHandle, +} from "./lib/git-harness"; +import { + apiCall, + createTenant, + signUpUser, + type SignedUpUser, +} from "./lib/git-asset-fixtures"; +import { listRoleGrants } from "./lib/membership-fixtures"; + +describe.skipIf(!harnessHubEnvAvailable())( + "tenant creation system role send grants", + () => { + let hub: HubHandle; + let owner: SignedUpUser; + + beforeAll(async () => { + hub = await startHub(); + owner = await signUpUser(hub.url, { emailPrefix: "owner" }); + }); + + afterAll(async () => { + await hub.stop(); + }); + + async function sendGrantsOf(tenantId: string) { + const rows = await listRoleGrants(hub.schema, tenantId); + return rows.filter((row) => row.resource.startsWith("mail.send:")); + } + + function expectedSendGrants(tenantId: string) { + return ["admin", "owner"].map((roleName) => ({ + roleName, + resource: `mail.send:tenant:${tenantId}`, + action: "send", + effect: "allow", + origin: "system", + })); + } + + test("owner and admin get one tenant send grant and member gets none", async () => { + const tenant = await createTenant(hub.url, owner); + + expect(await sendGrantsOf(tenant.tenantId)).toEqual( + expectedSendGrants(tenant.tenantId), + ); + }); + + test("a child tenant's send grants name the child, not the parent", async () => { + const parent = await createTenant(hub.url, owner); + const slug = `child${String(Date.now())}`; + const created = await apiCall( + hub.url, + "POST", + "/api/tenants", + { name: `Child ${slug}`, slug, parentId: parent.tenantId }, + owner.cookies, + ); + expect(created.status).toBe(201); + const childId = type({ id: "string" }).assert(created.data).id; + + expect(await sendGrantsOf(childId)).toEqual(expectedSendGrants(childId)); + }); + }, +); diff --git a/tests/hub-api/trigger-delegator-race.test.ts b/tests/hub-api/trigger-delegator-race.test.ts index a2fbebaad..2b571a051 100644 --- a/tests/hub-api/trigger-delegator-race.test.ts +++ b/tests/hub-api/trigger-delegator-race.test.ts @@ -162,7 +162,10 @@ describe.skipIf(!harnessDbEnvAvailable())( body: JSON.stringify({ name: "Acme", slug: "acme" }), }); expect(res.status).toBe(201); - const { id: tenantId } = type({ id: "string" }).assert(await res.json()); + const { id: tenantId, domain } = type({ + id: "string", + domain: "string", + }).assert(await res.json()); for (const id of [CREATOR, DEPLOYER]) { await seedPrincipal(h.db, { @@ -190,7 +193,8 @@ describe.skipIf(!harnessDbEnvAvailable())( tenantId, anchorRunId: ANCHOR, definitionId: "wfd_first", - address: `${ANCHOR}@acme.example`, + // A deployment's address is on its tenant's domain, as deploy sets it. + address: `${ANCHOR}@${domain}`, status: "deployed", }); const runPrincipalId = await deriveRunPrincipalId(tenantId, ANCHOR); @@ -376,5 +380,33 @@ describe.skipIf(!harnessDbEnvAvailable())( .where(eq(workflowRun.id, ANCHOR)); expect(anchor?.principalId).toBeNull(); }); + + // The owner role's tenant send grant lets the owner mail the run (every + // other test here). Without it the trigger refuses before it links the + // run principal or commits a grant. + test("an owner without a send grant is refused before the first trigger links the run", async () => { + const { app, tenantId, runPrincipalId } = await setup([]); + await h.db + .delete(grant) + .where(and(eq(grant.tenantId, tenantId), eq(grant.action, "send"))); + + const res = await trigger(app, tenantId); + + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "mail_send_denied" }, + }); + const [anchor] = await h.db + .select({ principalId: workflowRun.principalId }) + .from(workflowRun) + .where(eq(workflowRun.id, ANCHOR)); + expect(anchor?.principalId).toBeNull(); + expect( + await h.db + .select() + .from(grant) + .where(eq(grant.principalId, runPrincipalId)), + ).toEqual([]); + }); }, ); diff --git a/tests/workflow-deploy/approval-tracer-roundtrip.test.ts b/tests/workflow-deploy/approval-tracer-roundtrip.test.ts index aead25e5b..062d6c124 100644 --- a/tests/workflow-deploy/approval-tracer-roundtrip.test.ts +++ b/tests/workflow-deploy/approval-tracer-roundtrip.test.ts @@ -487,7 +487,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/asset-source.e2e.test.ts b/tests/workflow-deploy/asset-source.e2e.test.ts index a26fb32cb..779cbcf62 100644 --- a/tests/workflow-deploy/asset-source.e2e.test.ts +++ b/tests/workflow-deploy/asset-source.e2e.test.ts @@ -297,7 +297,6 @@ describe.skipIf(!harnessDbEnvAvailable())("asset-sourced workflow e2e", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // 1) Install + probe + gate + freeze against the source asset. diff --git a/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts b/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts index 5f7538797..2ae1f70f3 100644 --- a/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts +++ b/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts @@ -323,7 +323,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${mailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${TOOL_NAME}`, ]), router: env.hub.probeRouter, diff --git a/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts index 2f1f3effd..1799ec6aa 100644 --- a/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts @@ -201,7 +201,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = childWorkflowEntry({ diff --git a/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts index 52fe01f07..7cf83dfd7 100644 --- a/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts @@ -171,7 +171,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The child's loop body declares the inline tool, so the deploy walk // folds its grant up into the parent's spawn step; the operator must // approve it. diff --git a/tests/workflow-deploy/child-workflow-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-roundtrip.test.ts index 7ee23e0aa..53d35f315 100644 --- a/tests/workflow-deploy/child-workflow-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-roundtrip.test.ts @@ -372,7 +372,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const { workflowRunRepoId: parentWorkflowRunRepoId } = await deployParent( @@ -580,7 +579,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The child step declares the inline tool, so the deploy walk folds its // `tool:` grant into the parent step; the operator must approve it. `tool:${MAIL_TOOL_NAME}`, @@ -715,7 +713,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const { workflowRunRepoId: parentWorkflowRunRepoId } = await deployParent( @@ -819,7 +816,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const handle = await deployWorkflowSourceForTest(env, { @@ -960,7 +956,6 @@ describe.skipIf(!harnessDbEnvAvailable())( `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, `mail.address:${grandchildMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const { workflowRunRepoId: parentWorkflowRunRepoId } = await deployParent( @@ -1192,7 +1187,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${parentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ...siblingMailAddresses.map((a) => `mail.address:${a}`), ]); diff --git a/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts index d43132d2c..4a4ad4721 100644 --- a/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts @@ -143,7 +143,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${parentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The child step declares the inline tool, so the deploy walk folds its // grant into the parent step; the operator must approve it. `tool:${MAIL_TOOL_NAME}`, diff --git a/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts b/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts index cdc9a4880..c06f1b43e 100644 --- a/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts +++ b/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts @@ -172,7 +172,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const handle = await deployWorkflowSourceForTest(env, { diff --git a/tests/workflow-deploy/childworkflow-restart-resumes.test.ts b/tests/workflow-deploy/childworkflow-restart-resumes.test.ts index 907a9c27b..98b122a86 100644 --- a/tests/workflow-deploy/childworkflow-restart-resumes.test.ts +++ b/tests/workflow-deploy/childworkflow-restart-resumes.test.ts @@ -154,7 +154,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const handle = await deployWorkflowSourceForTest(env, { diff --git a/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts b/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts index 4a5d17f53..b025c5816 100644 --- a/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts +++ b/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts @@ -287,6 +287,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: OPERATOR_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-operator-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: OPERATOR_PRINCIPAL_ID, + }); await seedGrant(h.db, { id: "grant-operator-resolve", tenantId: TENANT_ID, @@ -315,7 +326,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, `mail.address:${childMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts b/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts index 923bd10b2..759d4a8ff 100644 --- a/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts +++ b/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts @@ -253,7 +253,6 @@ async function deployGateWorkflow(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/crash-loop-latch.test.ts b/tests/workflow-deploy/crash-loop-latch.test.ts index 9b4a47e9d..e35b6a1ea 100644 --- a/tests/workflow-deploy/crash-loop-latch.test.ts +++ b/tests/workflow-deploy/crash-loop-latch.test.ts @@ -216,7 +216,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = twoStepEntry({ diff --git a/tests/workflow-deploy/crash-respawn-fifo.test.ts b/tests/workflow-deploy/crash-respawn-fifo.test.ts index f2bb6966f..6fec69cd3 100644 --- a/tests/workflow-deploy/crash-respawn-fifo.test.ts +++ b/tests/workflow-deploy/crash-respawn-fifo.test.ts @@ -151,7 +151,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = signalGateEntry({ diff --git a/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts b/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts index e12aa1cf6..49f47032a 100644 --- a/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts +++ b/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts @@ -174,7 +174,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = signalGateEntry({ diff --git a/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts b/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts index c4a93dfc6..04ad9e3c1 100644 --- a/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts +++ b/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts @@ -138,7 +138,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/drain-roundtrip.test.ts b/tests/workflow-deploy/drain-roundtrip.test.ts index 51b46fda8..e1ab60c50 100644 --- a/tests/workflow-deploy/drain-roundtrip.test.ts +++ b/tests/workflow-deploy/drain-roundtrip.test.ts @@ -171,7 +171,6 @@ describe.skipIf(!harnessDbEnvAvailable())("drain round-trip", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // The gate is `drainBehavior: "cancel"` so a mid-flight drain @@ -343,7 +342,6 @@ describe.skipIf(!harnessDbEnvAvailable())("drain round-trip", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = signalGateEntry({ address: deploymentMailAddress, @@ -493,7 +491,6 @@ async function deployParkedOnSignal(args: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${address}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]), config: { sessionId: SESSION_ID, diff --git a/tests/workflow-deploy/fifo-mail-load.test.ts b/tests/workflow-deploy/fifo-mail-load.test.ts index 1395809f8..d5580fdd8 100644 --- a/tests/workflow-deploy/fifo-mail-load.test.ts +++ b/tests/workflow-deploy/fifo-mail-load.test.ts @@ -199,7 +199,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/fifo-mail.test.ts b/tests/workflow-deploy/fifo-mail.test.ts index 5efcfc924..9ef6fd797 100644 --- a/tests/workflow-deploy/fifo-mail.test.ts +++ b/tests/workflow-deploy/fifo-mail.test.ts @@ -164,7 +164,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = twoStepEntry({ diff --git a/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts b/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts index 526e47b9a..26d8e2db1 100644 --- a/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts +++ b/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts @@ -196,7 +196,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); @@ -328,7 +327,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // A tool-less single-step agent: the negative control is about the diff --git a/tests/workflow-deploy/hub-access-env.ts b/tests/workflow-deploy/hub-access-env.ts index 923f8d660..d07d90cf7 100644 --- a/tests/workflow-deploy/hub-access-env.ts +++ b/tests/workflow-deploy/hub-access-env.ts @@ -310,6 +310,9 @@ export async function startHubAccessEnv(args: { // for its own, so a run that reads another run would show the // requirement copied the creator's grant instead of binding `self`. await grantCaller("workflow-run:*", "read"); + // The mail that starts a run needs a send grant, as the caller of a + // tenant made through the route holds by its role. + await grantCaller(`mail.send:tenant:${tenantId}`, "send"); }, async deploy(deployArgs) { diff --git a/tests/workflow-deploy/hub-link-reconnect.test.ts b/tests/workflow-deploy/hub-link-reconnect.test.ts index 56ca59e5b..9bd373f0e 100644 --- a/tests/workflow-deploy/hub-link-reconnect.test.ts +++ b/tests/workflow-deploy/hub-link-reconnect.test.ts @@ -156,7 +156,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts b/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts index 5c67955e3..f7bea27e5 100644 --- a/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts +++ b/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts @@ -302,6 +302,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: CALLER_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-caller-manage-d1-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); // D1: no authored policy. Deploy D1 alone up front and run its // push-producing scenarios (a)/(b) before D2 exists; D2 is deployed diff --git a/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts b/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts index 2523115f0..11d1e6307 100644 --- a/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts +++ b/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts @@ -184,7 +184,6 @@ async function deploySingleStepWorkflow( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts b/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts index a50a572f2..3809fc8a6 100644 --- a/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts @@ -152,7 +152,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = loopAwaitSignalEntry({ diff --git a/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts b/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts index ecd7d5f99..f413cf4eb 100644 --- a/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts +++ b/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts @@ -160,7 +160,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = loopAwaitSignalEntry({ diff --git a/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts b/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts index 93083450d..11d86ded6 100644 --- a/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts +++ b/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts @@ -150,7 +150,6 @@ async function runGrandchildDrainTest(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = loopChildWorkflowInFlightEntry({ diff --git a/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts b/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts index 8f950321a..2f3dce860 100644 --- a/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts @@ -195,7 +195,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = loopChildWorkflowEntry({ diff --git a/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts index 49b926397..eef16405d 100644 --- a/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts @@ -175,7 +175,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The map's per-item agent declares the inline tool, so the deploy walk // folds its grant into the top level through the loop body; the // operator must approve it. diff --git a/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts index 7b2e8f1ee..215a8ed53 100644 --- a/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts @@ -198,7 +198,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The innermost body step declares the inline tool, so the deploy walk // folds its grant up through both loop bodies to the top level; the // operator must approve it. diff --git a/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts index c917c2ba1..62b464d33 100644 --- a/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts @@ -177,7 +177,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, // The loop body's step declares the inline tool, so the deploy walk // folds its grant into the top level; the operator must approve it. `tool:${MAIL_TOOL_NAME}`, diff --git a/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts b/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts index a04811e16..ad283cd43 100644 --- a/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts +++ b/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts @@ -158,7 +158,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = loopWorkThenSignalEntry({ diff --git a/tests/workflow-deploy/mail-edge-cases.test.ts b/tests/workflow-deploy/mail-edge-cases.test.ts index fe6bdc56d..3099ed362 100644 --- a/tests/workflow-deploy/mail-edge-cases.test.ts +++ b/tests/workflow-deploy/mail-edge-cases.test.ts @@ -546,7 +546,6 @@ async function deployEdgeWorkflow( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts b/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts index be4427df8..f88e28aba 100644 --- a/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts @@ -334,6 +334,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: CALLER_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: `grant-caller-manage-${opts.anchorRunId}-send`, + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); // The creator's backing grant, present only in the positive case. if (opts.creatorHoldsGrant) { await seedGrant(h.db, { diff --git a/tests/workflow-deploy/mail-federation-run-completes.test.ts b/tests/workflow-deploy/mail-federation-run-completes.test.ts index ca9f6b1ea..59d98dc89 100644 --- a/tests/workflow-deploy/mail-federation-run-completes.test.ts +++ b/tests/workflow-deploy/mail-federation-run-completes.test.ts @@ -60,10 +60,15 @@ import { } from "@intx/db"; import { createTestCredentialCipher } from "@intx/test-harness/crypto"; import { + grant as grantTable, tenant as tenantTable, workflowRun as workflowRunTable, } from "@intx/db/schema"; -import { createMailTriggeredRunGrantsMaterializer } from "@intx/hub-api"; +import { + createMailSendAuthorizer, + createMailTriggeredRunGrantsMaterializer, + deriveRunMailSendGrantRow, +} from "@intx/hub-api"; import { deriveRunPrincipalId } from "@intx/hub-common"; import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import type { WireGrantRule } from "@intx/types/grant-wire"; @@ -197,6 +202,12 @@ describe.skipIf(!harnessDbEnvAvailable())( }, inferenceEchoUserMessage: true, materializeMailTriggeredRunGrants: materializer, + // The production send check, so the sender's mail reaches the + // receiver only through its run principal's send grant. + authorizeMailSend: createMailSendAuthorizer({ + db: h.db, + grantStoreFor: createGrantStore, + }), // Co-deliver the sender's durable key on the receiver run's grants // barrier the way production does, so the sender (a local same-hub run // with a resolvable key) verifies `clean` rather than the cache-miss @@ -414,6 +425,17 @@ describe.skipIf(!harnessDbEnvAvailable())( .update(workflowRunTable) .set({ principalId: SENDER_RUN_PRINCIPAL_ID }) .where(eq(workflowRunTable.id, SENDER_ID)); + // The commit path would also stage the sender's send grant; give it the + // same row, so the relay's send check allows its mail to the receiver. + await h.db + .insert(grantTable) + .values( + deriveRunMailSendGrantRow( + SENDER_TENANT_ID, + SENDER_RUN_PRINCIPAL_ID, + new Date(), + ), + ); // Wait for sidecar 1 (the receiver) to reconnect so the receiver is // routable again before the sender's mail can arrive, then trigger the @@ -495,7 +517,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${address}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ...extra, ]); } diff --git a/tests/workflow-deploy/mail-send-denied-real-route.test.ts b/tests/workflow-deploy/mail-send-denied-real-route.test.ts new file mode 100644 index 000000000..ce428c39b --- /dev/null +++ b/tests/workflow-deploy/mail-send-denied-real-route.test.ts @@ -0,0 +1,385 @@ +// An operator deny on a run refuses the run's mail, end to end. +// +// This drives the PRODUCTION `POST /workflows/:runId/mail` route against a +// real migrated schema and a real sidecar subprocess, with the hub's +// database-backed send authorizer on the relay. Before the first trigger an +// operator gives the deployment's run principal `deny mail.send:*` with the +// action `*`. The first trigger still stages the run's `mail.send:*:*` +// allow, and the run still completes, but its reply is refused: the hub +// relays it to no one and stores its audit copy for no recipient. + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { type } from "arktype"; + +import { eq } from "drizzle-orm"; + +import { createGrantStore } from "@intx/db"; +import { + grant as grantTable, + tenant as tenantTable, + workflowRun as workflowRunTable, +} from "@intx/db/schema"; +import { + createApp, + createMailSendAuthorizer, + type GetSession, +} from "@intx/hub-api"; +import { + createAssetService, + type EventCollectorRegistry, +} from "@intx/hub-sessions"; +import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { + seedAsset, + seedGrant, + seedPrincipal, + seedPrincipalKey, + tenantSlugFromId, +} from "@intx/test-harness/seed"; +import { deriveRunPrincipalId } from "@intx/hub-common"; +import { deriveRunAddress } from "@intx/workflow-deploy"; + +import { + SESSION_ID, + deployWorkflowSourceForTest, + startDeployFlowEnv, + waitFor, + waitForWorkflowRunComplete, + type DeployFlowEnv, +} from "../hub-agent/lib/deploy-flow-env"; +import { singleStepAgentEntry } from "./fixtures/single-step-agent"; + +// The tenant domain must equal the fixture's deploy domain so the route's +// derived address (`@`) matches the +// address the fixture deployed the sidecar workflow under; otherwise the +// route's routeMail targets an unknown address (409). +const DEPLOYMENT_DOMAIN = "integration.interchange"; +const DEPLOYMENT_ID = "run_mail-send-denied-1"; +const TENANT_ID = "tnt_mail_send_denied"; +const CALLER_USER_ID = "usr_mail_send_denied_caller"; +const CALLER_PRINCIPAL_ID = "prn_mail_send_denied_caller"; +const DEFINITION_ASSET_ID = "ast_mail_send_denied_wf"; +const STEP_ID = "step1"; + +const deploymentMailAddress = deriveRunAddress({ + runId: DEPLOYMENT_ID, + domain: DEPLOYMENT_DOMAIN, +}); + +// The trigger route's 202 body shape. Validated rather than cast so a +// route response drift surfaces at the boundary. +const TriggerResponse = type({ + runId: "string", + address: "string", + messageId: "string", +}); + +let env: DeployFlowEnv; +let h: TestDb; + +function createMockGetSession(userId: string): GetSession { + const now = new Date("2025-01-01"); + return async () => ({ + user: { + id: userId, + email: "caller@example.com", + emailVerified: true, + name: "Caller", + createdAt: now, + updatedAt: now, + }, + session: { + id: "session_mail_send_denied", + userId, + token: "tok_mail_send_denied", + expiresAt: new Date("2999-01-01"), + createdAt: now, + updatedAt: now, + }, + }); +} + +function notImpl(name: string): never { + throw new Error(`mail-send denied mock: ${name} not implemented`); +} + +function createMockEventCollectors(): EventCollectorRegistry { + return { + create: () => notImpl("create"), + dispatch: () => notImpl("dispatch"), + abandon: () => notImpl("abandon"), + has: () => false, + getStatus: () => undefined, + getAccumulatedText: () => undefined, + getCurrentTurnId: () => undefined, + getLastTurnId: () => undefined, + }; +} + +describe.skipIf(!harnessDbEnvAvailable())( + "an operator deny on a run refuses its mail through the real relay", + () => { + let hasRun = false; + + beforeAll(async () => { + h = await createTestDb(); + // Echo inference so the single agent step produces a reply and the + // run terminates on its own once its grants barrier is satisfied. The + // relay checks that reply with the production authorizer, so only the + // send grant the trigger commits can let it through. + env = await startDeployFlowEnv({ + inferenceEchoUserMessage: true, + authorizeMailSend: createMailSendAuthorizer({ + db: h.db, + grantStoreFor: createGrantStore, + }), + }); + }); + + afterAll(async () => { + if (env !== undefined) await env.teardown(); + if (h !== undefined) await h.close(); + }); + + beforeEach(async () => { + await h.reset(); + }); + + afterEach(async () => { + await h.reset(); + }); + + test("the run completes and its reply goes to no one", async () => { + if (hasRun) { + throw new Error( + "this suite assumes a single test per shared subprocess env; " + + "add a new scenario in its own file with its own env instead", + ); + } + hasRun = true; + + // Seed the tenancy the route resolves against: the tenant carries the + // deploy domain so the derived address matches the sidecar deployment, + // and the caller is an active user-principal holding the + // `workflow-run:/manage` grant the `/mail` route requires. The + // caller also creates the `workflow`-kind definition asset the frozen + // definition projects over; the trigger route reads that asset's + // creator to resolve the run's creator grants. + await h.db.insert(tenantTable).values({ + id: TENANT_ID, + name: TENANT_ID, + slug: tenantSlugFromId(TENANT_ID), + domain: DEPLOYMENT_DOMAIN, + parentId: null, + }); + await seedPrincipal(h.db, { + id: CALLER_PRINCIPAL_ID, + tenantId: TENANT_ID, + kind: "user", + refId: CALLER_USER_ID, + status: "active", + }); + // The caller signs the trigger mail with its durable hub principal key, + // which production mints at principal creation; the direct row insert + // above bypasses that, so mint it here or the trigger's sign() throws. + await seedPrincipalKey(h.db, CALLER_PRINCIPAL_ID); + await seedAsset(h.db, { + id: DEFINITION_ASSET_ID, + tenantId: TENANT_ID, + kind: "workflow", + name: "mail-send-denied-wf", + creatorPrincipalId: CALLER_PRINCIPAL_ID, + }); + await seedGrant(h.db, { + id: "grant-caller-manage", + tenantId: TENANT_ID, + resource: `workflow-run:${DEPLOYMENT_ID}`, + action: "manage", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-caller-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); + + // Deploy the same workflow to the real sidecar through the code-sourced + // front. This freezes the definition + grant snapshot against the real + // DB, writes the anchor `workflow_run` row, and makes the deployment's + // mail address routable; it also registers the deployment with the + // fixture so run events can be read for the completion wait. + const inferenceSource: InferenceSource = { + id: "anthropic:mock-model", + provider: "anthropic", + baseURL: `http://localhost:${String(env.inference.server.port)}`, + credentialId: "sk-mock", + model: "mock-model", + }; + const config: HarnessConfig = { + sessionId: SESSION_ID, + agentAddress: deploymentMailAddress, + sources: [inferenceSource], + defaultSource: "anthropic:mock-model", + }; + const entryModule = singleStepAgentEntry({ + stepId: STEP_ID, + systemPrompt: "You are the single-step run-completes agent.", + address: deploymentMailAddress, + agentId: `agent_${DEPLOYMENT_ID}`, + }); + const handle = await deployWorkflowSourceForTest(env, { + entryModule, + db: h.db, + tenantId: TENANT_ID, + definitionAssetId: DEFINITION_ASSET_ID, + anchorRunId: DEPLOYMENT_ID, + sourceAuthorityPrincipalId: CALLER_PRINCIPAL_ID, + deploymentDomain: DEPLOYMENT_DOMAIN, + agentAddress: deploymentMailAddress, + approvals: "approve-probed", + config, + sources: { [STEP_ID]: [inferenceSource] }, + }); + expect(handle.publicKey).toBeTruthy(); + + // The source-ref frame round-trips through the real sidecar subprocess + // (index the pack, check out the pinned subtree, register the address), + // so routability is asynchronous. Wait for it before driving the route. + await waitFor( + () => + env.hub.router.getRoutableAddresses().includes(deploymentMailAddress), + { diagnostics: env.sidecarDiagnostics }, + ); + + const runPrincipalId = await deriveRunPrincipalId( + TENANT_ID, + DEPLOYMENT_ID, + ); + // Deploy created the run principal, so the operator can deny it before + // the first trigger. + await seedGrant(h.db, { + id: "grant-operator-deny-send", + tenantId: TENANT_ID, + resource: "mail.send:*", + action: "*", + effect: "deny", + origin: "system", + principalId: runPrincipalId, + }); + + const grantStore = createGrantStore(h.db); + const assetService = createAssetService({ + db: h.db, + repoStore: env.hub.agentRepoStore.repoStore, + }); + const triggerApp = createApp({ + getSession: createMockGetSession(CALLER_USER_ID), + authHandler: () => new Response("", { status: 404 }), + db: h.db, + grantStore, + sidecarRouter: env.hub.router, + sessionService: env.hub.sessionService, + eventCollectors: createMockEventCollectors(), + assetService, + repoStore: env.hub.agentRepoStore.repoStore, + maxTarballBytes: 10_000_000, + }); + + const res = await triggerApp.request( + `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "kick off the run" }), + }, + ); + if (res.status !== 202) { + const body: unknown = await res.json(); + throw new Error( + `expected 202 from /mail, got ${String(res.status)}: ${JSON.stringify(body)}\n${env.sidecarDiagnostics()}`, + ); + } + const json = TriggerResponse.assert(await res.json()); + expect(json.address).toBe(deploymentMailAddress); + + // The load-bearing assertion: the run the route dispatched reaches + // terminal SUCCESS. The runId is the deployment mail address, so that + // is what the run events are keyed on. A RunFailed here (or a timeout) + // is the signature of grants staged under the wrong runId. + const terminal = await waitForWorkflowRunComplete( + env, + DEPLOYMENT_ID, + DEPLOYMENT_ID, + { diagnostics: env.sidecarDiagnostics }, + ); + if (terminal.type !== "RunCompleted") { + throw new Error( + `expected RunCompleted, got ${terminal.type}: ${JSON.stringify(terminal.body)}\n${env.sidecarDiagnostics()}`, + ); + } + expect(terminal.type).toBe("RunCompleted"); + + // The first trigger staged the run's own allow next to the operator's + // deny. The deny is namespace-wide, so it ranks with the allow and its + // effect wins for every recipient. + const runSendGrants = await h.db + .select({ resource: grantTable.resource, effect: grantTable.effect }) + .from(grantTable) + .where(eq(grantTable.principalId, runPrincipalId)); + expect(runSendGrants).toContainEqual({ + resource: "mail.send:*:*", + effect: "allow", + }); + expect(runSendGrants).toContainEqual({ + resource: "mail.send:*", + effect: "deny", + }); + + // The audit copy arrives after the relay frame on the same socket, so + // once it is here the relay has decided the reply. The hub stored the + // copy for no recipient and reported nothing undelivered. + await waitFor( + () => + env.hub.outboundMail.some( + (m) => m.senderAddress === deploymentMailAddress, + ), + { diagnostics: env.sidecarDiagnostics }, + ); + const reply = env.hub.outboundMail.find( + (m) => m.senderAddress === deploymentMailAddress, + ); + expect(reply?.recipients).toEqual([]); + expect(env.hub.undeliveredMail).toEqual([]); + + // The collapse: the trigger reconciles onto the deployment's single + // self-anchored run rather than minting a second routable row. After the + // first trigger exactly ONE workflow_run row exists, and it is the anchor + // (id == anchorRunId == anchorRunId) -- the anchor IS the run. + const runRows = await h.db.select().from(workflowRunTable); + expect(runRows).toHaveLength(1); + expect(runRows[0]?.id).toBe(DEPLOYMENT_ID); + expect(runRows[0]?.anchorRunId).toBe(DEPLOYMENT_ID); + }); + }, +); diff --git a/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts b/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts index 449c6c4b3..a8c3f8e8c 100644 --- a/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts @@ -292,6 +292,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: CALLER_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-caller-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); // Deploy the action-step workflow BY SOURCE-REF to the real sidecar. The // freeze writes the deployment's definition + frozen grant snapshot (the @@ -317,7 +328,6 @@ describe.skipIf(!harnessDbEnvAvailable())( const operatorApprovals: ApprovalSet = createApprovalSet([ "effect:fs:write", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const handle = await deployWorkflowSourceForTest(env, { entryModule: actionStepEntry({ diff --git a/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts b/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts index 81f3af616..6471337d3 100644 --- a/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts +++ b/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts @@ -38,12 +38,19 @@ import { } from "bun:test"; import { type } from "arktype"; +import { eq } from "drizzle-orm"; + import { createGrantStore } from "@intx/db"; import { + grant as grantTable, tenant as tenantTable, workflowRun as workflowRunTable, } from "@intx/db/schema"; -import { createApp, type GetSession } from "@intx/hub-api"; +import { + createApp, + createMailSendAuthorizer, + type GetSession, +} from "@intx/hub-api"; import { createAssetService, type EventCollectorRegistry, @@ -148,8 +155,16 @@ describe.skipIf(!harnessDbEnvAvailable())( beforeAll(async () => { h = await createTestDb(); // Echo inference so the single agent step produces a reply and the - // run terminates on its own once its grants barrier is satisfied. - env = await startDeployFlowEnv({ inferenceEchoUserMessage: true }); + // run terminates on its own once its grants barrier is satisfied. The + // relay checks that reply with the production authorizer, so only the + // send grant the trigger commits can let it through. + env = await startDeployFlowEnv({ + inferenceEchoUserMessage: true, + authorizeMailSend: createMailSendAuthorizer({ + db: h.db, + grantStoreFor: createGrantStore, + }), + }); }); afterAll(async () => { @@ -215,6 +230,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: CALLER_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-caller-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); // Deploy the same workflow to the real sidecar through the code-sourced // front. This freezes the definition + grant snapshot against the real @@ -316,6 +342,31 @@ describe.skipIf(!harnessDbEnvAvailable())( } expect(terminal.type).toBe("RunCompleted"); + // The run's first trigger commits its `mail.send:*:*` row, and the + // relay's send check reads it for the run's reply. The hub stores the + // audit copy of a send only for the recipients that check allows. + const runSendGrants = await h.db + .select({ resource: grantTable.resource, origin: grantTable.origin }) + .from(grantTable) + .where(eq(grantTable.action, "send")); + expect(runSendGrants).toContainEqual({ + resource: "mail.send:*:*", + origin: "creator", + }); + await waitFor( + () => + env.hub.outboundMail.some( + (m) => m.senderAddress === deploymentMailAddress, + ), + { diagnostics: env.sidecarDiagnostics }, + ); + const reply = env.hub.outboundMail.find( + (m) => m.senderAddress === deploymentMailAddress, + ); + expect(reply?.recipients).toEqual([ + `${CALLER_USER_ID}@${DEPLOYMENT_DOMAIN}`, + ]); + // The collapse: the trigger reconciles onto the deployment's single // self-anchored run rather than minting a second routable row. After the // first trigger exactly ONE workflow_run row exists, and it is the anchor diff --git a/tests/workflow-deploy/map-fan-out-real-agent.test.ts b/tests/workflow-deploy/map-fan-out-real-agent.test.ts index 3790a9116..2908dfa34 100644 --- a/tests/workflow-deploy/map-fan-out-real-agent.test.ts +++ b/tests/workflow-deploy/map-fan-out-real-agent.test.ts @@ -240,7 +240,6 @@ async function deployAndRunMap(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ...(withTool ? [`tool:${TOOL_NAME}`] : []), ]); diff --git a/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts b/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts index fc1dafac0..8b96650d9 100644 --- a/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts +++ b/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts @@ -149,7 +149,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = signalGateEntry({ diff --git a/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts b/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts index 2e01febbd..763648752 100644 --- a/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts +++ b/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts @@ -157,7 +157,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = signalGateEntry({ diff --git a/tests/workflow-deploy/multistep-signal.test.ts b/tests/workflow-deploy/multistep-signal.test.ts index 51b665fbe..d86677ec9 100644 --- a/tests/workflow-deploy/multistep-signal.test.ts +++ b/tests/workflow-deploy/multistep-signal.test.ts @@ -121,7 +121,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; // Approval set: per-step grants plus the trigger-derived - // `mail.address` / `mail.send` pair. The capability walk attaches + // `mail.address` grant. The capability walk attaches // trigger grants to every step (including the `awaitSignal` // primitive); the operator approval set must therefore enumerate // them for every step that the walk surfaces. @@ -129,7 +129,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // Two distinct agent system prompts exercise the per-step prompt diff --git a/tests/workflow-deploy/multistep-signed-send.test.ts b/tests/workflow-deploy/multistep-signed-send.test.ts index b0ca51761..b0acc0df5 100644 --- a/tests/workflow-deploy/multistep-signed-send.test.ts +++ b/tests/workflow-deploy/multistep-signed-send.test.ts @@ -170,7 +170,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/on-trigger-action-body.test.ts b/tests/workflow-deploy/on-trigger-action-body.test.ts index 6840be9c5..808b34216 100644 --- a/tests/workflow-deploy/on-trigger-action-body.test.ts +++ b/tests/workflow-deploy/on-trigger-action-body.test.ts @@ -165,7 +165,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, "effect:ship", ]); diff --git a/tests/workflow-deploy/on-trigger-agent-body.test.ts b/tests/workflow-deploy/on-trigger-agent-body.test.ts index 595f03103..400adfc42 100644 --- a/tests/workflow-deploy/on-trigger-agent-body.test.ts +++ b/tests/workflow-deploy/on-trigger-agent-body.test.ts @@ -174,7 +174,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = onTriggerBodyEntry({ diff --git a/tests/workflow-deploy/on-trigger-between-events-restart.test.ts b/tests/workflow-deploy/on-trigger-between-events-restart.test.ts index 99e13abe4..6faa07da4 100644 --- a/tests/workflow-deploy/on-trigger-between-events-restart.test.ts +++ b/tests/workflow-deploy/on-trigger-between-events-restart.test.ts @@ -201,7 +201,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // The section body: a single non-agent step that completes on its own so diff --git a/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts b/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts index a41cb6ca0..3fc08cba7 100644 --- a/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts +++ b/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts @@ -208,7 +208,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // A long-duration sleep body: the section spawns it per event, and it diff --git a/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts index 596c8a856..b0a9a7adc 100644 --- a/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts @@ -204,7 +204,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = onTriggerChildWorkflowEntry({ diff --git a/tests/workflow-deploy/on-trigger-credential-tool.test.ts b/tests/workflow-deploy/on-trigger-credential-tool.test.ts index e769269ee..841b66de2 100644 --- a/tests/workflow-deploy/on-trigger-credential-tool.test.ts +++ b/tests/workflow-deploy/on-trigger-credential-tool.test.ts @@ -191,7 +191,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${PROBE_DEFINITION_NAME}`, `credential:${CREDENTIAL_HANDLE}`, ]); diff --git a/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts index f459716e8..20ab66b71 100644 --- a/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts @@ -148,7 +148,6 @@ async function deployDrainSection(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = onTriggerBodyEntry({ address: deploymentMailAddress, diff --git a/tests/workflow-deploy/on-trigger-signal-delivery.test.ts b/tests/workflow-deploy/on-trigger-signal-delivery.test.ts index a7005aacb..bb3b1da20 100644 --- a/tests/workflow-deploy/on-trigger-signal-delivery.test.ts +++ b/tests/workflow-deploy/on-trigger-signal-delivery.test.ts @@ -258,7 +258,6 @@ async function deployAndPark(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const handle = await deployWorkflowSourceForTest(env, { diff --git a/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts index 4d9768794..98e63c446 100644 --- a/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts @@ -197,7 +197,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts b/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts index 433ad3362..87724eede 100644 --- a/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts +++ b/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts @@ -472,7 +472,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/run-mail-send-real-route.test.ts b/tests/workflow-deploy/run-mail-send-real-route.test.ts index b7d2b4250..829d2a1be 100644 --- a/tests/workflow-deploy/run-mail-send-real-route.test.ts +++ b/tests/workflow-deploy/run-mail-send-real-route.test.ts @@ -191,6 +191,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: CALLER_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-caller-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: CALLER_PRINCIPAL_ID, + }); env = await startDeployFlowEnv(); }); diff --git a/tests/workflow-deploy/scope-always-roundtrip.test.ts b/tests/workflow-deploy/scope-always-roundtrip.test.ts index b04f20ce7..2703b07b3 100644 --- a/tests/workflow-deploy/scope-always-roundtrip.test.ts +++ b/tests/workflow-deploy/scope-always-roundtrip.test.ts @@ -264,6 +264,17 @@ describe.skipIf(!harnessDbEnvAvailable())( origin: "system", principalId: OPERATOR_PRINCIPAL_ID, }); + // The mail that starts the run also needs a send grant, as the + // caller of a tenant made through the route holds by its role. + await seedGrant(h.db, { + id: "grant-operator-manage-send", + tenantId: TENANT_ID, + resource: `mail.send:tenant:${TENANT_ID}`, + action: "send", + effect: "allow", + origin: "system", + principalId: OPERATOR_PRINCIPAL_ID, + }); await seedGrant(h.db, { id: "grant-operator-resolve", tenantId: TENANT_ID, @@ -293,7 +304,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-credential-tool.test.ts b/tests/workflow-deploy/single-step-credential-tool.test.ts index 3fa7e4d3a..0eba76ea3 100644 --- a/tests/workflow-deploy/single-step-credential-tool.test.ts +++ b/tests/workflow-deploy/single-step-credential-tool.test.ts @@ -296,7 +296,6 @@ async function deployProbeSource(opts: { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${mailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${PROBE_DEFINITION_NAME}`, ...(withBinding ? [`credential:${CREDENTIAL_HANDLE}`] : []), ]); diff --git a/tests/workflow-deploy/single-step-full-lifecycle.test.ts b/tests/workflow-deploy/single-step-full-lifecycle.test.ts index c4c096029..e8db24860 100644 --- a/tests/workflow-deploy/single-step-full-lifecycle.test.ts +++ b/tests/workflow-deploy/single-step-full-lifecycle.test.ts @@ -237,7 +237,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-grants-bridge.test.ts b/tests/workflow-deploy/single-step-grants-bridge.test.ts index 4a362f19d..7e3d9dc01 100644 --- a/tests/workflow-deploy/single-step-grants-bridge.test.ts +++ b/tests/workflow-deploy/single-step-grants-bridge.test.ts @@ -201,7 +201,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-mail-loop.test.ts b/tests/workflow-deploy/single-step-mail-loop.test.ts index 6c2ec534b..681c92f93 100644 --- a/tests/workflow-deploy/single-step-mail-loop.test.ts +++ b/tests/workflow-deploy/single-step-mail-loop.test.ts @@ -29,6 +29,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import type { WireGrantRule } from "@intx/types/grant-wire"; +import { permitAllMailSend } from "@intx/authz/testing"; import { deriveRunAddress } from "@intx/workflow-deploy"; import { tenant as tenantTable } from "@intx/db/schema"; import { @@ -105,6 +106,7 @@ const DEFINITION_ASSET_ID = "ast_single_step_mail_loop_wf"; let env: DeployFlowEnv; let h: TestDb; +const sendChecks: { sender: unknown; recipients: string[] }[] = []; beforeAll(async () => { if (!harnessDbEnvAvailable()) return; @@ -139,6 +141,15 @@ beforeAll(async () => { input: {}, resultPrefix: RESULT_PREFIX, }, + // Record the relay's send checks, so the test can show the warm reply, + // which no mail tool sends, passes through the check. + async authorizeMailSend(args) { + sendChecks.push({ + sender: args.sender, + recipients: [...args.recipients], + }); + return permitAllMailSend(args); + }, }); }); @@ -281,6 +292,22 @@ describe.skipIf(!harnessDbEnvAvailable())( m.recipients.includes(INBOUND_FROM), ); expect(replyOut).toBeDefined(); + + // The hub checks the audit copy above apart from the relayed mail. The + // relay reports the reply as undelivered (no local address routes the + // external sender) only after its own send check allowed it, and the + // check saw the deployment send to the original sender. + await waitFor( + () => + env.hub.undeliveredMail.some((m) => + m.recipients.includes(INBOUND_FROM), + ), + { diagnostics: env.sidecarDiagnostics }, + ); + expect(sendChecks).toContainEqual({ + sender: { kind: "address", address: deploymentMailAddress }, + recipients: [INBOUND_FROM], + }); }); }, ); diff --git a/tests/workflow-deploy/single-step-message-input.test.ts b/tests/workflow-deploy/single-step-message-input.test.ts index fc710b8ae..0a42337af 100644 --- a/tests/workflow-deploy/single-step-message-input.test.ts +++ b/tests/workflow-deploy/single-step-message-input.test.ts @@ -161,7 +161,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ @@ -295,7 +294,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/single-step-per-run-grants.test.ts b/tests/workflow-deploy/single-step-per-run-grants.test.ts index bd1527583..c946f9f53 100644 --- a/tests/workflow-deploy/single-step-per-run-grants.test.ts +++ b/tests/workflow-deploy/single-step-per-run-grants.test.ts @@ -162,7 +162,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts b/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts index f688edb86..432f8b237 100644 --- a/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts +++ b/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts @@ -144,7 +144,6 @@ describe.skipIf(!harnessDbEnvAvailable())("single-step ask-marked tool", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-posix-tool.test.ts b/tests/workflow-deploy/single-step-posix-tool.test.ts index 41ca9a3c5..ee20ee172 100644 --- a/tests/workflow-deploy/single-step-posix-tool.test.ts +++ b/tests/workflow-deploy/single-step-posix-tool.test.ts @@ -173,7 +173,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/single-step-real-agent.test.ts b/tests/workflow-deploy/single-step-real-agent.test.ts index c109bebb2..296cb74ba 100644 --- a/tests/workflow-deploy/single-step-real-agent.test.ts +++ b/tests/workflow-deploy/single-step-real-agent.test.ts @@ -147,7 +147,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const entryModule = singleStepAgentEntry({ diff --git a/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts b/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts index be3660d0d..e86011ccd 100644 --- a/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts +++ b/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts @@ -470,7 +470,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${mailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]), config: harnessConfig(env, mailAddress), diff --git a/tests/workflow-deploy/source-catalog.e2e.test.ts b/tests/workflow-deploy/source-catalog.e2e.test.ts index 87df14ab3..95263db91 100644 --- a/tests/workflow-deploy/source-catalog.e2e.test.ts +++ b/tests/workflow-deploy/source-catalog.e2e.test.ts @@ -276,7 +276,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const approved = await installAndApproveWorkflowDefinition({ diff --git a/tests/workflow-deploy/source-closure-director.e2e.test.ts b/tests/workflow-deploy/source-closure-director.e2e.test.ts index 18e3654e0..2f0ed5b88 100644 --- a/tests/workflow-deploy/source-closure-director.e2e.test.ts +++ b/tests/workflow-deploy/source-closure-director.e2e.test.ts @@ -434,7 +434,6 @@ const approvalsFor = (address: string, directorId: string): ApprovalSet => "director:@intx/agent/default", `director:${directorId}`, `mail.address:${address}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // Pull the text out of a captured inference request's `system` field. Narrows diff --git a/tests/workflow-deploy/source-credential.e2e.test.ts b/tests/workflow-deploy/source-credential.e2e.test.ts index 7e1206988..a9ae244f0 100644 --- a/tests/workflow-deploy/source-credential.e2e.test.ts +++ b/tests/workflow-deploy/source-credential.e2e.test.ts @@ -293,7 +293,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${TOOL_NAME}`, `credential:${CREDENTIAL_HANDLE}`, ]); diff --git a/tests/workflow-deploy/source-inline-tool.e2e.test.ts b/tests/workflow-deploy/source-inline-tool.e2e.test.ts index 3d9983725..58e9ac7b4 100644 --- a/tests/workflow-deploy/source-inline-tool.e2e.test.ts +++ b/tests/workflow-deploy/source-inline-tool.e2e.test.ts @@ -160,7 +160,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]); diff --git a/tests/workflow-deploy/source-mixed-closure.e2e.test.ts b/tests/workflow-deploy/source-mixed-closure.e2e.test.ts index 73ee2da64..1aaf3724d 100644 --- a/tests/workflow-deploy/source-mixed-closure.e2e.test.ts +++ b/tests/workflow-deploy/source-mixed-closure.e2e.test.ts @@ -352,7 +352,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const approved = await installAndApproveWorkflowDefinition({ diff --git a/tests/workflow-deploy/source-monorepo-many.e2e.test.ts b/tests/workflow-deploy/source-monorepo-many.e2e.test.ts index 3c4ecea88..feee05610 100644 --- a/tests/workflow-deploy/source-monorepo-many.e2e.test.ts +++ b/tests/workflow-deploy/source-monorepo-many.e2e.test.ts @@ -438,7 +438,6 @@ const distinctApprovals = (address: string): ApprovalSet => "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${address}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // Pull the text out of a captured inference request's `system` field. The mock @@ -468,7 +467,6 @@ const collapseApprovals: ApprovalSet = createApprovalSet([ "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${COLLAPSE_ADDRESS}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); describe.skipIf(!harnessDbEnvAvailable())( diff --git a/tests/workflow-deploy/source-monorepo.e2e.test.ts b/tests/workflow-deploy/source-monorepo.e2e.test.ts index 2cbe31472..13472d62a 100644 --- a/tests/workflow-deploy/source-monorepo.e2e.test.ts +++ b/tests/workflow-deploy/source-monorepo.e2e.test.ts @@ -317,7 +317,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // 1) Install: enumerate the monorepo members, probe, gate, freeze. diff --git a/tests/workflow-deploy/source-posix-lsp.e2e.test.ts b/tests/workflow-deploy/source-posix-lsp.e2e.test.ts index a5c8d5bbe..dca0fb599 100644 --- a/tests/workflow-deploy/source-posix-lsp.e2e.test.ts +++ b/tests/workflow-deploy/source-posix-lsp.e2e.test.ts @@ -437,7 +437,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${LSP_TOOL_NAME}`, ...POSIX_TOOL_NAMES.map((n) => `tool:${n}`), ]); diff --git a/tests/workflow-deploy/source-tamper-reject.e2e.test.ts b/tests/workflow-deploy/source-tamper-reject.e2e.test.ts index bbd7bf523..76728e9d8 100644 --- a/tests/workflow-deploy/source-tamper-reject.e2e.test.ts +++ b/tests/workflow-deploy/source-tamper-reject.e2e.test.ts @@ -249,7 +249,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // 1) Install cleanly: the genuine closure is resolved and frozen. diff --git a/tests/workflow-deploy/source-workflow.e2e.test.ts b/tests/workflow-deploy/source-workflow.e2e.test.ts index 897578196..1e19b697f 100644 --- a/tests/workflow-deploy/source-workflow.e2e.test.ts +++ b/tests/workflow-deploy/source-workflow.e2e.test.ts @@ -261,7 +261,6 @@ describe.skipIf(!harnessDbEnvAvailable())("source-sourced workflow e2e", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // 1) Install + probe + gate + freeze against the source asset. diff --git a/tests/workflow-deploy/unresolvable-director.test.ts b/tests/workflow-deploy/unresolvable-director.test.ts index c46206f3e..70e1042c8 100644 --- a/tests/workflow-deploy/unresolvable-director.test.ts +++ b/tests/workflow-deploy/unresolvable-director.test.ts @@ -125,7 +125,6 @@ describe.skipIf(!harnessDbEnvAvailable())( "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, `director:${UNRESOLVABLE_DIRECTOR_ID}`, ]); diff --git a/tests/workflow-deploy/walking-skeleton.e2e.test.ts b/tests/workflow-deploy/walking-skeleton.e2e.test.ts index 749aaaceb..e7ee21f2a 100644 --- a/tests/workflow-deploy/walking-skeleton.e2e.test.ts +++ b/tests/workflow-deploy/walking-skeleton.e2e.test.ts @@ -465,7 +465,6 @@ describe.skipIf(!harnessDbEnvAvailable())("walking skeleton e2e", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${deploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); // 1) Install + probe + gate + freeze through the production entrypoint. @@ -688,7 +687,6 @@ describe.skipIf(!harnessDbEnvAvailable())("walking skeleton e2e", () => { "inference.source:anthropic:mock-model", "director:@intx/agent/default", `mail.address:${bodyDeploymentMailAddress}`, - `mail.send:${DEPLOYMENT_DOMAIN}`, ]); const approved = await installAndApproveWorkflowDefinition({