diff --git a/.gitattributes b/.gitattributes index 522c2b612..61c3cadbd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,4 +11,3 @@ module/services/telemetry/code/pkg/gen/** linguist-generated=true # Build recipes emitted by the pinned Go service agent. module/services/accounts/builder/** linguist-generated=true module/services/auth-gateway/builder/** linguist-generated=true -module/services/policy-log/builder/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75f333792..c168a1bc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -366,6 +366,26 @@ jobs: - name: Qualify the scoped reader, writer and request pools run: python3 scripts/qualify-scoped-pools.py --postgres-bin "${{ steps.postgres.outputs.bindir }}" + # The shared host-interface conformance suite at codefly-dev/authority main, + # against accounts: the newest authority that contains accounts' pin, so a + # case the suite adds or a rule the authority core changes reaches accounts on + # its next pull request, never silently on the next re-pin. The same suite at + # the pinned revision runs with accounts' tests in the quality phases. + host-interface-conformance: + name: Host interface conformance (authority main) + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.3" + cache-dependency-path: module/services/accounts/code/go.sum + - name: Install Codefly + run: bash scripts/ci/install-codefly.sh + - name: Run the shared suite at authority main against accounts + run: bash scripts/ci/host-interface-conformance-main.sh + marketing: name: Marketing isolation runs-on: ubuntu-latest @@ -1146,6 +1166,7 @@ jobs: - codefly-quality - codefly-supply-chain - docs-sync + - host-interface-conformance - kit-version - marketing - module-package diff --git a/AGENTS.md b/AGENTS.md index 1cc7bfab8..d7a94e925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,11 +190,11 @@ Skills in `.claude/skills/`, loaded when the task calls for them: - Canonical **service** gate: `codefly ci run`. It owns lint, compile/typecheck, tests, dependency/vuln audit, SBOM, and container build for every service in the graph. See [RELEASE_GATES.md](./RELEASE_GATES.md). -- Beside it, CI runs nine repository-specific gates that no service owns — the +- Beside it, CI runs ten repository-specific gates that no service owns — the module verification job, authorization coverage, the release-gate contract, interface docs and story tests, the kit's version, marketing isolation, the SDK boundary, the immutable module package, the scoped pools' database - authority. [RELEASE_GATES.md § Repository-specific + authority, and the shared host-interface conformance suite at authority main. [RELEASE_GATES.md § Repository-specific gates](./RELEASE_GATES.md#repository-specific-gates) says what each runs, and `release-gates.test.mjs` holds this prose to the enforced set. - More checks run as *steps* inside the `base-integrity` job than as gates of their diff --git a/CLAIM_INVENTORY.md b/CLAIM_INVENTORY.md index a82e67e12..e5867005e 100644 --- a/CLAIM_INVENTORY.md +++ b/CLAIM_INVENTORY.md @@ -73,7 +73,7 @@ must not be inferred from a row here. See | Claim | Source | Executable evidence | Status | Owner | | --- | --- | --- | --- | --- | | `codefly ci run` is the canonical **service** gate and provider YAML reimplements none of it | RELEASE_GATES.md; AGENTS.md | `.github/workflows/ci.yml` `codefly-quality` / `codefly-supply-chain` / `codefly-build` | implemented | Codefly | -| ~~"GitHub Actions ... does not encode Go, Rust, Next.js, protobuf, dependency, container, or service-specific commands"~~ and ~~base integrity is "the one repository-specific gate"~~ | RELEASE_GATES.md, AGENTS.md, before this change | nine repository-specific jobs run `node --test`, `go test`, `buf breaking`, `python3` and `npm` directly | **corrected** | #541 | +| ~~"GitHub Actions ... does not encode Go, Rust, Next.js, protobuf, dependency, container, or service-specific commands"~~ and ~~base integrity is "the one repository-specific gate"~~ | RELEASE_GATES.md, AGENTS.md, before this change | ten repository-specific jobs run `node --test`, `go test`, `buf breaking`, `python3` and `npm` directly | **corrected** | #541 | | The documented repository-specific gate list is the enforced one | RELEASE_GATES.md § Repository-specific gates; AGENTS.md | `release-gates.test.mjs` compares the table and both prose counts against `REQUIRED_GATES` | implemented | #541 | | The complete audit runs `--fail-on-vuln=false`; a separate step enforces first-party findings fail-closed | RELEASE_GATES.md § Vulnerability policy | `.github/workflows/ci.yml` `codefly-supply-chain` | configured | Codefly + this repo | | No artifact-writing job runs unless every mandatory gate actually succeeded | RELEASE_GATES.md § Publication gating | `scripts/ci/release-gates.mjs check` + `decide`, `release-gates.test.mjs` | implemented (#535) | this repo | diff --git a/RELEASE_GATES.md b/RELEASE_GATES.md index 0ce3e1f79..1c8abff68 100644 --- a/RELEASE_GATES.md +++ b/RELEASE_GATES.md @@ -12,8 +12,8 @@ same gate as this repository. guard artifacts and contracts no single service owns: the canonical base manifest that seeds every consumer, the authorization catalog, the release gating graph itself, the interface docs, the published frontend kit's version, -the marketing isolation build, the SDK boundary, and the -immutable module package. They run +the marketing isolation build, the SDK boundary, the +immutable module package, and the host interfaces accounts serves. They run `node --test`, `go test`, `buf breaking`, and `npm` commands directly — see [Repository-specific gates](#repository-specific-gates) for the full list and why each one lives here rather than in a plugin. @@ -65,6 +65,7 @@ base-integrity ───────┐ authz-coverage ───────┤ release-contract ─────┤ docs-sync ────────────┤ +host-interface-conformance ┤ kit-version ──────────┤ marketing ────────────┤ ┌─▶ publish-module-package (module-package/v*) sdk-boundary ─────────┼─▶ release-gates ────┼─▶ publish-frontend-kit (v*) @@ -110,6 +111,7 @@ no per-track exemption. | `authz-coverage` | required | required | RBAC, audit, and no-broadening | | `release-contract` | required | required | this gating graph itself | | `docs-sync` | required | required | interface docs and story tests | +| `host-interface-conformance` | required | required | the shared host-interface suite at authority main | | `kit-version` | required | required | published frontend kit version vs. content | | `marketing` | required | required | marketing isolation build | | `sdk-boundary` | required | required | Codefly SDK boundary and contracts | @@ -441,6 +443,7 @@ reverse, fails the `release-contract` job. | `authz-coverage` | the generated authorization catalog: RBAC coverage, audit coverage, and permission no-broadening against `main` | `node module/tools/authz-coverage-gate.mjs`, plus `go test` for the gateway header-lockstep and adapter enforcement tests | | `release-contract` | this gating graph itself, and the Dependabot configuration that feeds it | `node --test scripts/ci/release-gates.test.mjs`, `node --test scripts/ci/dependabot-coverage.test.mjs`, `node scripts/ci/release-gates.mjs check`, `node scripts/ci/dependabot-coverage.mjs check` | | `docs-sync` | the generated interface docs and the story-trace tests (#516) | `node module/tools/interface-docs-gate.mjs check`, `node module/tools/story-trace-gate.mjs tests` | +| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request | `bash scripts/ci/host-interface-conformance-main.sh`, which re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | | `kit-version` | the published frontend kit's version moves whenever its content does, since a registry version is immutable once served | `node --test scripts/ci/kit-version.test.mjs`, `node scripts/ci/kit-version.mjs check` | | `marketing` | the marketing runtime builds in isolation from the app, and the public config is current | `node module/tools/generate-public-config.mjs --check`, `node module/tools/marketing-extraction.mjs` | | `sdk-boundary` | the root module's own tests, the Codefly SDK boundary, single-invocation protocol generation, the exported API contract, and the published module-authority client against the real host | `go test ./...` **in the root module only**, `go test codefly_sdk_boundary_test.go`, `codefly generate contracts saas-starter --check`, `codefly generate runnables saas-starter --check`, `go test ./...` in `qualification/module-authority` | diff --git a/configurations/local/policy-log.env b/configurations/local/policy-log.env deleted file mode 120000 index da2b30d35..000000000 --- a/configurations/local/policy-log.env +++ /dev/null @@ -1 +0,0 @@ -../../module/configurations/local/policy-log.env \ No newline at end of file diff --git a/configurations/local/policy-log.secret.env b/configurations/local/policy-log.secret.env deleted file mode 120000 index 64d494965..000000000 --- a/configurations/local/policy-log.secret.env +++ /dev/null @@ -1 +0,0 @@ -../../module/configurations/local/policy-log.secret.env \ No newline at end of file diff --git a/libraries/source-read-sdk/library.codefly.yaml b/libraries/source-read-sdk/library.codefly.yaml index 9d75d47c3..2711a1cc3 100644 --- a/libraries/source-read-sdk/library.codefly.yaml +++ b/libraries/source-read-sdk/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: authority - contract-digest: sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3 + contract-digest: sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3 services: - ModuleCapabilitiesService generated-by: diff --git a/module/AGENTS.md b/module/AGENTS.md index a0f7b30c1..71ec6338b 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -59,8 +59,8 @@ adding a REST-enabled RPC means editing it. Do not go looking for a generator. ## Go suites -Every Go service here carries **its own `go.mod`** — `accounts`, `auth-gateway`, -`store` and `policy-log` — and `tools` is another, with **no `go.work`** tying them together. So +Every Go service here carries **its own `go.mod`** — `accounts`, `auth-gateway` +and `store` — and `tools` is another, with **no `go.work`** tying them together. So `go test ./...` only ever covers the module you run it in, and a service's suite is outside whatever you run from the tree root. The canonical repository adds its own root module (the module agent, the host, and the generated reference diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index 7e72bddbc..969a5a33c 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -525,8 +525,8 @@ executable inventory and this table in the same change. | Scope | Relations | Required database boundary | |---|---|---| -| `global` | `audit_event_types`, `bootstrap_state`, `data_retention_policies`, `datasource_credential_budgets`, `email_templates`, `feature_flags`, `hop_admission_keys`, `identity_providers`, `module_envelopes`, `plan_entitlements`, `plans`, `platform_admins`, `policy_log_commits`, `policy_log_cursor`, `solution_delivery_documents`, `solution_generation_history`, `solution_host_bindings`, `solution_registrations`, `solution_targets` | No RLS; exact grants | -| `tenant` | `actor_chain_journal`, `actor_chain_revocations`, `api_keys`, `approval_decisions`, `approval_requests`, `audit_event_idempotency`, `audit_events`, `connector_credentials`, `dashboards`, `datasource_account_links`, `datasource_domains`, `datasource_group_bindings`, `codefly_effect_receipts`, `datasource_operation_attempts`, `datasource_oauth_consumed_states`, `datasource_source_operations`, `datasource_sources`, `delegation_grants`, `domain_events`, `entitlement_overrides`, `executable_artifact_approvals`, `github_app_installations`, `github_app_setups`, `installations`, `invitations`, `membership_integrity_findings`, `module_delegations`, `org_generic_settings`, `org_identity_providers`, `org_key_bindings`, `org_settings`, `organization_activations`, `organization_authorization_revisions`, `organization_members`, `organizations`, `principal_authorization_revisions`, `principals`, `record_shares`, `role_assignments`, `role_permissions`, `roles`, `scope_grants`, `scope_nodes`, `source_delegations`, `source_read_revisions`, `subscriptions`, `team_members`, `team_membership_quarantine`, `teams`, `usage_events`, `usage_totals`, `webhook_deliveries`, `webhook_subscriptions`, `work_context_replay` | Enabled and forced RLS with at least one policy | +| `global` | `audit_event_types`, `authority_core_delegations`, `authority_core_grant_epochs`, `authority_core_installations`, `authority_core_module_revisions`, `authority_core_modules`, `authority_core_owner_aware`, `authority_core_person_epochs`, `authority_core_replay`, `authority_core_role_grants`, `authority_core_tombstones`, `bootstrap_state`, `data_retention_policies`, `datasource_credential_budgets`, `email_templates`, `feature_flags`, `hop_admission_keys`, `hop_admission_proofs`, `identity_providers`, `module_envelopes`, `plan_entitlements`, `plans`, `platform_admins`, `solution_delivery_documents`, `solution_generation_history`, `solution_host_bindings`, `solution_registrations`, `solution_targets` | No RLS; exact grants | +| `tenant` | `actor_chain_journal`, `actor_chain_revocations`, `api_keys`, `approval_decisions`, `approval_requests`, `audit_event_idempotency`, `audit_events`, `connector_credentials`, `dashboards`, `datasource_account_links`, `datasource_domains`, `datasource_group_bindings`, `codefly_effect_receipts`, `datasource_operation_attempts`, `datasource_oauth_consumed_states`, `datasource_source_operations`, `datasource_sources`, `delegation_grants`, `domain_events`, `entitlement_overrides`, `executable_artifact_approvals`, `github_app_installations`, `github_app_setups`, `installations`, `invitations`, `membership_integrity_findings`, `org_generic_settings`, `org_identity_providers`, `org_key_bindings`, `org_settings`, `organization_activations`, `organization_authorization_revisions`, `organization_members`, `organizations`, `principal_authorization_revisions`, `principals`, `record_shares`, `role_assignments`, `role_permissions`, `roles`, `scope_grants`, `scope_nodes`, `source_delegations`, `source_read_revisions`, `subscriptions`, `team_members`, `team_membership_quarantine`, `teams`, `usage_events`, `usage_totals`, `webhook_deliveries`, `webhook_subscriptions`, `work_context_replay` | Enabled and forced RLS with at least one policy | | `user` | `client_authorization_codes`, `gdpr_requests`, `mfa_backup_codes`, `mfa_devices`, `mfa_login_transactions`, `notifications`, `onboarding_progress`, `resource_follows`, `sessions`, `user_consent_events`, `user_consent_preferences`, `user_identities`, `users`, `webauthn_ceremonies`, `webauthn_credentials` | Enabled and forced RLS with at least one policy | | `pre_auth` | `magic_links`, `waitlist_entries` | Enabled and forced RLS; fail-closed request policy, accessed only by the control-plane role | | `job` | `job_messages` | Enabled and forced RLS with at least one policy; no request relation grant — function-only scoped enqueue plus exact job-worker grants | @@ -1044,124 +1044,46 @@ creation/activity timestamps, idle expiry, and absolute expiry are unchanged. This lock order serializes switch-versus-refresh races without treating a stale access-token session id as refresh replay. -## The policy log, and why it is not in this database - -Every narrowing of authority — a revocation, a tenancy change, a tightened -envelope — and every **authorised regrant** is appended to an **external** -append-only log before it takes effect here, and the host keeps only the -receipt. - -The reason it cannot live in this database is the whole point. A transactional -audit event plus an append-only table beside the state they describe are -**backed up with that state**, so restoring the database restores revoked -authority together with the history that was supposed to witness against it. A -history that is restored alongside what it witnesses cannot witness. - -**The protocol is append → receipt → commit-with-receipt**, and the ordering is -load-bearing in one direction: - -| failure | state afterwards | why it is safe | -| --- | --- | --- | -| the append fails | nothing narrowed, caller refused | authority was not reduced and nobody was told it was | -| the append lands, the commit fails | **the host stops serving** | the log says the narrowing happened; serving the wider authority still held locally would be serving authority that was revoked | -| the commit lands without an append | **impossible** | the append comes first and its receipt is required by the commit | - -Two relations hold the local half. `policy_log_commits` is one row per -operation — the operation id is the **caller's** idempotency key, because only -the caller knows two attempts are the same revocation — carrying the receipt and -a `committed_at` that is NULL exactly while the gap is open. `policy_log_cursor` -is a single row recording how far this host has reconciled the log and when it -last reached it. Neither is deletable: a receipt is the evidence that an append -happened, so deleting one would make an unreconciled gap **disappear** rather -than be closed, which is the one way the fail-closed asymmetry could be defeated -locally. - -**Serving is gated on two conditions, checked on the serving path and not only at -startup.** A gap opens at runtime — a commit that failed after its append landed -— so a startup-only check would serve through exactly the window the protocol -exists for. The host refuses when it holds an unapplied logged operation, and -when it has not reached the log within a bounded staleness window: a host that -cannot read the log does not know whether its authority is current, and serving -what it last believed is serving authority that may have been revoked since. - -The staleness window is deliberately not zero. A log round trip per request -would make the log's availability the host's own, and a momentary blip would -become an outage. It is deliberately not unbounded either, because then "I -cannot reach the log" would never become "I must stop serving". - -**The append is bounded, and the bound is about locks rather than latency.** It -happens before the narrowing's transaction opens, so a slow log cannot hold -database locks — but a caller blocked indefinitely still holds its request and -everything above it. The bound converts that into a refusal the caller can act -on, and a narrowing that cannot be logged must not proceed. - -**Reconciliation reports gaps; it does not decide authority from the log.** -Applying an entry means re-running the narrowing it describes, which is domain -logic — a reconciler that re-derived authority from log payloads would be a -second implementation of every narrowing, and the copy is what drifts. - -### What goes through it, and what the host does without it - -**Four narrowings route through the protocol**, each appending before it applies -and committing its receipt in the transaction that applies it: revoking an -installation (`UninstallSolution`), closing a withdrawn solution target — which -revokes every installation naming it — revoking a hierarchical scope grant, and -removing a team membership. Installing, granting and adding are not narrowings -and do not appear in the log; an authorised **regrant** does, because a log that -recorded only reductions could not tell "this authority was restored" from "it -was never taken away". - -That transaction is a **control-plane** one even for an organisation-scoped -narrowing, because `policy_log_commits` is control-plane only and the receipt and -the narrowing must be the same transaction or "applied but unwitnessed" becomes -reachable. The tenant policy that would otherwise have confined each statement is -replaced by an explicit predicate at the call site: three of the four already -name their organisation in the statement, and the fourth — a team membership, -whose delete is keyed on (team, user) and carries no organisation column — -re-reads the team's owning organisation on the same transaction and refuses a -mismatch. - -The **serving gate runs on the request path**, in both transports' authorization -interceptors, after authorization so the refusal reaches only callers the host -would have served. Its answer is reused for a bounded window rather than -recomputed per request — the gate is a control-plane read — and the window hides -nothing: a gap this host opens itself drops the cached answer at the instant the -append is recorded, and a gap another replica opened arrives through -reconciliation, whose interval is longer than the window. A host that cannot -evaluate the gate refuses, because "I could not check" must not be served as -"there is nothing to find". The refusal is **Unavailable and never -PermissionDenied**: the caller is not unauthorized, the host is unable, and a -host answering "forbidden" would tell every caller their authority had been -revoked when nothing of theirs had. - -### The independent policy-log service - -The transport owner is the **[policy-log service](services/policy-log/README.md); -the host wires its client**. Its witness assigns a global sequence and returns -a signed receipt only after an immutable entry and a generation-guarded object-store -head have committed in the warehouse's trust domain. Accounts holds neither -that store's write authority nor the signing key. Composition delivers -`policy_log_public_key.json` under the host's trust-anchor directory. - -The warehouse remains a best-effort mirror, never the source of the receipt. -A BigQuery writer gets no global ordinal from streaming inserts or per-stream -offsets, cannot wait for ingestion visibility within the five-second bound, -and deliberately holds no read or job-creation permission to recover its row. -Those limitations are why the receipt issuer is a separate service rather than -an accounts adapter that manufactures its own evidence. - -**This checkout does not yet have a runnable log transport.** The new service's -witness, storage, signing-key loader, mirror, and private service declaration are -implemented; protobuf and OpenAPI generation and the RPC listener remain blocked -by the required generator prerequisite. Its README records that boundary and -the remaining real-GCS qualification. The host's Go code and client wiring are -unchanged, so this source addition must not be read as a running deployment or -as completion of the policy-log condition. - -Until the transport and host client are wired, the existing host still serves -normally and refuses the four narrowings above. Once wired, inability to reach -the log expires the serving gate's freshness window and refuses service; an -appended operation without a local commit remains a gap until reconciled. +## Narrowings apply directly + +A narrowing of authority — removing a team or organization member, revoking a +role, scope grant, share, API key, principal, delegation or platform role, +narrowing or uninstalling an installation, closing a withdrawn solution target, +lowering the module envelope — applies **directly**, and its audit record +commits in the same transaction (`business/narrowing.go`). No external witness +is asked first (owner decision, 2026-10-27): a narrowing that waited on a log a +host does not run could not be performed at all, which left access that could +never be removed. Widening paths are unchanged. + +The transaction is a **control-plane** one, as it was under the former witness. +The tenant policy that would otherwise have confined each statement is +replaced by an explicit predicate at the call site: each narrowing names its +organisation in the statement, and a team membership — whose delete is keyed on +(team, user) and carries no organisation column — re-reads the team's owning +organisation on the same transaction and refuses a mismatch. + +Migration 57 drops the former witness's local relations (`policy_log_commits`, +`policy_log_cursor`); nothing reads or writes them. + +## The authority core's installations + +The authority core (migration 56) keeps the installations of solutions in +`authority_core_installations`. A module's installation is not there: it is +accounts' own `installations` row of kind `module`, an administrator's install +with consent, within the module envelope, revoked by an uninstall or by a +ceiling that no longer allows the organization, all in accounts' own +transactions. The core reads those rows live as its installations and installs +no module itself. Its one write to a module's installation, raising its +revision when a redelivery narrows the module, is kept beside the row in +`authority_core_module_revisions`, because `installations.revision` is +lifecycle-owned. The revision the core reads also adds the committed +envelope revision, so an envelope write ends every capability decided under +the ceiling it replaced. + +The hop's admission keys (migration 55) are pinned one per hop instance +(`hop_admission_keys.instance`, migration 58), and each registration proof's +nonce is kept in `hop_admission_proofs` until it could no longer be judged +fresh, so a proof is used once across replicas. ## Control-plane boundary diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 5e700a935..8362f5bc5 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -31,13 +31,6 @@ added elsewhere does not touch it. Read them from the assertions. The accounts descriptor catalog is an input: if its RPCs use gRPC, Connect, or REST without a corresponding accounts endpoint, generation fails. -`policy-log` is in the inventory with one private `grpc` endpoint and appears in -NO row of the service graph below, which is correct rather than an omission: it -declares no dependency on anything, and the host's client for it is not built -yet, so there is no edge to draw. When that client lands it adds an -`accounts` → `policy-log/grpc` row and nothing else — the witness must never -depend on the database whose authority it witnesses. - ## Service graph | Caller | Target endpoints | Kubernetes ports | diff --git a/module/FEATURES.md b/module/FEATURES.md index a86df8589..8cd3c0dc9 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -717,7 +717,7 @@ _All previously-open gaps closed 2026-04-25._ - ✅ **Cache invalidation on member-remove** — confirmed correct on a closer read. `CacheInvalidator.InvalidateMembership` calls `cache.Delete` against shared Redis, so all api instances see the change immediately. The 30s TTL is safety net, not staleness window. - ✅ **OAuth `state` not validated server-side** — added `auth.OAuthStateSigner` (HMAC-SHA256, key derived from the JWT private key with a domain label, 10-min TTL). `BeginOAuth(provider, redirect_uri) → state` mints a server-signed token bound to provider + redirect URI. `Authenticate` requires and re-verifies it; mismatch returns the canonical `ErrInvalidOAuthState` without an oracle. The frontend fails closed if state cannot be minted. At ingress, frontend derives the exact callback origin from the real request and authenticates it to auth-gateway with Codefly's internal service credential; auth-gateway carries it across the gateway-token boundary. `IDENTITY_ALLOWED_REDIRECT_URIS` is only an optional fallback for intentional direct access. - ✅ **PKCE for OAuth code flow** — FE now generates a 64-byte `code_verifier` per sign-in, computes SHA-256 `code_challenge`, and includes both in the authorize URL. `code_verifier` rides through `Authenticate` to `Exchanger.Exchange`, which forwards it as the standard `code_verifier` form parameter to the provider's token endpoint. Belt-and-suspenders alongside the existing `client_secret` flow — recommended even for confidential clients per OAuth 2.1. -- ✅ **Every refusal names its reason, in the response and the log** — handlers return `status.Error(codes.X, ...)` (gRPC); `translateGRPCError` in the Connect adapter maps the code to its Connect equivalent and carries the status's own message and details, so the wire message is the reason (`mfa_required`, "seats quota exhausted: used 5 of 5"), never grpc-go's `rpc error: code = … desc = …` rendering. An error that is not a status goes through one seam (`refusalStatus`, `pkg/adapters/refusals.go`): a known business refusal travels under its code with its own sentence — a duplicate pending invitation or an existing member as `AlreadyExists`, an insufficient platform role as `PermissionDenied`, a missing justification as `InvalidArgument`, a precondition (administrator continuity, an inactive impersonation target, no email delivery, no verified public origin to build a link from, an unreachable policy log) as `FailedPrecondition`, a quota as `ResourceExhausted` — and anything else is `Internal` with a fixed sentence, its chain (a constraint name, a query, a call path) withheld from the caller. One interceptor on the Connect chain and on each gRPC server logs every refused call — procedure, code, the reason sent and, for a fault, the withheld cause; DEBUG for an unauthenticated or rate-limited call and the auth interceptor's own revocation-list outage (so an anonymous flood is no WARN stream), WARN for any other refusal by the caller, ERROR for the host's fault; the trace id stamped from the request's span; no request body. The portal's `refusalMessage` shows the host's sentence for `InvalidArgument`, `NotFound`, `AlreadyExists`, `PermissionDenied`, `FailedPrecondition`, `ResourceExhausted` and `Unauthenticated`, answers `mfa_required` with the passkey step-up, and keeps the screen's own fallback for every other code; `refusalToast` uses it. +- ✅ **Every refusal names its reason, in the response and the log** — handlers return `status.Error(codes.X, ...)` (gRPC); `translateGRPCError` in the Connect adapter maps the code to its Connect equivalent and carries the status's own message and details, so the wire message is the reason (`mfa_required`, "seats quota exhausted: used 5 of 5"), never grpc-go's `rpc error: code = … desc = …` rendering. An error that is not a status goes through one seam (`refusalStatus`, `pkg/adapters/refusals.go`): a known business refusal travels under its code with its own sentence — a duplicate pending invitation or an existing member as `AlreadyExists`, an insufficient platform role as `PermissionDenied`, a missing justification as `InvalidArgument`, a precondition (administrator continuity, an inactive impersonation target, no email delivery, no verified public origin to build a link from) as `FailedPrecondition`, a quota as `ResourceExhausted` — and anything else is `Internal` with a fixed sentence, its chain (a constraint name, a query, a call path) withheld from the caller. One interceptor on the Connect chain and on each gRPC server logs every refused call — procedure, code, the reason sent and, for a fault, the withheld cause; DEBUG for an unauthenticated or rate-limited call and the auth interceptor's own revocation-list outage (so an anonymous flood is no WARN stream), WARN for any other refusal by the caller, ERROR for the host's fault; the trace id stamped from the request's span; no request body. The portal's `refusalMessage` shows the host's sentence for `InvalidArgument`, `NotFound`, `AlreadyExists`, `PermissionDenied`, `FailedPrecondition`, `ResourceExhausted` and `Unauthenticated`, answers `mfa_required` with the passkey step-up, and keeps the screen's own fallback for every other code; `refusalToast` uses it. - ✅ **Rate limiting per org / API key** — `cache.RateLimiter` (Redis-backed fixed-window) + Connect/gRPC interceptors. Key derivation: API-key id > org id > user id. Default 1000 req/min, configurable. Returns `ResourceExhausted` (gRPC 8 / HTTP 429) with `Retry-After` and `X-RateLimit-*` headers; nil-receiver / nil-cache / zero-limit all degrade to allow-all so an unconfigured Redis can't mass-reject. 5 unit tests cover budget exhaustion, per-key isolation, and graceful degradation. - ✅ **Cmd-K command palette** — global cmd/ctrl+K dialog at the dashboard root. Its generated navigation projection is shared with the sidebar/plugin registry and role-gated (admin / super_admin / personal); async user search uses Connect-ES (200ms debounce, super_admin only — server-gated, not just UI). E2E specs cover hotkey, role visibility, and navigation. Built on the existing cmdk + shadcn primitives (zero new deps). diff --git a/module/MODULE_INSTALLATION.md b/module/MODULE_INSTALLATION.md index f0ab08c73..7ea74e652 100644 --- a/module/MODULE_INSTALLATION.md +++ b/module/MODULE_INSTALLATION.md @@ -504,8 +504,8 @@ that caused it. The derived role carries its provenance in `roles.derived_from`: the installation, the audience, and the chosen role. **How it is revoked, with its cause.** `RevokeInstallationFromTeam` revokes every -derived grant of the grant it removes, first and in the same policy-logged -narrowing. `UninstallSolution` revokes every derived grant whose cause is a grant +derived grant of the grant it removes, first and in the same narrowing +transaction. `UninstallSolution` revokes every derived grant whose cause is a grant at that installation's authority root. Both go through `revokeScopeApply`, so each carries the same `scope.revoked` event and audit record as any other revocation. `derived_from_grant_id` is `ON DELETE CASCADE`, which closes the paths that go diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 11fa4cd4f..b6b9e4d3f 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -213,8 +213,8 @@ Four distinctions the projections keep: and a grant never lands on an installation already uninstalled. The write is `GrantScope`'s own body, so it emits the same `scope.granted` event and audit record. `RevokeInstallationFromTeam` - is `RevokeScope` after the same resolution: a narrowing under the policy log, - refused in the host's words when the log cannot witness it. + is `RevokeScope` after the same resolution: a narrowing, applied directly + with its audit record in one transaction. `ListOrganizationInstallations` is the administrator's read of the same state — each active installation with its health, installed target, authority-root path and the teams granted there or inherited from above. It is org-admin and bound to @@ -523,9 +523,8 @@ generation's widening waits for that organisation's administrator. or below a revision a platform administrator wrote interactively it is refused (409) naming that revision, and the composition must state the change and deliver above it. Revisions need not be contiguous. -6. **Write** it through the writer the interactive path uses: a narrowing is - witnessed in the policy log (refused, writing nothing, on a host with none), - installations the new ceiling no longer allows are revoked, the row and its +6. **Write** it through the writer the interactive path uses: a narrowing applies + directly, installations the new ceiling no longer allows are revoked, the row and its `saas.platform.module_envelope_delivered` audit event (actor `system`; revision, previous revision, principals, signer, ownership domain, digest) commit together, and a registry pass is requested. diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index cde7fa31e..d1157ec9a 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -139,12 +139,12 @@ and restoring the binding invalidates an already issued child. Missing or unavailable owner state cannot fall back to an ordinary exchange. Changing the mode of an existing binding is a policy restriction, including -adding it where the mode was previously absent. The existing witnessed policy -log path must authorize that envelope transition; adding a JSON field is not -an additive grant. An entirely new binding remains subject to the normal grant -and installation checks. A host without the configured policy-log witness -refuses a mode migration and leaves the stored envelope revision unchanged. -No local substitute witness or automatically inferred migration is supported. +adding it where the mode was previously absent: a platform administrator or a +signed delivery writes it as a new envelope revision through the one envelope +writer, and like every narrowing it applies directly, revoking what the new +ceiling no longer allows in the same transaction. Adding a JSON field is not an +additive grant. An entirely new binding remains subject to the normal grant and +installation checks. No automatically inferred migration is supported. **Installed module actors.** diff --git a/module/configurations/local/policy-log.env b/module/configurations/local/policy-log.env deleted file mode 100644 index ed2b4b8b5..000000000 --- a/module/configurations/local/policy-log.env +++ /dev/null @@ -1,14 +0,0 @@ -# The independent witness's own storage and mirror, which belong to the -# WAREHOUSE's trust domain rather than to the database whose authority they -# witness. A restore of the accounts database must not restore the witness, so -# none of these may ever point at anything accounts can write. -# -# Provisioning remains unwired in this local composition. Empty values refuse -# startup. A native composition must supply real independently retained custody -# through its owners; an Accounts-private store or synthetic receipt is invalid. -# -# A real deployment supplies these through Codefly's configuration provider, -# against a dedicated bucket whose IAM forbids deleting or replacing anything -# under `entries/`. -POLICY_LOG_BUCKET= -POLICY_LOG_WAREHOUSE_ENDPOINT= diff --git a/module/configurations/local/policy-log.secret.env b/module/configurations/local/policy-log.secret.env deleted file mode 100644 index e5473960b..000000000 --- a/module/configurations/local/policy-log.secret.env +++ /dev/null @@ -1,13 +0,0 @@ -# The witness's own warehouse credential. It is the witness's, not the host's: -# accounts must hold no warehouse writer capability at all, or a compromise of -# the database could rewrite the evidence that database's narrowings are -# witnessed by. -# -# A placeholder, never a usable value. Production supplies a unique, -# high-entropy token through Codefly's secret configuration provider, over -# HTTPS, with redirects refused. -POLICY_LOG_WAREHOUSE_TOKEN=local-dev-only-replace-me - -# Persistent witness identity, provisioned by the composition secret provider. -# Canonical standard base64 of PKCS#8 Ed25519 DER; no generated boot fallback. -POLICY_LOG_SIGNING_KEY=local-dev-only-replace-me diff --git a/module/contracts/accounts/saas.events.codefly.yaml b/module/contracts/accounts/saas.events.codefly.yaml index 239bd11f5..0ef82f641 100644 --- a/module/contracts/accounts/saas.events.codefly.yaml +++ b/module/contracts/accounts/saas.events.codefly.yaml @@ -87,6 +87,38 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.authority.installation_activated + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.installation_requested + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.installation_uninstalled + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.resource_attached + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.resource_detached + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.resource_transferred + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.role_granted + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d + - type: saas.authority.role_revoked + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.billing.checkout_started schema: saas/events/v1/events.proto#EventEnvelope visibility: external diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 150215b19..b81bda2fa 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/host/DIGESTS.sha256 b/module/contracts/api/accounts/authority/proto/host/DIGESTS.sha256 index 2cb4bfbfe..0b7594cea 100644 --- a/module/contracts/api/accounts/authority/proto/host/DIGESTS.sha256 +++ b/module/contracts/api/accounts/authority/proto/host/DIGESTS.sha256 @@ -1,5 +1,5 @@ -6f4ecb6d3493f29ca79dfcf3ccf8f9ec1cfa3bf8e56c70bcc2055339a6666d1c authority/v1/authority.proto +5f743acb2e21e23eb916092313f54b0737742fe73c77d3f43208987a4aabf4c4 authority/v1/authority.proto f1ea4a9113527f8ed972660c3d2134a39135e96cf212b780ec04d14321ddcab1 authority/v1/work_context.proto -187b07dd29d4d2f082bf5cd60f6d4801c124522a5e93725d4de8004d3368050e composition/v1/composition.proto -2bfc4fa02a2ca5d77908614e31b22da5c7e6ee18a43b58ce387f82806e56fe85 installation/v1/installation.proto +65353da82976f3f00d0a87792b93a723adbfc9deec48a5f7e337ff9526be53f5 composition/v1/composition.proto +d69c70528987ef59180b0ac683ac3c30de9b18077dacc3eece1342465953314e installation/v1/installation.proto 5d1e2f8fb2e7c3a7b8e027d8de23e26e53fa21212831e23fac492372ac53f28b session/v1/session.proto diff --git a/module/contracts/api/accounts/authority/proto/host/authority/v1/authority.proto b/module/contracts/api/accounts/authority/proto/host/authority/v1/authority.proto index 104828eaa..1cd66fd03 100644 --- a/module/contracts/api/accounts/authority/proto/host/authority/v1/authority.proto +++ b/module/contracts/api/accounts/authority/proto/host/authority/v1/authority.proto @@ -32,14 +32,36 @@ service AuthorityService { rpc Keys(KeysRequest) returns (KeysResponse); // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -306,6 +328,17 @@ message RegisterAdmissionKeyRequest { WorkloadCredential actor = 1; // The raw Ed25519 public key the hop signs admissions with: 32 bytes. bytes public_key = 2; + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. + string proof = 3; } // RegisterAdmissionKeyResponse is the key as Keys now publishes it. diff --git a/module/contracts/api/accounts/authority/proto/host/composition/v1/composition.proto b/module/contracts/api/accounts/authority/proto/host/composition/v1/composition.proto index a50859362..a834fd4c3 100644 --- a/module/contracts/api/accounts/authority/proto/host/composition/v1/composition.proto +++ b/module/contracts/api/accounts/authority/proto/host/composition/v1/composition.proto @@ -62,6 +62,11 @@ message AuthorityManifestV1 { // Core's manifest carries no roles, so they travel here until it does. A // module declares none; a module's delivery listing any is refused. repeated RoleV1 roles = 6; + // The module checks a resource's owner against an owner-selected scope + // (Claims.AllowsOwned): only an audience declaring it receives one, and a + // delivery withdrawing it refuses the owner-selected capabilities issued + // before. Every solution receiving its roles' scopes declares it. + bool owner_aware = 7; } // MethodPolicyV1 is what a call to one method must hold, as the module's @@ -112,7 +117,7 @@ message ModuleManifestV1 { PageV1 page = 7; // The roles a solution declares a person may hold on an installation of it, // each within what the solution serves or explicitly consumes through - // invoke/lookup bindings; one is named installer, held by whoever installs it. + // invoke/lookup bindings; one is named installer, held only by whom it is granted to. // Headless-only scopes cannot supply a role grant. A module declares none. repeated RoleV1 roles = 8; } @@ -121,6 +126,15 @@ message ModuleManifestV1 { message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; + // Scopes the role grants only on the resources the installation owns: those + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; } // BindingV1 is one declared call towards another module. diff --git a/module/contracts/api/accounts/authority/proto/host/installation/v1/installation.proto b/module/contracts/api/accounts/authority/proto/host/installation/v1/installation.proto index 9661643e9..c1ebefd92 100644 --- a/module/contracts/api/accounts/authority/proto/host/installation/v1/installation.proto +++ b/module/contracts/api/accounts/authority/proto/host/installation/v1/installation.proto @@ -1,24 +1,55 @@ syntax = "proto3"; // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package host.installation.v1; option go_package = "gateway/pkg/gen/host/installation/v1;installationv1"; // InstallationService is implemented by the host and by the local authority. service InstallationService { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + rpc Current(CurrentInstallationRequest) returns (CurrentInstallation); // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. rpc Install(InstallRequest) returns (Installation); - // GrantToTeam exposes an installation to a team under the installation's role. - rpc GrantToTeam(GrantToTeamRequest) returns (Installation); + // Review reads the current offer and installation revision without granting consent. + rpc Review(ReviewRequest) returns (ConsentApproval); + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + rpc GrantRole(GrantRoleRequest) returns (Installation); + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + rpc RevokeRole(RevokeRoleRequest) returns (Installation); + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + rpc ListRoleGrants(ListRoleGrantsRequest) returns (ListRoleGrantsResponse); // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. rpc List(ListRequest) returns (ListResponse); } +message CurrentInstallationRequest { + string parent_token = 1; + string actor_credential = 2; +} + +// CurrentInstallation is request-local metadata, never a lease or consent. +message CurrentInstallation { + string id = 1; + string organization_id = 2; + string target = 3; + uint64 revision = 4; + string namespace = 5; +} + // InstallableService is implemented by a solution; the host calls it once per // installation and retries until it is acknowledged. service InstallableService { @@ -26,6 +57,22 @@ service InstallableService { rpc Install(InstallationNotice) returns (InstallationAck); } +// OfferService is the local authority's setup read, served beside +// InstallationService and not part of codefly.dev/installation: what solutions +// this run delivered and, for one, what installing it would grant. A host +// answers its own product's reads. It grants nothing; Install, GrantRole and +// RevokeRole stay the only changes, each decided by the authority. +service OfferService { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + rpc ListOffers(ListOffersRequest) returns (ListOffersResponse); + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + rpc ReviewOffer(ReviewRequest) returns (ReviewedOffer); +} + // InstallRequest asks to install one target at one organisation. message InstallRequest { // The organisation installing. @@ -36,31 +83,81 @@ message InstallRequest { string role = 3; // The accountable person; an administrator of the organisation. string owner_person_id = 4; - // The exact catalogue offer and installation state the administrator approved. + // Required: the exact offer reviewed by the administrator; unchanged on retry. ConsentApproval approval = 5; } -// ConsentApproval binds an explicit approval to the state reviewed by the admin. -// Obtain target_id and generation from ListAvailableSolutions, and revision from -// the installation listing (zero means absent). Keep these values and operation_id -// unchanged on retry; a refusal requires a fresh review and operation. +// ReviewRequest names the offer an administrator intends to review. +message ReviewRequest { + string organization_id = 1; + string target = 2; +} + +// ConsentApproval binds consent to an immutable target, its reviewed delivery and +// the installation revision (zero if absent). Review leaves operation_id empty; +// the caller supplies a fresh UUID and preserves the entire request on retry. message ConsentApproval { string target_id = 1; uint64 generation = 2; uint64 expected_revision = 3; string operation_id = 4; + // sha256 digest of the reviewed authority offer. + string digest = 5; } -// GrantToTeamRequest exposes an installation to a team. -message GrantToTeamRequest { +// GrantRoleRequest grants one role to exactly one person or team. +message GrantRoleRequest { + // The organization the installation belongs to; a request naming another + // organization's installation is refused naming the organization, before + // the caller's authority is read. + string organization_id = 1; + // The installation. + string installation_id = 2; + // One of the roles the solution declares. + string role = 3; + // The person granted, a member of the organization; exactly one of + // person_id and team_id is set. + string person_id = 4; + // The team granted, a team of the organization. + string team_id = 5; +} + +// RevokeRoleRequest ends one grant, named as GrantRole named it. +message RevokeRoleRequest { + // The organization the installation belongs to. + string organization_id = 1; // The installation. - string installation_id = 1; - // The team granted the installation's role at its root. - string team_id = 2; - // The organisation the installation belongs to; a grant is an organisation - // administrator's act, and the host checks the actor against it before it - // reads the installation. Learned from the second implementation (the host). - string organization_id = 3; + string installation_id = 2; + // The role. + string role = 3; + // The person; exactly one of person_id and team_id is set. + string person_id = 4; + // The team. + string team_id = 5; +} + +// ListRoleGrantsRequest names one installation. +message ListRoleGrantsRequest { + // The organization the installation belongs to. + string organization_id = 1; + // The installation. + string installation_id = 2; +} + +// ListRoleGrantsResponse is every grant on the installation. +message ListRoleGrantsResponse { + // The grants. + repeated RoleGrant grants = 1; +} + +// RoleGrant is one role held by one person or team. +message RoleGrant { + // The role. + string role = 1; + // The person, when a person holds it. + string person_id = 2; + // The team, when a team holds it. + string team_id = 3; } // ListRequest asks for one organisation's installations. @@ -87,17 +184,22 @@ message Installation { Status status = 4; // Bumped on every change that narrows or widens the installation. uint64 revision = 5; - // The role the installation holds. + // The role the installation holds when it acts for itself (its agent's + // role); no person or team inherits it. string role = 6; - // The teams granted it. - repeated string team_ids = 7; - // The principal the installation acts as for itself. - string agent_principal_id = 8; + reserved 7, 8; + reserved "team_ids", "agent_principal_id"; // The namespace the installation's own authored content belongs to (an // agent's source): the host names it, so a module never chooses which source // an installation may run. A module installed by composition is // ".". string namespace = 9; + // The solution principal: the identity the installation's data is owned by + // and the installation acts as for itself, a stable UUID per organization + // and target, reused by a reinstall. Empty for a module installation. + string principal_id = 10; + // The roles the solution declares, sorted. + repeated string roles = 11; // Status is the installation's lifecycle. enum Status { @@ -120,3 +222,90 @@ message InstallationNotice { // InstallationAck acknowledges the install step. message InstallationAck {} + +// ListOffersRequest names the organization whose administrator reads. +message ListOffersRequest { + string organization_id = 1; +} + +// ListOffersResponse is every delivered solution, by name. +message ListOffersResponse { + repeated Offer offers = 1; +} + +// Offer is one delivered solution. +message Offer { + // The solution's name in the composition: Install's target. + string target = 1; + // The delivery generation an approval binds. + uint64 generation = 2; +} + +// ReviewedOffer is one solution's authority offer and the consent that names it. +message ReviewedOffer { + // What Install takes, with a fresh operation_id; read with the rest. + ConsentApproval approval = 1; + string target = 2; + // Each role the solution declares, the installer role included. Every role + // scope is confined to this installation: to what it owns, or to the + // resources attached to it. + repeated OfferRole roles = 3; + // The solution's own installation at the organization; absent before Install. + Installation installation = 4; + // The permission ceiling: each module the solution calls, and the most it + // may ask there. No role reaches beyond it. + repeated OfferBinding ceiling = 5; + // False when this run delivered no prepared Runnable bindings: no tool is + // shown, which says nothing about what a composed binding would expose. + bool tools_composed = 6; +} + +// OfferBinding is one declared call from the solution to a module. +message OfferBinding { + string binding = 1; + // The module called. + string module = 2; + repeated OfferScope scopes = 3; +} + +// OfferRole is one declared role and what a grant of it reaches. +message OfferRole { + string name = 1; + // The scopes the role declares. + repeated OfferScope scopes = 2; + // Each composed operation the role's scopes cover. + repeated OfferCapability capabilities = 3; +} + +// OfferScope is one declared scope. +message OfferScope { + string resource_kind = 1; + repeated string actions = 2; + // True when the scope reaches only the resources attached to the + // installation, listed in resource_ids (none until one is attached); false + // when it reaches every resource the installation owns. + bool attached_only = 3; + repeated string resource_ids = 4; +} + +// OfferCapability is one module operation a role's scopes cover. +message OfferCapability { + string module = 1; + // The operation as the module's authority document names it. + string operation = 2; + // What a call to it must hold. + repeated OfferScope requires = 3; + // The tools composed for this operation, from its prepared Runnable + // bindings; empty when no binding exposes it as a tool. + repeated OfferTool tools = 4; +} + +// OfferTool is one tool-exposed Runnable of an operation. +message OfferTool { + // The tool's name, as its exposure declares it. + string name = 1; + // The prepared binding a caller names to run it. + string binding = 2; + // True when the tool changes data. + bool write = 3; +} diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index d1ece4677..2457f333d 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -324,8 +324,8 @@ service InstallationService { }; } - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. rpc NarrowInstallation(NarrowInstallationRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED @@ -567,9 +567,8 @@ service InstallationService { } // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 150215b19..b81bda2fa 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/host/DIGESTS.sha256 b/module/contracts/api/accounts/connect/proto/host/DIGESTS.sha256 index 2cb4bfbfe..0b7594cea 100644 --- a/module/contracts/api/accounts/connect/proto/host/DIGESTS.sha256 +++ b/module/contracts/api/accounts/connect/proto/host/DIGESTS.sha256 @@ -1,5 +1,5 @@ -6f4ecb6d3493f29ca79dfcf3ccf8f9ec1cfa3bf8e56c70bcc2055339a6666d1c authority/v1/authority.proto +5f743acb2e21e23eb916092313f54b0737742fe73c77d3f43208987a4aabf4c4 authority/v1/authority.proto f1ea4a9113527f8ed972660c3d2134a39135e96cf212b780ec04d14321ddcab1 authority/v1/work_context.proto -187b07dd29d4d2f082bf5cd60f6d4801c124522a5e93725d4de8004d3368050e composition/v1/composition.proto -2bfc4fa02a2ca5d77908614e31b22da5c7e6ee18a43b58ce387f82806e56fe85 installation/v1/installation.proto +65353da82976f3f00d0a87792b93a723adbfc9deec48a5f7e337ff9526be53f5 composition/v1/composition.proto +d69c70528987ef59180b0ac683ac3c30de9b18077dacc3eece1342465953314e installation/v1/installation.proto 5d1e2f8fb2e7c3a7b8e027d8de23e26e53fa21212831e23fac492372ac53f28b session/v1/session.proto diff --git a/module/contracts/api/accounts/connect/proto/host/authority/v1/authority.proto b/module/contracts/api/accounts/connect/proto/host/authority/v1/authority.proto index 104828eaa..1cd66fd03 100644 --- a/module/contracts/api/accounts/connect/proto/host/authority/v1/authority.proto +++ b/module/contracts/api/accounts/connect/proto/host/authority/v1/authority.proto @@ -32,14 +32,36 @@ service AuthorityService { rpc Keys(KeysRequest) returns (KeysResponse); // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -306,6 +328,17 @@ message RegisterAdmissionKeyRequest { WorkloadCredential actor = 1; // The raw Ed25519 public key the hop signs admissions with: 32 bytes. bytes public_key = 2; + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. + string proof = 3; } // RegisterAdmissionKeyResponse is the key as Keys now publishes it. diff --git a/module/contracts/api/accounts/connect/proto/host/composition/v1/composition.proto b/module/contracts/api/accounts/connect/proto/host/composition/v1/composition.proto index a50859362..a834fd4c3 100644 --- a/module/contracts/api/accounts/connect/proto/host/composition/v1/composition.proto +++ b/module/contracts/api/accounts/connect/proto/host/composition/v1/composition.proto @@ -62,6 +62,11 @@ message AuthorityManifestV1 { // Core's manifest carries no roles, so they travel here until it does. A // module declares none; a module's delivery listing any is refused. repeated RoleV1 roles = 6; + // The module checks a resource's owner against an owner-selected scope + // (Claims.AllowsOwned): only an audience declaring it receives one, and a + // delivery withdrawing it refuses the owner-selected capabilities issued + // before. Every solution receiving its roles' scopes declares it. + bool owner_aware = 7; } // MethodPolicyV1 is what a call to one method must hold, as the module's @@ -112,7 +117,7 @@ message ModuleManifestV1 { PageV1 page = 7; // The roles a solution declares a person may hold on an installation of it, // each within what the solution serves or explicitly consumes through - // invoke/lookup bindings; one is named installer, held by whoever installs it. + // invoke/lookup bindings; one is named installer, held only by whom it is granted to. // Headless-only scopes cannot supply a role grant. A module declares none. repeated RoleV1 roles = 8; } @@ -121,6 +126,15 @@ message ModuleManifestV1 { message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; + // Scopes the role grants only on the resources the installation owns: those + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; } // BindingV1 is one declared call towards another module. diff --git a/module/contracts/api/accounts/connect/proto/host/installation/v1/installation.proto b/module/contracts/api/accounts/connect/proto/host/installation/v1/installation.proto index 9661643e9..c1ebefd92 100644 --- a/module/contracts/api/accounts/connect/proto/host/installation/v1/installation.proto +++ b/module/contracts/api/accounts/connect/proto/host/installation/v1/installation.proto @@ -1,24 +1,55 @@ syntax = "proto3"; // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package host.installation.v1; option go_package = "gateway/pkg/gen/host/installation/v1;installationv1"; // InstallationService is implemented by the host and by the local authority. service InstallationService { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + rpc Current(CurrentInstallationRequest) returns (CurrentInstallation); // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. rpc Install(InstallRequest) returns (Installation); - // GrantToTeam exposes an installation to a team under the installation's role. - rpc GrantToTeam(GrantToTeamRequest) returns (Installation); + // Review reads the current offer and installation revision without granting consent. + rpc Review(ReviewRequest) returns (ConsentApproval); + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + rpc GrantRole(GrantRoleRequest) returns (Installation); + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + rpc RevokeRole(RevokeRoleRequest) returns (Installation); + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + rpc ListRoleGrants(ListRoleGrantsRequest) returns (ListRoleGrantsResponse); // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. rpc List(ListRequest) returns (ListResponse); } +message CurrentInstallationRequest { + string parent_token = 1; + string actor_credential = 2; +} + +// CurrentInstallation is request-local metadata, never a lease or consent. +message CurrentInstallation { + string id = 1; + string organization_id = 2; + string target = 3; + uint64 revision = 4; + string namespace = 5; +} + // InstallableService is implemented by a solution; the host calls it once per // installation and retries until it is acknowledged. service InstallableService { @@ -26,6 +57,22 @@ service InstallableService { rpc Install(InstallationNotice) returns (InstallationAck); } +// OfferService is the local authority's setup read, served beside +// InstallationService and not part of codefly.dev/installation: what solutions +// this run delivered and, for one, what installing it would grant. A host +// answers its own product's reads. It grants nothing; Install, GrantRole and +// RevokeRole stay the only changes, each decided by the authority. +service OfferService { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + rpc ListOffers(ListOffersRequest) returns (ListOffersResponse); + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + rpc ReviewOffer(ReviewRequest) returns (ReviewedOffer); +} + // InstallRequest asks to install one target at one organisation. message InstallRequest { // The organisation installing. @@ -36,31 +83,81 @@ message InstallRequest { string role = 3; // The accountable person; an administrator of the organisation. string owner_person_id = 4; - // The exact catalogue offer and installation state the administrator approved. + // Required: the exact offer reviewed by the administrator; unchanged on retry. ConsentApproval approval = 5; } -// ConsentApproval binds an explicit approval to the state reviewed by the admin. -// Obtain target_id and generation from ListAvailableSolutions, and revision from -// the installation listing (zero means absent). Keep these values and operation_id -// unchanged on retry; a refusal requires a fresh review and operation. +// ReviewRequest names the offer an administrator intends to review. +message ReviewRequest { + string organization_id = 1; + string target = 2; +} + +// ConsentApproval binds consent to an immutable target, its reviewed delivery and +// the installation revision (zero if absent). Review leaves operation_id empty; +// the caller supplies a fresh UUID and preserves the entire request on retry. message ConsentApproval { string target_id = 1; uint64 generation = 2; uint64 expected_revision = 3; string operation_id = 4; + // sha256 digest of the reviewed authority offer. + string digest = 5; } -// GrantToTeamRequest exposes an installation to a team. -message GrantToTeamRequest { +// GrantRoleRequest grants one role to exactly one person or team. +message GrantRoleRequest { + // The organization the installation belongs to; a request naming another + // organization's installation is refused naming the organization, before + // the caller's authority is read. + string organization_id = 1; + // The installation. + string installation_id = 2; + // One of the roles the solution declares. + string role = 3; + // The person granted, a member of the organization; exactly one of + // person_id and team_id is set. + string person_id = 4; + // The team granted, a team of the organization. + string team_id = 5; +} + +// RevokeRoleRequest ends one grant, named as GrantRole named it. +message RevokeRoleRequest { + // The organization the installation belongs to. + string organization_id = 1; // The installation. - string installation_id = 1; - // The team granted the installation's role at its root. - string team_id = 2; - // The organisation the installation belongs to; a grant is an organisation - // administrator's act, and the host checks the actor against it before it - // reads the installation. Learned from the second implementation (the host). - string organization_id = 3; + string installation_id = 2; + // The role. + string role = 3; + // The person; exactly one of person_id and team_id is set. + string person_id = 4; + // The team. + string team_id = 5; +} + +// ListRoleGrantsRequest names one installation. +message ListRoleGrantsRequest { + // The organization the installation belongs to. + string organization_id = 1; + // The installation. + string installation_id = 2; +} + +// ListRoleGrantsResponse is every grant on the installation. +message ListRoleGrantsResponse { + // The grants. + repeated RoleGrant grants = 1; +} + +// RoleGrant is one role held by one person or team. +message RoleGrant { + // The role. + string role = 1; + // The person, when a person holds it. + string person_id = 2; + // The team, when a team holds it. + string team_id = 3; } // ListRequest asks for one organisation's installations. @@ -87,17 +184,22 @@ message Installation { Status status = 4; // Bumped on every change that narrows or widens the installation. uint64 revision = 5; - // The role the installation holds. + // The role the installation holds when it acts for itself (its agent's + // role); no person or team inherits it. string role = 6; - // The teams granted it. - repeated string team_ids = 7; - // The principal the installation acts as for itself. - string agent_principal_id = 8; + reserved 7, 8; + reserved "team_ids", "agent_principal_id"; // The namespace the installation's own authored content belongs to (an // agent's source): the host names it, so a module never chooses which source // an installation may run. A module installed by composition is // ".". string namespace = 9; + // The solution principal: the identity the installation's data is owned by + // and the installation acts as for itself, a stable UUID per organization + // and target, reused by a reinstall. Empty for a module installation. + string principal_id = 10; + // The roles the solution declares, sorted. + repeated string roles = 11; // Status is the installation's lifecycle. enum Status { @@ -120,3 +222,90 @@ message InstallationNotice { // InstallationAck acknowledges the install step. message InstallationAck {} + +// ListOffersRequest names the organization whose administrator reads. +message ListOffersRequest { + string organization_id = 1; +} + +// ListOffersResponse is every delivered solution, by name. +message ListOffersResponse { + repeated Offer offers = 1; +} + +// Offer is one delivered solution. +message Offer { + // The solution's name in the composition: Install's target. + string target = 1; + // The delivery generation an approval binds. + uint64 generation = 2; +} + +// ReviewedOffer is one solution's authority offer and the consent that names it. +message ReviewedOffer { + // What Install takes, with a fresh operation_id; read with the rest. + ConsentApproval approval = 1; + string target = 2; + // Each role the solution declares, the installer role included. Every role + // scope is confined to this installation: to what it owns, or to the + // resources attached to it. + repeated OfferRole roles = 3; + // The solution's own installation at the organization; absent before Install. + Installation installation = 4; + // The permission ceiling: each module the solution calls, and the most it + // may ask there. No role reaches beyond it. + repeated OfferBinding ceiling = 5; + // False when this run delivered no prepared Runnable bindings: no tool is + // shown, which says nothing about what a composed binding would expose. + bool tools_composed = 6; +} + +// OfferBinding is one declared call from the solution to a module. +message OfferBinding { + string binding = 1; + // The module called. + string module = 2; + repeated OfferScope scopes = 3; +} + +// OfferRole is one declared role and what a grant of it reaches. +message OfferRole { + string name = 1; + // The scopes the role declares. + repeated OfferScope scopes = 2; + // Each composed operation the role's scopes cover. + repeated OfferCapability capabilities = 3; +} + +// OfferScope is one declared scope. +message OfferScope { + string resource_kind = 1; + repeated string actions = 2; + // True when the scope reaches only the resources attached to the + // installation, listed in resource_ids (none until one is attached); false + // when it reaches every resource the installation owns. + bool attached_only = 3; + repeated string resource_ids = 4; +} + +// OfferCapability is one module operation a role's scopes cover. +message OfferCapability { + string module = 1; + // The operation as the module's authority document names it. + string operation = 2; + // What a call to it must hold. + repeated OfferScope requires = 3; + // The tools composed for this operation, from its prepared Runnable + // bindings; empty when no binding exposes it as a tool. + repeated OfferTool tools = 4; +} + +// OfferTool is one tool-exposed Runnable of an operation. +message OfferTool { + // The tool's name, as its exposure declares it. + string name = 1; + // The prepared binding a caller names to run it. + string binding = 2; + // True when the tool changes data. + bool write = 3; +} diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index d1ece4677..2457f333d 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -324,8 +324,8 @@ service InstallationService { }; } - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. rpc NarrowInstallation(NarrowInstallationRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED @@ -567,9 +567,8 @@ service InstallationService { } // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index b9b04a7cb..1aae64ef7 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3", + "digest": "sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3", "services": [ { "name": "ModuleCapabilitiesService", @@ -66,7 +66,7 @@ "kind": "protobuf", "package": "", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3", + "digest": "sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3", "services": [ { "name": "AuthorityService", @@ -96,9 +96,13 @@ "name": "InstallationService", "fullName": "host.installation.v1.InstallationService", "procedures": [ - "/host.installation.v1.InstallationService/GrantToTeam", + "/host.installation.v1.InstallationService/Current", + "/host.installation.v1.InstallationService/GrantRole", "/host.installation.v1.InstallationService/Install", - "/host.installation.v1.InstallationService/List" + "/host.installation.v1.InstallationService/List", + "/host.installation.v1.InstallationService/ListRoleGrants", + "/host.installation.v1.InstallationService/Review", + "/host.installation.v1.InstallationService/RevokeRole" ] }, { diff --git a/module/deployment/README.md b/module/deployment/README.md index 5ed721ad8..0481cdf03 100644 --- a/module/deployment/README.md +++ b/module/deployment/README.md @@ -385,7 +385,6 @@ services: - name: cache - name: frontend - name: marketing - - name: policy-log - name: store - name: telemetry # no `vault`: KEY_SERVICE_BACKEND=kms with diff --git a/module/deployment/generated/asyncapi.json b/module/deployment/generated/asyncapi.json index 9e74e8435..313fc2393 100644 --- a/module/deployment/generated/asyncapi.json +++ b/module/deployment/generated/asyncapi.json @@ -229,6 +229,94 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.authority.installation_activated": { + "address": "saas.authority.installation_activated", + "title": "saas.authority.installation_activated", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.installation_activated" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.installation_requested": { + "address": "saas.authority.installation_requested", + "title": "saas.authority.installation_requested", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.installation_requested" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.installation_uninstalled": { + "address": "saas.authority.installation_uninstalled", + "title": "saas.authority.installation_uninstalled", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.installation_uninstalled" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.resource_attached": { + "address": "saas.authority.resource_attached", + "title": "saas.authority.resource_attached", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.resource_attached" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.resource_detached": { + "address": "saas.authority.resource_detached", + "title": "saas.authority.resource_detached", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.resource_detached" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.resource_transferred": { + "address": "saas.authority.resource_transferred", + "title": "saas.authority.resource_transferred", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.resource_transferred" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.role_granted": { + "address": "saas.authority.role_granted", + "title": "saas.authority.role_granted", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.role_granted" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, + "saas.authority.role_revoked": { + "address": "saas.authority.role_revoked", + "title": "saas.authority.role_revoked", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.authority.role_revoked" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.billing.checkout_started": { "address": "saas.billing.checkout_started", "title": "saas.billing.checkout_started", @@ -2306,6 +2394,62 @@ }, "title": "saas publishes saas.auth.sso_jit_provisioned" }, + "send:saas.authority.installation_activated": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.installation_activated" + }, + "title": "saas publishes saas.authority.installation_activated" + }, + "send:saas.authority.installation_requested": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.installation_requested" + }, + "title": "saas publishes saas.authority.installation_requested" + }, + "send:saas.authority.installation_uninstalled": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.installation_uninstalled" + }, + "title": "saas publishes saas.authority.installation_uninstalled" + }, + "send:saas.authority.resource_attached": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.resource_attached" + }, + "title": "saas publishes saas.authority.resource_attached" + }, + "send:saas.authority.resource_detached": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.resource_detached" + }, + "title": "saas publishes saas.authority.resource_detached" + }, + "send:saas.authority.resource_transferred": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.resource_transferred" + }, + "title": "saas publishes saas.authority.resource_transferred" + }, + "send:saas.authority.role_granted": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.role_granted" + }, + "title": "saas publishes saas.authority.role_granted" + }, + "send:saas.authority.role_revoked": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.authority.role_revoked" + }, + "title": "saas publishes saas.authority.role_revoked" + }, "send:saas.billing.checkout_started": { "action": "send", "channel": { @@ -3674,6 +3818,62 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.authority.installation_activated": { + "name": "saas.authority.installation_activated", + "title": "saas.authority.installation_activated", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.installation_requested": { + "name": "saas.authority.installation_requested", + "title": "saas.authority.installation_requested", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.installation_uninstalled": { + "name": "saas.authority.installation_uninstalled", + "title": "saas.authority.installation_uninstalled", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.resource_attached": { + "name": "saas.authority.resource_attached", + "title": "saas.authority.resource_attached", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.resource_detached": { + "name": "saas.authority.resource_detached", + "title": "saas.authority.resource_detached", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.resource_transferred": { + "name": "saas.authority.resource_transferred", + "title": "saas.authority.resource_transferred", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.role_granted": { + "name": "saas.authority.role_granted", + "title": "saas.authority.role_granted", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, + "saas.authority.role_revoked": { + "name": "saas.authority.role_revoked", + "title": "saas.authority.role_revoked", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.billing.checkout_started": { "name": "saas.billing.checkout_started", "title": "saas.billing.checkout_started", diff --git a/module/deployment/generated/communication.md b/module/deployment/generated/communication.md index a085c9859..900100317 100644 --- a/module/deployment/generated/communication.md +++ b/module/deployment/generated/communication.md @@ -168,6 +168,70 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.authority.installation_activated + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.installation_requested + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.installation_uninstalled + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.resource_attached + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.resource_detached + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.resource_transferred + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.role_granted + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + +## saas.authority.role_revoked + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.billing.checkout_started - **Publisher:** saas diff --git a/module/deployment/generated/event-catalog.json b/module/deployment/generated/event-catalog.json index 962da759d..a893d768b 100644 --- a/module/deployment/generated/event-catalog.json +++ b/module/deployment/generated/event-catalog.json @@ -2004,6 +2004,806 @@ } ] }, + { + "type": "saas.authority.installation_activated", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.installation_requested", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.installation_uninstalled", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.resource_attached", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.resource_detached", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.resource_transferred", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.role_granted", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, + { + "type": "saas.authority.role_revoked", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.billing.checkout_started", "namespace": "saas", diff --git a/module/deployment/generated/service-topology.json b/module/deployment/generated/service-topology.json index 895e1cb00..7216daef9 100644 --- a/module/deployment/generated/service-topology.json +++ b/module/deployment/generated/service-topology.json @@ -136,17 +136,6 @@ } ] }, - { - "name": "policy-log", - "endpoints": [ - { - "name": "grpc", - "api": "CODEFLY_API_GRPC", - "visibility": "ENDPOINT_VISIBILITY_PRIVATE", - "port": 9090 - } - ] - }, { "name": "store", "endpoints": [ @@ -225,12 +214,6 @@ "ports": [ 443 ] - }, - { - "service": "policy-log", - "ports": [ - 443 - ] } ] } diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index 5eb1274d4..41914927e 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -24,9 +24,9 @@ interface: # contract the authority gateway implements for a local run. A # consumer depends on the interface, never on this service. implements: - - codefly.dev/authority@0.3.3 - - codefly.dev/composition@0.1.0 - - codefly.dev/installation@0.1.0 + - codefly.dev/authority@0.3.4 + - codefly.dev/composition@0.2.0 + - codefly.dev/installation@0.3.0 - codefly.dev/session@0.2.0 - service: auth-gateway endpoint: grpc @@ -49,6 +49,5 @@ services: - name: cache - name: frontend - name: marketing - - name: policy-log - name: store - name: vault diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index ce54aa4ca..6dbff0d98 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3 + digest: sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3 - endpoint: connect kind: protobuf package: "" path: contracts/api/accounts/connect/contract.binpb - digest: sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3 + digest: sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3 - name: auth-gateway endpoints: [grpc, rest] api-contracts: @@ -38,8 +38,6 @@ services: endpoints: [http] - name: marketing endpoints: [http] - - name: policy-log - endpoints: [grpc] - name: store endpoints: [tcp] - name: vault diff --git a/module/services/README.md b/module/services/README.md index 84e374b62..ad77f5d40 100644 --- a/module/services/README.md +++ b/module/services/README.md @@ -8,10 +8,7 @@ wrong, and nothing enforces it. `marketing` and `frontend` are separate Next.js applications: marketing owns public apex/`www` content, while frontend remains the authenticated product -behind `auth-gateway`. `policy-log` is the independent signed witness for -authority changes; it is private to this module, has no public endpoint, and -belongs to the warehouse's trust domain rather than to the database whose -narrowings it witnesses — see `policy-log/README.md`. +behind `auth-gateway`. Run the complete local dependency graph from `module/`: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 1eb966b38..a75265b4d 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -148,14 +148,12 @@ on the Connect listener and synchronously runs the existing reconciler. Its optional authority document is signed by the same pinned key and bound to the core manifest digest, module and generation. Both carriers persist in one inbox row (migration 49). An exact replay preserves policy; a rewritten pair is refused. -`AuthorityService/Check` reads and verifies only the applied pair and requires -whole-kind authority for whole-kind method requirements. A missing policy refuses -the named method. `HOST_INTERFACE.md` records conformance and remaining gaps. - -`InstallationService/GrantToTeam` uses the installation's standing role under the -existing grant transaction. A pending installation must be acknowledged first. -Root team-grant changes advance the installation revision through migration 50; -unchanged re-grants preserve it. Scope-grant audit is reused. + +Every host interface operation (authority, composition, installation) is decided +by the shared authority core of `codefly-dev/authority`, over this host's +Postgres (`pkg/infra/postgres_authority_core.go`, migration 56); what this host +adds is identity, the signing key, delivery, the review of workload credentials +and the audit record. [HOST_INTERFACE.md](./HOST_INTERFACE.md) describes it. ## The solution's runtime boundary is derived from its delivered binding diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index 5118fd06a..293a01efe 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -170,8 +170,8 @@ keep their prior subject intersection and credential shape. The host retains the explicit mode through manifest/envelope intersection. Missing target authority is a refusal or outage, never an ordinary fallback. -Mode changes on existing bindings require the existing policy-log witness; -a host without it refuses migration and preserves its current envelope. +A mode change on an existing binding is a narrowing written as a new envelope +revision; it applies directly, like every narrowing. See [Work Contexts](../../WORK_CONTEXTS.md#installed-delegated-audience-exchange) for the envelope field, terminal-lifetime rules and full exchange contract. diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 5558699bc..e8ed78bf8 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -2,27 +2,31 @@ > Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`. -Inventory: **289 RPCs** across **40 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **293 RPCs** across **40 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor. | Procedure | Shape | REST mapping | Tier | Guards | Permission / scope | Resource bindings | Audit | Idempotency / rate | Sensitivity req → res | Description | |---|---|---|---|---|---|---|---|---|---|---| -| `/host.authority.v1.AuthorityService/AttachResource` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Record that one resource belongs to an installation; refused by name until this host records installation-owned resources. | -| `/host.authority.v1.AuthorityService/AuthorizeTransfer` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Decide whether an existing resource may join an installed solution's principal; refused by name until this host records installation-owned resources. | +| `/host.authority.v1.AuthorityService/AttachResource` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.authority.resource_attached | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Record that one resource created through an installation belongs to it; the kind's serving module asks. | +| `/host.authority.v1.AuthorityService/AuthorizeTransfer` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.authority.resource_transferred | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Decide whether an existing resource may join an installed solution's principal, and record it. | | `/host.authority.v1.AuthorityService/Check` | unary | `POST /v1/authority:check` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Verify a presented Work Context for the audience the caller is, re-checking the revision and the seal live. | | `/host.authority.v1.AuthorityService/Delegate` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | SUCCESS: saas.module_delegation.granted | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | Record a signed-in person's grant to a module, within what they hold. | -| `/host.authority.v1.AuthorityService/DetachResource` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Release one resource from the installation that owns it; refused by name until this host records installation-owned resources. | +| `/host.authority.v1.AuthorityService/DetachResource` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.authority.resource_detached | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Release one deleted resource from the installation that owns it; the kind's serving module asks. | | `/host.authority.v1.AuthorityService/GetDelegation` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Read whether one delegation still stands, for its module or its grantor. | -| `/host.authority.v1.AuthorityService/Issue` | unary | `POST /v1/authority:issue` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER; impersonation=FORBIDDEN | — | — | SUCCESS: saas.work_context.task_started | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | Issue a Work Context for one owner shape, one audience and the scopes asked for, bounded by what the owner holds. | +| `/host.authority.v1.AuthorityService/Issue` | unary | `POST /v1/authority:issue` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER; impersonation=FORBIDDEN | — | — | SUCCESS: saas.work_context.task_started, saas.work_context.child_session_started | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | Issue a Work Context for one owner shape, one audience and the scopes asked for, bounded by what the owner holds. | | `/host.authority.v1.AuthorityService/Keys` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / STANDARD_READ | PUBLIC → PUBLIC | Return the issuer and the public keys a verifier pins: the Work Context keys and, apart from them, the hop's admission keys. | | `/host.authority.v1.AuthorityService/ListResources` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Answer which resources of one kind a checked capability covers for one action, from its owner's current grants. | | `/host.authority.v1.AuthorityService/RegisterAdmissionKey` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.platform.hop_admission_key_published | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Publish the hop's admission public key in Keys for one lease; only the hop's workload registers one. | | `/host.authority.v1.AuthorityService/RevokeDelegation` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | SUCCESS: saas.module_delegation.revoked | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | End a delegation; every capability issued from it is refused from then on. | | `/host.composition.v1.CompositionService/ApplyManifest` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Deliver one module instance's manifest at one generation; the ceiling is the manifest. | -| `/host.installation.v1.InstallationService/GrantToTeam` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | — | SUCCESS: saas.scope.granted | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Expose an installation to a team under the installation's role. | -| `/host.installation.v1.InstallationService/Install` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | — | SUCCESS: saas.installation.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Install one target at one organisation under the least role it needs; pending until its install step is acknowledged. | +| `/host.installation.v1.InstallationService/Current` | unary | `—` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | SECRET → INTERNAL | Read the authenticated module's own active installation in the tenant of a current parent addressed to it. | +| `/host.installation.v1.InstallationService/GrantRole` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | SUCCESS: saas.authority.role_granted | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Grant a person or a team one of an installed solution's roles. | +| `/host.installation.v1.InstallationService/Install` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | — | SUCCESS: saas.authority.installation_requested, saas.authority.installation_activated | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Install one solution at one organisation under the consent its administrator reviewed; pending until its install step converges. | | `/host.installation.v1.InstallationService/List` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List what one organisation has installed. | +| `/host.installation.v1.InstallationService/ListRoleGrants` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List who holds which role on one installation. | +| `/host.installation.v1.InstallationService/Review` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Read the exact authority offer an administrator consents to, without recording consent. | +| `/host.installation.v1.InstallationService/RevokeRole` | unary | `—` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | SUCCESS: saas.authority.role_revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Revoke one role grant; its subject's capabilities through the installation end. | | `/host.session.v1.SessionService/Current` | unary | `GET /v1/session` | `auth` | exposure=AUTHENTICATED; tenant=NONE; impersonation=FORBIDDEN | — | — | — | OPTIONAL / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Answer the session the caller's bearer belongs to, without the bearer. | | `/host.session.v1.SessionService/ListFixturePeople` | unary | `GET /v1/session/fixture-people` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | OPTIONAL / AUTHENTICATION | PUBLIC → SECRET | List the people fixture sign-in admits, in the fixture's order; none where it is not enabled. | | `/host.session.v1.SessionService/SignIn` | unary | `POST /v1/session:sign-in` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.auth.login | FORBIDDEN / AUTHENTICATION | SECRET → SECRET | Open a session for a fixture person by their fixture token. | @@ -116,7 +120,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.InstallationService/ListOrganizationInstallations` | unary | `GET /v1/installations` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List the caller's organization's active installations with health, installed target, authority root and the teams it is exposed to. | | `/saas.accounts.v1.InstallationService/NarrowInstallation` | unary | `POST /v1/installations/{installation_id}:narrow` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.narrowed | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Narrow an installation's scope, bump its revision and cancel the organization's affected work. | | `/saas.accounts.v1.InstallationService/RetryInstallation` | unary | `POST /v1/installations/{installation_id}:retry` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.retried | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Run a failed installation's install step again; the installation is pending until the solution acknowledges. | -| `/saas.accounts.v1.InstallationService/RevokeInstallationFromTeam` | unary | `POST /v1/installations/{installation_id}/teams/{team_id}:revoke` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.scope.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Remove a team's grant at an installation's authority root, under the policy log. | +| `/saas.accounts.v1.InstallationService/RevokeInstallationFromTeam` | unary | `POST /v1/installations/{installation_id}/teams/{team_id}:revoke` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.scope.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Remove a team's grant at an installation's authority root. | | `/saas.accounts.v1.InstallationService/TransferInstallationOwnership` | unary | `POST /v1/installations/{installation_id}:transferOwnership` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.ownership_transferred | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Reassign an installation's accountable owner of record. | | `/saas.accounts.v1.InstallationService/UninstallSolution` | unary | `POST /v1/installations/{installation_id}:uninstall` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Uninstall a solution and reverse its composition. | | `/saas.accounts.v1.IntrospectionService/GetServiceInfo` | unary | `GET /v1/.well-known/service-info` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / PUBLIC | PUBLIC → PUBLIC | Self-describing service catalog (this RPC). | @@ -300,10 +304,10 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit ## Tier totals -- `auth`: 47 +- `auth`: 50 - `internal`: 55 - `mfa`: 5 - `org_admin`: 68 - `org_member`: 57 - `platform_admin`: 29 -- `public`: 28 +- `public`: 29 diff --git a/module/services/accounts/HOST_INTERFACE.md b/module/services/accounts/HOST_INTERFACE.md index 2130d803b..93edac932 100644 --- a/module/services/accounts/HOST_INTERFACE.md +++ b/module/services/accounts/HOST_INTERFACE.md @@ -1,1333 +1,101 @@ # The host interfaces, served by accounts -**What we are doing.** accounts implements the four host interfaces declared in -the authority interface repository (`codefly.dev/authority`, `composition`, `installation`, `session`) -as adapters over what it already does, so the same contract the local authority -serves on a laptop is served here on a cell, and the authority interface repository's -`pkg/conformance` can prove the two agree. Rung 1 of the validation ladder. - -## What is on this branch - -- `proto/host/**`: the interface protos, copied unchanged from the authority interface repository; - `proto/buf.gen.yaml` generates them beside `saas` (`code/pkg/gen/host/**`). -- `module.codefly.yaml`: the `connect` endpoint declares `implements` for the four (moved from `authority` on 2026-10-25; see below). -- `code/pkg/business/interface_policies.go`: the host's method policies for the - interface procedures. The interface protos carry no `saas.policy` option on - purpose (policy is one implementation's guard, not the caller's promise), so the - policy lives here, keyed by full method, and `rpc_policy.go` reads it when a - descriptor carries none. Descriptions for the catalog live in the same file. - `InstallableService` is excluded: it is the solution's side. -- `code/pkg/adapters/host_interface.go`: the adapters. `Keys` and `Check` are the - Work Context authority's issuer, key and `verifyFor`. `Issue` maps by owner - shape: a person → `StartTask` for the bearer's own session; an installation → - `StartInstallationTask`; a workload → the headless operation-context mint - through a binding it declares, or its own module context - (`host_interface_workload.go`); a parent token with an actor → the hop - through the actor's binding, without one → `ExchangeAudience` at another - audience, or a renewal at the parent's own (`RenewWorkContext` for a - delegated parent, `StartRootSession` for an owner's own); a delegation → - Unimplemented, naming the delegation gap. `List` → `ListInstallations`. - `SignIn` → fixture `Authenticate`, returning the access token's session id. -- `code/pkg/adapters/connect_handlers_host_interface.go` + `connect_bindings.yaml`: - the Connect handlers and their bindings, in the shape every other service uses. - -## The gap list (red on this branch until each is closed) - -1. **Closed.** The service catalog carries one `api_package` (the service's own) and the `interface_packages` it implements beside it (`catalog.proto` field 9; `service_catalog.go`); the frontend catalog, the gateway route aliases and the mesh gates read the API package only. Four tests that hard-coded one package now read the served set. -2. **Closed.** `cmd/connect-registration` resolves a binding by full service name first and short name otherwise, and imports each implemented package's generated Go and Connect packages under derived aliases; `connect_bindings.yaml` keys the four interface services by full name. The authorization matrix counts services by full name. -3. **Closed.** Workload-owned `Issue` is served: the credential names the - module (`ModulePrefixOfWorkload`), and the issue is the headless - operation-context mint through a declared binding, narrowed to the asked - scopes, operation and lifetime, or the module's own context (see "Review r12 - repairs" below). -4. **Method-policy delivery closed (2026-10-25).** `ApplyManifest` accepts the - signed core carrier and its optional signed `AuthorityManifestV1` through - the tenant listener. The existing delivery owner authenticates the carrier, - persists the pair and reconciles it synchronously. `Check` enforces the - exact applied generation's method policy. Details and limits below. -5. **Install and GrantToTeam are served.** Install resolves the target and role - through the existing catalogues. Both human Install RPCs require `approval`: - the immutable `target_id` and `generation` offered by ListAvailableSolutions, - the installation's `expected_revision` from List (zero if absent), and a new - UUID `operation_id`. Keep the entire request unchanged on retry. The database - holds the applied binding and installation locks through the consent write; - `INSTALLATION_APPROVAL_GENERATION_CHANGED` and - `INSTALLATION_APPROVAL_REVISION_CHANGED` require a fresh review and approval. - A receipt returns the unchanged operation's installation while its resulting - revision is current; changed inputs refuse with - `INSTALLATION_APPROVAL_OPERATION_CONFLICT`. Each fresh receipt advances the revision, including unchanged consent and a - pending attempt it supersedes; the superseded job cannot acknowledge. Revoke - retains that review state, and reinstall starts above every prior revision - for the organization and immutable target. List follows every internal page - and includes revoked revisions; revoked rows carry no standing role or team - authority. An old request cannot reapprove delivered widening, undo narrowing, - or recreate its revoked installation. - Host-owned fixture setup resolves its own fresh approval through this same - transaction. No human RPC resolves approval fields for its caller. GrantToTeam resolves the installation's - standing role under its installation lock and uses `GrantInstallationToTeam`. - Pending installations remain unexposable until the durable install step is - acknowledged. The shared suite still needs fixture hooks for that acknowledgement - and the host-generated team id. -6. **Exposure audit reused.** The existing scope-grant transaction emits the - `saas.scope.granted` audit event; the interface policy declares that success - emission and adds no duplicate installation-created event. The interface - documentation groups authority and installation under authorization, session - under identity, and composition under platform administration. -7. **Closed 2026-10-22.** The interfaces carry the host's names (publisher `codefly.dev`, packages `host.*.v1`), so this repository carries no consumer name; the naming gate is at its main baseline. -8. **The conformance harness** needs accounts booted with its store (the test - Postgres each package persists) and the fixture people; the suite takes a - `grpc.ClientConnInterface`, so an in-process server over bufconn is enough once - registration works. - -9. **Closed.** The naming gate reported `generated/service-catalog.json` as "too large to scan": main's catalog was 1.4 KB under the 512 KiB cap and eight methods crossed it. The gate now skips that generated file for the reason it already skips descriptors: every string in it is a scanned proto identifier or a scanned Go description, restated in `AUTHZ_MATRIX.md`. Named here for review: a tool fix, not an allowlist entry. - -## How to run the suite against accounts (the harness, next) - -The suite is `github.com/codefly-dev/authority/libraries/conformance/go`: it links -no generated package (an implementation links its own; a second registration of -one proto file panics) and drives the interfaces from a published descriptor set -with dynamic messages over any gRPC connection. The harness here is one -integration test in `pkg/adapters`, shaped like `database_transport_integration_test.go` -and `readable_sources_integration_test.go`, which already boot the persisted test -store: - -1. boot the store and the service as `pkg/business/service_test.go` does - (`sdk.WithDependencies`, `infra.NewPostgresStore`, `business.NewService`), seed - the `dev-admin` fixture (`fixtures.Seed`), set the package-level `service`; -2. configure the Work Context singleton as production does - (`permission_servers.go:51`) against that store; -3. `NewGrpServer(&Configuration{})` and serve it over `bufconn`, so the interceptor - authenticates bearers; -4. sign in through the fixture provider (a fixture person's token is their - `provider_id`, `dev-admin` for the super administrator; the suite's - `AdminToken` and `ViewerToken` name two of them); -5. `conformance.Run(t, conn, descriptors, fixture)` with the descriptor set - `codefly generate contracts` writes for this service - (`module/contracts/api/accounts/authority/contract.binpb`, regenerated on this - branch so it carries the `host.*` services). - -Expected red on the first run: workload-owned `Issue`, `ApplyManifest`, `Install` -and `GrantToTeam`, which refuse by name (gaps 3 to 6), and the `List` assertion -that an installed solution exists, since the fixture installs none. Each red line -is a line of the gap list, which is the point. - -## The suite against accounts (2026-10-22) - -`HOST_INTERFACE_CONFORMANCE=1 go test ./pkg/adapters/ -run TestAccountsPassesTheHostInterfaceConformanceSuite` -boots the test store, seeds the dev-admin fixture, configures the Work Context -authority against the store, serves the Connect listener on an ephemeral port -and runs the suite over gRPC. The harness composes three modules: documents (the -audience the viewer reads at), org-directory (the page module, one declared -workload, one binding to documents) and model (an audience the viewer's context -is not addressed to); a fake cluster projects org-directory's credential. - -**Green (6 of 10):** Keys; sign-in and an unknown token refused by name; the -person line; every refusal with a reason; the workload line; the child hop. - -What it took, each a fix at the place that owns the behaviour: - -- *Composition installs a module at every organisation* (contract decision): - `compositiontest.ComposedModuleInstallations` (a harness stand-in, imported by tests alone) answers the live module authority and - the seal store for a composed module's installation, held by every member, with - the declared grant as its scope (the manifest is the ceiling); a control-plane - membership read, `WorkContextOrgMember`, sits beside the other seal reads. -- *An uncomposed audience is NotFound by name*: checked against the derived - registry through `ModulePrincipalID` before any mint. -- *Check is authenticated by the token it checks*: public, like Keys. -- *Issue is authenticated by the request's shape* (`business.HostIssueMethod`): - both interceptors let a call carrying no credential at all reach the adapter, - which refuses a bearer-less person by name; a bearer or a forwarded identity is - admitted as on every other method. -- *A workload is named by its credential alone*: `ModulePrefixOfWorkload` reviews - the credential and finds the one principal declaring that workload; the issue - is the host's operation context when a binding is named, the module's own - context otherwise; an undeclared binding is NotFound by name. -- *An operation context names its binding*: the seal source resolves - `//` (the spelling the interface states; it - was `module-operation//` until 2026-10-25) from the - installed grant and the operation-task mint seals it. Until now the token carried no binding, which is - what left runtime hops "stale" at the revision check. -- *A child hop by the parent token and the actor's credential*: `childHop` - verifies the parent as issued, names the actor from its credential, re-reads - the owner's authority as the owner, refuses widening as PermissionDenied and - derives the hop with no binding. -- *A hidden refusal names its reason*: `mapWorkContextError` logs the one case - the caller is told nothing. - -**Red (4 of 10):** - -| Red line | Gap | Proper fix | -|---|---|---| -| empty ask: `authority_scopes: must contain at least 1 item(s)` | **11** | an effective-permissions read in the host (person × audience → scopes); the adapter uses it when the ask is empty | -| `installed solution = ` | fixture | the dev-admin fixture gains an `installations` section performed through the real install path (owner decision); follows gap 4 | -| ApplyManifest: `internal RPC is not exposed on the tenant listener` | 4 | the delivery path behind the composition interface, on the internal listener; the harness dials it there | -| Install, GrantToTeam | 5 | name-to-id resolvers; not reached until the manifest applies | - -**Gap 14 (closed 2026-10-25, see "Gap 14: a hop through its binding" below):** a hop -kept the owner's seal installation and core requires a binding named on it to be -granted within that installation; a module's binding is granted within the -module's own installation. The child is now sealed to the installation its -binding is granted within. - -Suite changes today, each stating the contract more precisely and none passing a -red line: an authenticated caller for the audience probe; explicit scopes in base -cases with the empty ask as its own line; List as an administrator's read; -`Session.bearer`; the elsewhere audience as its own fixture field; the hop names no -binding until core decides. - -## The suite at 13 lines (2026-10-23 evening) - -The interface repository's suite (at `2e2bcf2`) now states the hop rule and the -delegate rule: a module hops only on a token addressed to it, names the binding it -exercises, and inherits from the owner, so a chain of modules can cross audiences. -On accounts: 5 pass (Keys, sign-in, the person line, refusals by reason, the -workload line), 3 skip because this harness states no permission matrix, no -delegate chain and no installed solution yet, 5 fail. - -The hop line's new first step, a person's token naming its module's binding, is -refused because the adapter sends no scopes for that shape; the host's -`withOperationBindingScopes` (#1219) is the person half once it inherits the -person's holdings rather than installation ids. The hop itself must then require -the parent addressed to the acting module and re-derive from the owner, as the -host's audience exchange already re-mints, so a chain crosses audiences. - - -## Current suite baseline (2026-10-25) - -On branch `authority-interface` at `61835c789`, updating only the shared -conformance dependency to `06d0d3b8567448b991e4a7ab591ccc8ab1c32681` gives -**5 passed, 7 failed, 7 skipped** across nineteen cases. The command is: - -```sh -HOST_INTERFACE_CONFORMANCE=1 GOPRIVATE=github.com/codefly-dev/* GOWORK=off go test -json ./pkg/adapters/ -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' -count=1 -``` - -Run from this service's `code` directory. It starts Accounts with its isolated -PostgreSQL test dependency, and makes no model-provider calls. - -| Outcome | Cases | -| --- | --- | -| Passed (5) | Keys; fixture sign-in; explicit person scopes and wrong-audience refusal; named refusal reasons; workload binding | -| Failed (7) | ListFixturePeople missing from copied descriptors; Current missing; empty scope ask; no installed-solution fixture; child-hop initial binding mint has empty scopes; ApplyManifest called on tenant listener; install/grant setup blocked by that delivery refusal | -| Skipped (7) | Permission matrix; stale-installation verification; delegate chain; method policy; resource grants; lookup scopes; durable delegation | - -A skip is not qualification. The stale-installation case performs preliminary -verification checks but skips before completing its revocation proof. The -existing thirteen-case result above remains historical evidence only. - -The harness directly supplies a composed module registry and synthetic -installations. It does not exercise production signed delivery, persisted -installation consent or concurrent revocation fences. Passing this harness -would not alone establish production equivalence. The manifest-as-ceiling -model remains part of the open host-interface proposal, not proof that the -production envelope contract has changed. - -The shared installation-issuance case currently presents a public installation -ID without authenticating the installation caller. That test and the reference -issuer need an authenticated caller contract; Accounts must preserve its existing -trusted installation-mint perimeter. The adapter also still needs to map the -requested installation and organization into that owner operation. - -The signed-carrier commit `e154aec500b8206848c825d2812629b4eb72a8c9` is already an -ancestor of this branch; no sibling cherry-pick is needed. - - -### Repairs following the current baseline - -Child issuance now resolves the actor from its workload credential before -verifying the parent through the existing `verifyFor` owner seam against that -actor's audience. A regression through `Issue` proves that a correctly addressed -parent issues, a parent addressed elsewhere does not, and the refused attempt -does not consume that parent's single-use capability. Restoring the previous -`verifyIssued` call reproduced the erroneous issuance. - -The SDK dependency process now has a lifetime separate from the service's -bounded context. Cleanup calls `Destroy` with a fresh bounded context before -canceling that lifetime and reports destruction errors. Previously the function's -deferred cancellation killed the SDK context before test cleanup, producing a -canceled destruction warning. The repeated real-store run no longer emits that -warning even though the service context is canceled before cleanup. - -These repairs do not close the other seven failures or seven skips. In -particular, Core reports the wrong-audience parent as `Unauthenticated`, while -the shared child-hop case expects `PermissionDenied`; that case is still blocked -earlier by its empty-scope/binding setup. Invalid tokens must not all be remapped -to permission errors to silence this mismatch. Full delegated exchange reuse, -named binding support and current interface generation remain open. - -## Session discovery preparation (2026-10-25) - -The authentication owners now provide the reads needed by the session adapter: -`devvalidator.Validator.FixtureTokens` returns a defensive, ordered copy of the -configured allowlist. Duplicate tokens retain the validator's existing last-wins -identity and appear only at the winning declaration's position. -`pgauth.Resolver.LookupIdentity` uses the same active-person lookup and organization -selection as login, within the existing database-enforced read-only transaction. -It does not create sessions, provision people, bootstrap an administrator or touch -login timestamps. It returns person/org/org-role/display facts only; it is not a -minting identity. - -Real PostgreSQL tests compare discovery with login for ambiguous memberships, -a persisted default newer than the oldest membership, and provider organization -precedence. They verify no session/bootstrap/membership/recency changes, and -refusal of missing or suspended people. Validator tests verify order, duplicate -credential semantics and defensive copies. - -These are preparatory owner capabilities, not completed SessionService endpoints. -`Current` and `ListFixturePeople` are still absent from the vendored descriptor; -the conformance baseline above remains 5 pass / 7 fail / 7 skip. Integration must -use only the enabled development validator, preserve real-provider and orgless -sessions, resolve teams through the existing member-filtered store, and prove -ended-session refusal through the actual authentication perimeter. The existing -MFA-challenge response must be handled explicitly, never bypassed. - -The proto-generation baseline is reproducible through Codefly after the template -selection fix, with its complete output retained. The catalog compilers are not -yet integrated into Codefly's generation lifecycle; see -[REST regeneration](../../REST_SURFACE.md#regeneration). That is an owner tooling -gap, not authorization to edit generated catalogs or run a substitute pipeline. -No composed browser journey, external identity provider or complete session -conformance is qualified by these tests. - -## Milestone 2: the edge and the hop against accounts (2026-10-25) - -What the authority's edge and hop call is now served on the declared endpoint: -`SessionService/SignIn`, `Current`, `ListFixturePeople`; `AuthorityService/Issue` -(a person's empty ask included), `Check` (with `method` and `advisory`); -`InstallationService/List`. The branch was merged with main first (no rebase). - -- **Protos at authority `06d0d3b`.** `proto/host/**` re-copied unchanged for the - four declared interfaces (`DIGESTS.sha256` re-recorded) and regenerated with - `codefly generate proto` and the repository's catalog step - ([REST regeneration](../../REST_SURFACE.md#regeneration)). `host/artifact/v1` - is not copied: no `interface.codefly.yaml` declares it. The interface's - `google.api.http` mappings are not on this host's REST surface: interface - procedures get Connect routes only, and the public OpenAPI drops their - operations (the gateway route compiler already did; the public OpenAPI - renderer now agrees instead of refusing). -- **`implements` is on `accounts/connect`.** The `authority` endpoint admits only - the internal module-authority procedures behind the internal credential - (`grpc_auth_interceptor.go`), so every interface method was refused there. - The Connect listener mounts all four services under their own policies. - `TestImplementedInterfacesAreDeclaredOnTheEndpointThatServesThem` holds the - declaration to the endpoint whose contract surface carries every method (it - fails with the declaration back on `authority`). -- **Gap 11 closed: an empty ask.** A person's `Issue` with no scopes is - everything they hold at the audience (`host_interface_holdings.go`). The - candidates are the kinds the audience module declares as content, each with - every action this host names on it (the composed permission vocabulary, the - organisation's roles, the composition's bindings to that audience). Each is - kept only if the person holds it by the check every mint makes - (`resolveAuthority` with the audience's content reads, the same check - `heldBindingScope` in #1219 makes for a named binding), and `StartTask` - decides the ask again. A person holding nothing is refused by name. - Reconciliation with #1219 (`start-task-operation-bindings`, open): a person's - ask that names an `operation_binding_id` is that PR's `operation_bindings`; - once it lands, the adapter forwards the binding to it, and `personHolds` - and `heldBindingScope` are one check that one of them should call. -- **`Current` and `ListFixturePeople`.** `Current` is an authenticated read of - the identity the perimeter verified (signature, window, the configured - revoker): session id, the effective subject, organisation, role (as `SignIn` - states it) and teams; no bearer back; a credential naming no session is - `Unauthenticated`. `ListFixturePeople` is - `business.Service.ListFixturePeople`: the development validator's ordered - allowlist (`FixtureTokens`), each projected through the resolver's read-only - `LookupIdentity` and the person's teams; empty, not refused, with no - development validator wired (every deployment that signs real people in), as - the interface states. A token whose person is not seeded is omitted. -- **Fixture people.** `dev-admin` now carries the authority fixture's people with - its tokens (maya, remi, alex, dev at Acme Corp; zoe, omar at a new Globex) and - teams. Their roles at composed modules are roles over those modules' - permissions, which this host cannot name (the boundary gate refuses them): - the composing workspace states them in its `fixture-grants.yaml` document - (roles and `assignments` in the fixture's own shape, by email beside - `agent_identifier`), which the seed adds to the selected fixture. -- **`List` names its target.** An installation's `target` is the presence's - route alias (`notes`), the name `Install` takes and a frontend binds to, not - this host's presence row id. -- **`Check` applies a named method.** No module has a delivered method policy on - this host (gap 4: no `AuthorityManifestV1` arrives), so the interface's rule - for that case applies: a check naming a method is refused by name - (PermissionDenied), and an advisory check answers the verified capability with - that refusal in `CheckResponse.refusal`. A hop must run - `hop/ENFORCEMENT=warn` against accounts until method-policy delivery lands; - that delivery (ApplyManifest carrying the authority document, on the internal - listener) is the next step. -- **Unserved, by name.** `ListResources`, `Delegate`, `RevokeDelegation`, - `GetDelegation` are classified and answer `Unimplemented` naming the gap. -- **The composition's modules.** `Issue`'s "not composed" check and every mint - read the registry the host derives from delivery (`IsDeclaredModulePrincipal`, - `ModulePrincipals`), the same one every module capability reads; nothing new - was added for it. A person's module installation is the production seal store - in a real run; the harness alone wires `compositiontest.ComposedModuleInstallations`. - -### The suite at `06d0d3b` against accounts - -`HOST_INTERFACE_CONFORMANCE=1 GOWORK=off go test -json ./pkg/adapters/ -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' -count=1`, -with the harness now stating a permission matrix and an in-memory revocation -list at the minter's revoker seam (production's is the cache's). **9 passed, 4 -failed, 6 skipped** of the nineteen suite cases, and the harness's own line -(an ended session is no longer current) passes. - -| Outcome | Case | Why | -| --- | --- | --- | -| pass | keys; fixture sign-in; every listed fixture person signs in; Current; person issued/checked/refused elsewhere; empty ask; permission matrix; refusals by reason; workload binding | | -| fail | an installation acts for itself | the fixture installs no solution (follows gap 4) | -| fail | a child hop names its actor and binding | the person's token naming a binding is #1219; the hop naming its binding is closed below (gap 14) | -| fail | a manifest moves only forward | ApplyManifest is internal and the harness dials the tenant listener (gap 4) | -| fail | install pending, then exposed | blocked by the same delivery refusal (gaps 4, 5) | -| skip | callee refuses what does not verify | needs an installation whose grant can move | -| skip | delegate chain; method policy; resources; lookup; delegation | the harness states no case: each needs a host capability above (gap 4, ListResources, delegation) | - -### Gap 14: a hop through its binding (2026-10-25) - -A module calling a module on its owner's behalf: `Issue` with a parent token, -the acting module's workload as `actor` and the binding it exercises. It was -refused (`Unimplemented`, "waits on core deciding where a cross-module binding -is held"). The owner's decision (child issuance **A**) is implemented in -`code/pkg/adapters/host_interface_hop.go`: - -- **Where the binding is held:** the module principal registry this host already - derives from delivery, as installed in the organisation - (`Service.ModuleOperationAudience`, the binding the delegated exchange - redeems). No second store. The child is sealed to the installation that binding - is granted within (the live answer for `//`), - so core's binding-within-installation rule holds by construction. -- **The child:** the owner's *current* grants at the binding's audience - (`personHolds`, the check every mint makes, per action of the binding's invoke - scopes, or its lookup scopes for `operation: lookup`), with the parent's - actor chain replayed and the acting module as the last hop naming the binding. - It descends from the parent's session (`parent_session_id`) and never outlives - the parent. An ask must sit inside that. -- **A narrowed parent stays narrowed:** for every kind the parent carries, in its - authority scopes or any hop's grant, the child is held to the least of them - (actions dropped and resources named stay so). The child carries those - narrowings in its own authority scopes, so a hop further down is held to them - too. A kind the parent never carried is no narrowing: that is how a chain - crosses into another audience. -- **Refused by name:** a binding the acting module does not declare (`NotFound`, - before the parent is verified, so a single-use parent is not spent); an - audience that is not the binding's; a parent addressed to another module - (`PermissionDenied`, "hops only on a token addressed to it", also before - verification); an owner holding none of the binding's scopes; an ask beyond - what the owner holds; an ask beyond what the parent narrowed to; a headless - operation; a context not owned by a person (this host reads current grants for - a person only). -- **Revision check:** `ConfirmModuleHopBinding` accepts the spelling this host - seals. The hop exercising it is confirmed against its binding; a module hop - replayed ahead of it carries the owner's authority through that binding and - stands while its module is installed. - -The harness now states a delegate chain (documents to model and back, for an -administrator) and a lookup case. **11 passed, 4 failed, 4 skipped** of the -nineteen suite cases at the same pin (`06d0d3b`), plus the harness's own line: - -| Outcome | Case | Why | -| --- | --- | --- | -| pass (new) | a delegate chain inherits the owner's authority at each audience | gap 14 | -| pass (new) | a lookup through a binding carries only the binding's lookup scopes | gap 14 | -| fail | a child hop names its actor and binding | first step: the person's token naming its module's binding is #1219 ("the person holds nothing at org-directory"); the binding id the case checks past it is the one this host now seals (below) | -| fail | an installation acts for itself | the fixture installs no solution (gap 4) | -| fail | a manifest moves only forward; install pending, then exposed | ApplyManifest is internal (gaps 4, 5) | -| skip | callee refuses what does not verify; method policy; resources; delegation | unchanged | - -### One spelling, one hop rule (2026-10-25) - -- **The sealed binding id is the interface's:** `//` - (`ModuleOperationBindingID`), for a workload's operation context and for a - hop's child alike. The `module-operation//` spelling is - deleted, not accepted beside it: the seal source, the operation-context shape - check and `ConfirmModuleHopBinding` read only the new one, and an id naming - another organisation confirms nothing. The organisation is a canonical UUID, - which is what tells this id apart from a source delegation's and an installed - binding's. A revision check naming the binding by its bare key is unchanged. -- **A hop names its binding:** a child naming no binding is refused - (`PermissionDenied`, "a module's hop names the binding it exercises"), before - the parent is verified. The interface's old shrink-from-parent child is - deleted; a module hop has one rule, the one above. -- **`ExchangeDelegatedOperationAudience` stays, for now.** It is the other - cross-module rule (strict attenuation of a parent through an installed - binding, plus the source-delegation reference arm). Composed modules still - call it, on their authority branches and their main branches alike, through - the Go SDK's module-authority client (`ExchangeOperation`). Deleting it waits - on those callers moving to the interface's hop. - -The suite is unchanged by these at **11 passed, 4 failed, 4 skipped**. - -### What was not verified - -No composed run: a composition that runs saas as the authority host, a -playground and the edge/hop against a live accounts were not started. The fixture people's sign-in and teams were proven -through the seeded harness store, not a `codefly run`. `codefly generate -contracts saas-starter --check` fails on this branch, before and after this -change: the CLI refuses a contract endpoint whose proto declares services in -more than one package (`cli cmd/generate/contracts.go:518`), so the exported -contracts do not yet carry the `host.*` services; that is a CLI gap. The -frontend fixture schema change was checked against the fixture with zod -directly, not through the frontend's vitest suite (no installed dependencies). - -## Fixture installations (2026-10-25) - -A fresh local run used to come up with nothing installed: each organisation's -administrator installed the solution and each module by hand through the install -API. A fixture now declares its installations (`installations:` with `org`, -`target`, `role`, `owner`), in the fixture file or in the composing workspace's -`fixture-grants.yaml`, since a target's name and a module's role are the -composition's words. The seed holds each owner to being an owner or admin of -the organisation in the fixture. Targets arrive by delivery after the seed, so -the declared installations are performed after every delivery pass -(`SolutionHostBindingReconciler.AfterEachPass`), as their owner, through -`InstallTargetByName`: an undelivered target waits, a held one (any status but -revoked) is reused, and any other refusal is reported with the pass, naming the -installation. A host with no delivery surface says it installs nothing. -Team grants are not declared yet (GrantToTeam, gap 5). - -## Signed method-policy delivery (2026-10-25) - -Classification: fixes at the Accounts delivery, authorization and installation -owners. Reused Core signature/admission checks, the existing carrier authorizer, -append-only delivery inbox, binding reconciler, Work Context scope containment, -and scoped-grant transaction. No second registry, issuer or grant table. - -- `ApplyManifest` is callable on the declared Connect endpoint. Its authentication - is the signed carrier plus the delivering module's workload credential. An - unsigned sketch is refused. The raw interface credential is adapted to the - existing carrier authorizer's Bearer-header input. HTTP delivery retains its - existing refusal classification, including unavailable identity review. -- The authority document uses the same proven trust pins and signer as its core - manifest and must name its module, generation and digest. The authority - library owns DSSE verification and method-policy vocabulary validation. - Duplicate or contradictory method policies and resource-specific requirements - are refused before anything is persisted. -- Migration 49 stores authority document and envelope in the same immutable inbox - row as the core carrier. Exact replay is allowed. Adding, replacing or dropping - policy at that same generation is refused. A newer desired receipt cannot - change the policy of the applied generation. Apply acknowledges only the - generation the existing reconciler actually applied. -- `Check` re-verifies the applied pair with current pins. Unknown methods are - `NotFound`; absent policies, missing required scopes and ambiguous HTTP - templates are named refusals. Method requirements check kind and action - eligibility; resource-limited grants pass unchanged, and the module enforces - their exact resource limits. Connect POSTs use the gRPC method policy; - HTTP templates support the interface's segment placeholders, literal custom - verbs and final `{path=**}` catch-alls. Advisory - checks return the same reason beside the verified capability. -- Installation responses project the existing role, revision and team grants. - Migration 50 routes root-team exposure changes through the installation's - existing revision owner; unchanged re-grants do not bump it. Grant and uninstall - serialize under an UPDATE lock, avoiding a SHARE-to-UPDATE upgrade race. - Existing scope-grant audit is the audit for exposure. -- The delegated-parent regression now declares and approves the operation binding - it mints before testing the person-only exchange refusal. Its previous failure - was at mint setup, before either exchange was exercised. - -The signed conformance harness now persists its approval revision and delivers -its fixture modules through the real inbox and reconciler. On conformance pin -`06d0d3b8567448b991e4a7ab591ccc8ab1c32681`, the latest run is **13 passed, -3 failed, 3 skipped** among the nineteen shared cases, plus the passing local -ended-session case. Method-policy enforcement and forward-only manifest delivery -pass. The failures are installation-owned issuance (no installation fixture), -the person's binding mint (PR #1219), and installation exposure: the shared case -never acknowledges the pending installation and hard-codes team id `finance`. -The host's direct PostgreSQL test proves pending refusal, acknowledgement, -exposure with the actual generated team id, one revision bump, unchanged replay, -and foreign-organization refusal. Resource grants, durable delegation and -installation-revocation conformance remain skipped. - -Before the catalog repair described below, `module/tools` failed only -`TestServiceCatalogProceduresAppearInAPIContractCatalog` -for the fourteen host procedures. The earlier CLI contract exporter rejected the Connect -surface's five proto packages in `cmd/generate/contracts.go`'s package-selection -function (`proto declares services in multiple packages ... a contract endpoint -must be single-package`). The owner fix described below retains the endpoint's explicit -service surface across packages in its descriptor and contract catalog, with a -regression using an owned API plus implemented interfaces. No generated contract -was hand-edited or partially retained. - -Generation used the corrected CLI candidate from PR #1128 and the documented -repository catalog chain. The installed CLI ignored template input selection; -that entire invalid baseline was discarded before rerunning the corrected CLI. -The clean-input catalog check also found `email` before `egress` in Accounts' -authored manifest; ordering those inputs restored generation. Complete generated -output is retained. The control-plane call-site inventory is regenerated by its -own documented test generator: new reads are the applied signed policy and the -installation target projection; delivery's transaction moved into its shared -receiver. - -No composed deployment, live edge/hop ENFORCE run, frontend journey or release was -qualified. The shared method-policy test exercises enforce refusals against real -Accounts. Existing envelope and installation-consent ceilings still govern -issuance; storing authority scope ceilings and solution roles does not replace -those existing owners or project new roles into the organization catalogue. - -The fresh-store blocker recorded here is resolved by the manifest-ceiling change -below: CLI composition delivery needs no administrative envelope. Installation -consent still governs organization access. A live composed ENFORCE run remains -unverified. - -Validation: Accounts adapters, business, infra and cataloggen suites pass; -`go vet ./...`, the naming gate and the boundary gate pass. At that checkpoint, `module/tools` -had only the contract-catalog failure described above, now repaired below. The full requested -`commit-identity-gate.mjs check origin/main HEAD` remains red on 38 identity -fields in commits already on this branch, including `86267c97`, `ab8577b3` -and `435e80b0`. Repairing those commits requires rewriting published history; -this task permits fast-forward pushes only. New work uses the requested -GitHub no-reply author and committer identity. - -### HTTP solution policies and local refusal diagnosis - -Codefly CLI `608561e2b` publishes `x-codefly-authority-policy` on each HTTP -operation. Its `MethodPolicies` reader turns these into `MethodPolicyV1` rows -named `VERB /path`, in the same signed `AuthorityManifestV1` that carries RPC -policies from `authority.hop.v1.policy`. Accounts stores and checks both kinds -through the existing signed-pair owner; it does not parse OpenAPI independently. -The HTTP matcher follows the reference authority's route grammar, including -`{id}:publish` and a final `{path=**}`. An empty capture, wrong verb, unknown -route, undeclared policy, missing scope or ambiguous template is refused. - -The Accounts conformance harness adds a signed **solution** delivery through the -tenant listener, then calls `Issue` and `Check` over that listener. It exercises -a scoped HTTP read, a multi-segment checked-caller route, a write refused for -missing authority, an unmarked route and unknown/wrong-verb routes. These local -cases complement the shared suite's signed RPC-policy case. -The full rerun remains **13 passed / 3 failed / 3 skipped** in the shared suite, -plus **2 passed** local cases (signed HTTP policy and ended session). This is -host-listener qualification, not a live composed hop running in ENFORCE mode. - -Two other live refusals are separate from policy storage: - -- `audience ... declares no content` comes from `ModuleContentResources` reading - the derived principal's `Resources`. In the inspected local composition, the - affected envelope entry declared only its tenant, and Accounts logged that - the manifest requested no authority and the envelope stood whole. The - composition must declare the intended content resources and the module must - publish its authority contract; method policies cannot manufacture consent. -- The Accounts log contains 21 `workload credential refused` replies between - 12:42 and 12:45 on 2026-10-25. An earlier 12:27:32 delivery was refused because - a gRPC endpoint address was not an HTTP(S) origin, and later calls still - reported that audience as uncomposed. However, `ModulePrefixOfWorkload` - returns token-review or workload-ownership errors without logging their - underlying cause; the adapter exposes a fixed refusal. The log therefore - does **not** prove which failure caused those 21 replies. Runtime credential - expiry or rotation has not been established as the cause. - -## Solution roles, ListResources and installation issuance (2026-10-25) - -Classification: fixes at the issuance, installation and store owners. Reused: -the applied signed authority document (the reader `CheckDeliveredMethod` -already used, now `appliedAuthorityDocument`), the installation read -(`ReadHostInstallation`), team membership, the derived-role seam, the headless -installation mint and the rule-A hop. No second registry, role table or issuer. - -- **Solution roles decide what a person holds through a solution.** - `business.PersonSolutionRoles` reads the delivered roles: installer at the - installation the person owns, and the installation's standing role through - each team it is exposed to that they belong to, bounded by the delivered - scope ceilings on the solution's own kinds and by its bindings' invoke and - lookup scopes on the kinds it consumes. A person's `Issue` at such a - solution is that grant on its own kinds. A hop with a solution on its path - carries those roles within the binding (with a solution audience's own grant - met in), and a parent issued at a solution stands while its roles still grant - what it carries. Organisation roles answer only where no solution on the - path declares roles; owner, admin and member keep administering the - organisation. -- **Install takes the solution's role names.** The installation's standing - role is a host-derived role per organisation, solution and role, outside the - catalogue; `GrantScope` admits it only at a solution's authority-root node in - its organisation, and `List` reads the solution's role name back. A fixture - installation names its `teams:`, granted through `GrantToTeam` once the - installation admits it. A built-in role whose name equals a solution role - (an installation made before this change with `viewer`) reads as that role. -- **ListResources is served.** It reads the capability back without spending - it, requires the action on the kind, and answers from the owner's grants now: - solution roles where a solution on the path declares them; otherwise every - resource for an organisation-wide grant, or the ids of grants limited to one - resource (`WorkContextScopedResourceIDs`). Ids the capability names are cut to - those. Content-read standing is not enumerated; the per-collection reads that - own it still answer it. -- **Installation issuance verifies the installation.** The actor's workload - credential must name the installed solution, the organisation is held to the - solution's pinned tenant, and the installation must be that solution's own - active installation there; each refusal is named before any mint. - -The shared contract now admits resource-limited scopes by kind and action. -Accounts preserves those limits in Check; modules enforce exact resources and -can ask `ListResources` for the owner's current grant intersection. - -The shared suite at its pin (`06d0d3b`) stays at **13 passed, 3 failed, -3 skipped**, plus the 2 local cases. Install with the solution's own role -`viewer` now succeeds (it failed on the standing grant before `GrantScope` -admitted solution roles); the case still fails later on the unacknowledged -pending installation. The installation-issuance cases are not exercised here: -the pinned case issues with no actor credential, which the interface now -refuses, and the newer suite's case (authority `3925878` onwards) needs a -pin move and a harness installation of a solution with its workload. Unit -tests in `pkg/adapters` cover issuance, roles and `ListResources`; no composed -run was made. - -## Review r12 repairs (2026-10-25) - -Review r12 of #1217 found one blocker and four majors in the interface -adapters; each is fixed where the behaviour lives, with a test that fails -without it. - -- **`Check` holds a method to the actor, not the owner.** A named method is held - to the call's effective scopes (the last hop's grant, the set `ListResources` - already used), so an actor delegated `read` by an owner holding `read` and - `write` no longer passes a method requiring `write` - (`TestCheckHoldsAMethodToWhatTheActorWasDelegated`). -- **A workload's issue is what it asked for, within its binding.** The ceiling is - the installed binding's headless grant, or for `lookup` the binding's lookup - scopes within it. An empty ask is the whole ceiling; a scope outside it, - `invoke`, an undeclared lookup, an audience other than the binding's and a - negative lifetime are refused by name; a lifetime only shortens (60 s ceiling - for an operation context, 15 min for the module's own). The narrowed scopes - are what the issuance audit records, and they stay within the headless grant - the revision check confirms. -- **Exchange and renewal through `Issue`.** The organisation is forwarded (the - request's, or the parent's own); an empty exchange ask is the parent's - effective scopes; at the parent's own audience the issue is a renewal. -- **A workload's own context is reachable.** Every shape is authenticated first - (bearer, workload or actor credential), and only then is its audience held to - the composition, so `module-capabilities` is reached and an uncomposed - audience is still NotFound by name, while a bearer-less caller learns nothing - of which modules are composed. -- **Minor repairs.** A hop from a parent narrowed to nothing of a kind is refused - (core signs no such parent today; the rule is held at the unit). A refused - workload or actor credential names its classified cause. The hop runs the - audience guards every mint runs, and no binding may be addressed to - `module-capabilities`. A resource question no longer reports a registered - scope node of another kind as an id of the asked kind - (`TestScopedResourceIDsNameOnlyResourcesOfTheAskedKind`); scoped assignments - are still read as exact ids, as the mint reads them, not expanded as subtrees. - An applied authority document is verified once per binding generation - (`TestAnAppliedAuthorityDocumentIsVerifiedOncePerGeneration`), and an - unparsable applied row no longer fails every other binding's answer. - -The shared suite, run as "Current suite baseline" states, gives **15 passed, 3 -failed, 3 skipped** of 21 cases, the same as the branch before these repairs: -the installation case (its fixture names no installed solution audience), the -child hop (the person holds nothing under the `documents` binding in this -harness) and install/grant (the installation is still pending) fail before -reaching anything changed here. - -## Combined integration on 2026-10-25 - -The installation REST registration (#1185), source-operation declaration fence -(#1188), and person operation-binding mint (#1219) are merged here with their -original commits and authorship. The REST routes remain product APIs: the host -Connect installation interface does not replace them. The source fence refuses -an admitted declaration that changed before replay or provider dispatch. - -The host person adapter now passes `operation_binding_id` to StartTask's -`operation_bindings`, without first expanding an empty ask into unrelated audience -holdings. StartTask's schema requires scopes or binding names; the existing -resolver expands names under its authority span. A binding-only request is valid, -an empty request is refused, and a person's binding mint accepts invoke only. -The regression mints the installed source scope, including its resource ID, and -has the module redeem that parent. Missing personal authority remains a named -refusal. The separate solution-role mint does not yet project named bindings and -now refuses that shape explicitly instead of silently ignoring the binding. - -The shared suite remains **13 passed / 3 failed / 3 skipped**, plus **2 passing -local cases**. Forward-only manifests, signed RPC policies and signed HTTP -solution policies pass. The three remaining shared failures are: - -- Installation-owned issuance: this harness still supplies no installed-solution - fixture or its workload credential. -- Child hop: the initial mint now reaches the binding resolver and refuses the - viewer by name. The resolver retains a scope only when all its actions are - held at the parent audience; the fixture's combined invoke/read binding does - not project the person's read holding at the target audience. The next fix - belongs in that binding/holdings owner, without widening the fixture grants. -- Pending installation exposure: the shared fixture never acknowledges the - installation and uses the literal team ID `finance`. The host-generated team - and acknowledgement hooks are still required at the conformance owner; the - existing PostgreSQL test covers pending refusal, acknowledgement and exposure. - -The fourteen missing host procedures in the published API catalog are fixed at -the CLI exporter, source `8de0f6674550fb040f03bc6cb2530116878c8c6b` in CLI #1128. -Complete contract, runnable and SDK regeneration retains the explicit served -surface across packages. The client-service guard now resolves full service names -across that surface and still checks imported-service leaks. Root/tools Core pins -validate the new format; service pins are unchanged, and release tags still -refuse the source CLI candidate. The SDK candidate is 0.3.37. - -The newer installation-identity and solution-role commits on the shared branch -are retained. Their moved control-plane read is recorded by regenerating the -existing call-site inventory. All generated conflicts were regenerated through -their owners, including after rebasing; no generated conflict was resolved by -selecting individual output lines. These are owner integration fixes, reusing -existing REST registration, delivery, source dispatch, binding resolution and -minting. They add no parallel policy or authorization store. - -Validation of the combined tree: Accounts adapters, business, infra, -permissionsplugin and cataloggen pass; the source-operation and person-binding -race tests pass; `module/tools` passes, including the formerly failing API -catalog test. Accounts/tools vet and the SDK's 107 tests and build pass. The -boundary gate caught consumer-specific names in a newly integrated role test; -those authored fixture names are generic now, and the boundary gate passes. -No live composed ENFORCE run or release is qualified by these checks. - -## Fresh-store manifest ceiling (2026-10-25) - -The composition path now derives module principals from applied signed manifests -without requiring an administrative envelope. Existing signature, workload-owner, -immutable generation and installation-consent checks remain in force. Changing -an authority document at the same generation is refused. Every authority decision -also fences the registry against current applied generations, so a replica cannot -use a grant from before a narrowing or withdrawal. - -`TestFreshStoreDerivesManifestAuthorityAndIssuesAPerson` boots an empty store, -delivers signed module and solution policies through the production owners, -asserts the derived registry and answers a person's Issue and HTTP Check. It -also refuses a changed authority document at the same generation. It runs by -default, independently of the opt-in shared conformance suite. - -**A widening needs the organisation's consent.** With no envelope a module -installation records the consented snapshot of the ceiling it was installed -under (`consentedInstallationScope`), not `Follows`, so a later generation that -widens the manifest reaches no organisation that installed an earlier one until -its administrator installs again; a narrowing still reaches it. -`TestAManifestWideningWithNoEnvelopeDoesNotReachExistingInstallations` derives -generations 1 and 2 through the real pass and holds an existing installation to -generation 1's read. Where an envelope is written, a module installation keeps -following it. An installation that already follows its grant holds nothing on -a host with no envelope (`ErrModuleInstallationConsentRequired`, answered -`FAILED_PRECONDITION` with reason `INSTALLATION_CONSENT_REQUIRED`) until its -administrator installs again; `TestAFollowingInstallationNeedsConsentWithNoEnvelope` -covers it. - -Reader inventory before deletion: `MODULE_PRINCIPALS` has no runtime reader and -was already removed. `MODULE_AUTHORITY_ENFORCEMENT` was read by `work.go` and -consumed by registry derivation; both the switch and its warn implementation are -removed. `PutModuleEnvelope` remains because the administration UI, its RPC -adapter, signed-envelope delivery, installation scope and revocation, retained -bindings and registry revision fences still use that surface. Where one exists, -its strict restrictions remain; this change does not retire that separate -administrative contract or migrate its installed delegation modes. - -## Composition responses belong to one binding - -`ApplyManifest` is served **in-cluster only**: the composition's deliverer calls -accounts' Connect listener directly with its module's `delivery` credential, -and the gateway never routes it (`business.ServedInClusterOnly`), exactly as the -HTTP delivery route is kept off the edge. `TestTheGatewayNeverRoutesModuleDelivery` -holds the built and committed route catalogs and the gateway's route table to -that, and the route validator refuses a catalog that carries it. - -`ApplyManifest` waits for the reconciliation pass, then answers from its own -binding's committed generation and digest. A failure applying another binding -or converging fixture installations is logged for the operator and cannot turn -an applied caller manifest into an Unavailable response. A caller whose manifest -has not applied receives its own pending reason; missing or unreadable applied -state remains a named refusal. Aggregate errors never enter this response. - -`TestApplyCompositionAnswersOnlyItsOwnManifestOutcome` covers unrelated binding -and fixture failures, the caller's own pending refusal, absent state, generation -and digest mismatches, and an unreadable result without another solution's reason. - -## Expanded shared suite (2026-10-25) - -The shared suite at `aa487dff75ed` runs 45 cases. Against the reviewed adapters -at `a2a9b92f3`, it reports **13 passed / 32 failed / 0 skipped**, plus **2 passing -local cases**. The default fresh-store signed-delivery regression also passes. -This supersedes the external 5/40 result against an older host revision. - -Many new cases refuse because the harness has not supplied their required -fixtures: solution roles, installation credentials and lifecycle, resource -grants, child scopes, workload organizations, parent limits and durable -delegation. These are unverified, not passes. Observed runtime failures include -a named parent binding discarding partially held scopes, a synthetic module -installation being used for a solution, and missing session display names. -Method admission now accepts resource-limited grants without changing them; -unit tests retain wrong-kind, wrong-action and missing-policy refusals. - -## Parent bindings preserve the person's holdings - -A person's named operation binding now projects each action independently at -the binding's target audience. A read-only person no longer loses their read -because the binding also permits invoke. Actions with different resource sets -stay separate; no cross-product of grants is minted. Content-read eligibility -comes only from retained whole-kind reads at that target, and the exact -installed binding identities are checked again before the signed parent leaves. -This reuses the installed binding resolver and existing person authority checks. - -The full shared suite now reports **18 passed / 27 failed / 0 skipped**, plus -**2 passing local cases**. Five child cases are newly green: the attenuating -hop and the named refusals for absent, undeclared, wrong-audience and widening -bindings. All targeted StartTask and module-actor tests, including action/resource -pair preservation, pass. The default empty-store signed-delivery regression -also passes with the updated Core dependency. New required fixture cases -listed above remain unverified; no skipped or missing fixture is counted green. - -## Empty declarations and workload diagnostics - -A manifest declaring a principal but no authority no longer inherits an -administrative envelope's grants. It retains its workload identity and any -existing tenant restriction, but no content, queues, audit namespaces, operation -bindings, artifact policies or cross-tenant permission. The signed manifest is -the ceiling even when an older envelope permits more. Missing content in that -manifest must be declared by its producer, not synthesized by Accounts. - -`ModulePrefixOfWorkload` logs every refusal's underlying reason, including -reviewer failures and ambiguous or undeclared workloads. The presented credential -is never a log field and is redacted if a reviewer echoes it in its error. -The authority libraries are pinned to merged revision `c9cef7470d20`. - -## The harness supplies every fixture the suite asks for (2026-10-25) - -The harness seeds its own fixture, `code/pkg/adapters/testdata/host-interface-conformance.yaml` -(the dev-admin people plus an owner who is not an administrator, a member with -no role, a resource-limited record grant and the module roles), delivers three -solutions through `ApplyManifest`, installs them through the fixture's declared -installations, acknowledges each install step through the host's own claim and -finish, and exposes them to their teams. Only external sources stand in: the -cluster, the composition's module authority (one module withdrawn for the -absent-organization case) and the policy log the uninstall path requires. - -Before: 13 of 45 direct cases passed, 32 failed, most of them for a missing -fixture. After: 21 pass, 24 fail, and none fails for a missing fixture; each -remaining red line is host behaviour, listed with its refusal and location in -the coordination worklist. Production wires no policy log, so the lifecycle's -revocation proves the uninstall path, not a deployed host's ability to revoke. -## Signed solution roles respect the manifest ceiling - -Signed solution roles are checked before delivery enters the inbox. Every scope -must stay inside a served kind's signed action ceiling or an explicit invoke or -lookup binding; headless operations cannot grant a person's role. A served kind -without an action ceiling retains the shared contract's unrestricted action -vocabulary. Empty or duplicate roles and excess grants are refused by role name. -The check reuses Core's scope-containment rule, including resource attenuation. - -With these role checks and the complete harness, the full shared suite at -merged authority `c9cef7470d20` reports **22 passed / 23 failed / 0 skipped**, -plus **2 passing local cases**. The signed role-ceiling refusal is newly green. -The default fresh-store regression also passes. The same merged local authority -passes **45/45** shared cases. Composed ENFORCE remains unqualified: the merged -hop requires authority 0.2.0, while this host advertises 0.1.0, so Codefly refuses -the binding before either policy surface can be redelivered. - -## Interface 0.2.0 and the host behaviour the suite reaches (2026-10-25) - -The interface protos are re-copied at authority `aa487dff` (authority and -session 0.2.0; composition and installation comments only), `DIGESTS.sha256` -re-recorded, and regenerated through `codefly generate proto`, the catalog -`go generate` steps, `codefly generate contracts`/`runnables` and the SDK's -`generate.mjs`, with the CI's CLI source pin. `module.codefly.yaml` advertises -`codefly.dev/authority@0.2.0` and `codefly.dev/session@0.2.0`. - -- **Session display names.** `SignIn` and `Current` fill - `person_display_name` (the profile name, else the primary email) and - `organization_display_name` (the organisation's name), each read under its - own row-level binding (`Service.SessionDisplayNames`). A session in no - organisation, or whose person left it, names no organisation. -- **An uninstalled audience** is refused before solution roles are read: - `FailedPrecondition`, `audience "…" is not installed in this organization, - so there is no installation to seal the capability to`. -- **A delivered solution named by its alias** resolves to the organisation's - installation of its solution binding, never to a composed module's - installation (`WorkContextAudienceInstallation`). -- **An installation's issue** proves its actor first, then reads the named - installation for whose it is (`WorkContextInstallationIdentity`: organisation, - delivered principal, status). Unknown is `NotFound`; another solution's - workload, another organisation, or a pending or revoked installation is - `PermissionDenied` naming why. The organisation is the installation's own. - An empty ask also considers the actions the solution's roles name. -- **A person at a solution through its binding** carries what the solution's - roles grant within that binding as installed; beyond it is refused - (`beyond`), nothing through it is refused (`holds nothing`). -- **An empty ask** carries a kind the person holds only on named resources, on - exactly those resources. -- **GrantToTeam** refuses a non-active installation as `PermissionDenied` - naming `pending` or `revoked`. -- Refusal texts now name the interface's reasons (`the owner holds nothing at - this audience`, `beyond what the owner holds`, `must name the organization - of the work item`, `already installed`). -- The harness's HTTP-route subtest installs its solution before issuing there: - it passed only because an uninstalled solution resolved to a synthetic - installation. - -Later the same day, on the coordinator's decisions: a person's ask meets -their holdings (an unheld id is dropped; an action held on nothing the ask -names is refused, "asked beyond what the owner holds"), and a kind a chain -dropped stays dropped (a denial row: the kind and no action, at a person's -root for each audience kind the ask left out and at a hop for each kind the -owner holds through the binding that the child was not granted; limits are -read per kind and per action). The harness names a real team with no member -for the staleness probe, and the suite is pinned at authority PR #8 -(`3222e991`). - -The shared suite now reports **40 of 45 shared cases passing, 5 failing** -(42/47 with the two harness cases). Still red: - -| Case | What the host must change | -| --- | --- | -| a delegation issues as its grantor | served: see "Delegations to a module" below | - -## An installation owns its capability as its agent (2026-10-26) - -On the coordinator's decision: an installation acting for itself is the -owner of what it is issued. No person is acting, so the human owner of -record is no longer named. - -- **The issue.** `forInstallation` mints through the same headless path with - `ownedByAgent`: the owner is the installation's agent principal, its kind - is `agent`, `owner_agent_id` is the agent identifier, there is no actor - hop and no team attribution. The internal `StartInstallationTask` and the - install step keep their shape (owner of record, agent acting): a person put - that work in motion. -- **Seal and revision.** The row-backed seal reads let an agent stand in its - organisation while its installation does (`standsInOrganization`): the - installation is active, the agent is neither revoked nor disabled, and an - owner or co-owner is still an organisation admin. The agent then holds the - organisation's installations and carries the greater of the organisation's - revision and its own, so core's check revokes the capability when any of - these ends. -- **Check and ListResources** re-verify an agent-owned capability - (`requireInstallationStands`). The agent must still act for one active - installation, that installation's authority must still resolve, and its - standing grants must still admit what the capability carries. A standing - grant can narrow without moving any revision, which is why this re-read is - needed. A removed owner is now `PermissionDenied`, a decision, where it - used to be `Internal`. That applies to a person who left as well. -- **Hops.** Rule A reads the installation's standing grants the way it reads a - person's current grants. Through the binding, a child carries what those - grants admit at the audience, within the agent's kind ceiling. The parent - is re-verified first. The child stays owned by the agent, and narrowing is - never recovered. -- **Harness.** The installation the suite refuses at an unrelated module is - now `conformance-proposals`. `conformance-pages` cannot serve: its viewer - role grants `example-records:write`, so `documents` is related to it, and - the local authority would grant that too. - -Suite at `3222e991`: **41 of 45 shared pass** (43/47 with the harness cases). -"an installation acts for itself" is green. "an installation owner survives" -now passes the agent-owned root and the first hop's owner, kind, agent, -tenant, task, session and expiry checks. It stops on that hop's -`actor_chain[0].principal_id`, which is the registry uuid (decision 1), so the -suite has not reached the second and third hops yet. The unit test -`TestAHopFromAnInstallationOwnedParentInheritsTheInstallationsStandingGrants` -covers two agent-owned hops. - -## A hop's actor is the module's name (2026-10-26) - -On the coordinator's decision, a hop through a binding names its actor by the -module's NAME, as binding ids (`//`) already -name it; the registry id (`ModulePrincipalID`) stays this host's storage -identity. - -- **The binding answer.** A module's installed operation binding is answered - as granted to the module's name; core holds the exercising principal to it - by equality, so another module (by name or id) cannot exercise it, and the - host refuses to sign such a hop before core would. The module's own headless - context (`StartModuleOperationTask`) is still owned by its registry id, - because the consumer contract (`CheckAuthorizationRevision`) types an owner - and every subject as a UUID; the seal source answers that context in its - owner's spelling, exactly when the owner is the binding's module - (`hostSealSource.grantee`). Moving headless owners to the name too needs that - contract to accept a module name, which is a regeneration of - `saas.accounts.v1`, not done here. -- **Seal, epoch and revision answers** resolve a principal to the declared - module by its name or its registry id (`ModulePrincipalRegistry.RegistryIDOf`), - locally and on the module authority surface. A module named as a UUID is - refused at registry derivation (`ValidateModulePrincipalGrant`), so names and - ids never collide; two manifests asking for one name were already refused. -- **The journal** records a module actor under its registry id (the column is - typed as an id; a name would have been dropped to NULL). -- **The solution-role path** reads the hop principals as names, which is what - `PersonSolutionRoles` was always given. -- **The revision check** (`ConfirmModuleHopBinding`, `IsDeclaredModulePrincipal`) - resolves a hop's module by name. -- Not moved: the source-delegation and installed-content hops and the module's - own identity capability still carry the registry id; the interface issues - none of them yet, and every reader above resolves both spellings. - -With the installation-owner work above (rebased onto `acf1593c`), the -shared suite reports **43 of 45 shared cases passing** (45/47 with the two -harness cases). "a module tool binding carries the person's solution role", -"an installation owner survives successive module actors" and the first -subcase of "child target projection" are green. The delegation case and the -child-projection delegation subcase followed with delegations, below: the -suite now passes 45 of 45. - -## Delegations to a module (2026-10-26) - -`Delegate`, `GetDelegation`, `RevokeDelegation` and Issue from a delegation are -served. A delegation is a row of `module_delegations` (migration 51: tenant -scoped, forced RLS, revoked once and never deleted, its grant immutable). - -- **Made by the person, in their own right.** `Delegate` and `RevokeDelegation` - refuse, by name, a resource-confined credential, an identity acting on - someone's behalf (`X-Act`, or a delegated request identity), an impersonated - session and any credential that is not an interactive session: the - credentials every other person mint holds back. The gateway already refuses a - resource-bound token at these procedures (ext_authz admits one only at its own - solution, at a declared module route paired with its capability, and at the - Work Context mint), so the host's refusal is the second layer, not the only one. -- **Bounded by the grantor.** `Delegate` holds each delegated action to what - the signed-in person holds now, at the module or at an audience one of its - bindings reaches, by the check every mint makes; beyond that is - `PermissionDenied` (`delegates what they do not hold`). A purpose past 1024 - characters is `InvalidArgument` by name. -- **Fail closed.** A host that cannot witness a revocation (no policy log is - wired) records no delegation: `Delegate` is `FailedPrecondition`, "this host - cannot witness a revocation, so it records no delegation". A delegation is - therefore never a standing grant that nothing could end. -- **Issue from a delegation** proves the actor's workload, which must be the - delegation's module (anyone else: `NotFound`), refuses a revoked delegation - by name, and issues the module's one hop through the binding it names, for - the grantor (`owner_principal_id`) with the delegation's id on the hop: the - grantor's current grants through the binding, cut to what was delegated. - Every later hop of that chain is cut to the delegation again, re-read from the - store (a revoked or mismatched one refuses the hop), so a kind or a resource - the person did not delegate is never recovered below it. The chain confirms a - stored delegation against the hop acting under it by the module's name. -- **Revocation is immediate and scoped to the delegation.** Its grantor or an - administrator of the organisation may revoke it (anyone else is told there is - none). The row is stamped under the policy log like every other narrowing. - Every hop of a chain under a delegation is sealed to - `//@` - (`business.DelegatedOperationBindingID`); the binding answer reads the - delegation live, so a revoked one revokes the binding for exactly those - capabilities, at every verifier, and nothing else the grantor holds moves. - Its module reads it as ended through `GetDelegation`, which, like `Issue`, - admits a bearer-less call whose actor credential authenticates it - (`business.ShapeAuthenticated`). -- **Audited.** `saas.module_delegation.granted` and - `saas.module_delegation.revoked` are registered (delegation, grantor, module, - scopes; the revoker on a revocation), emitted in the transaction that writes - the row, and declared as the two procedures' audit events; the events - contribution, event catalog, AsyncAPI and communication documents are - regenerated. -- **Evidence.** Suite pinned at authority `3222e991`: **45 of 45 shared cases - pass** (48/48 with the three harness cases). The harness case "a delegation is - bounded by its grantor and ends with its revocation" covers the bounds, a - revocation that leaves the grantor's own capability standing, an - administrator's revocation, the purpose bound and the refusal with no policy - log; `TestADelegationRefusesACredentialThatIsNotThePersonsOwn` covers each - refused credential. -- **Production.** Production wires no policy log, so a deployed host refuses - `Delegate` until one is wired, as it refuses every narrowing. - -## Review r93: chains, pins and the signed manifest (2026-10-26) - -- **Every hop of a chain is re-read.** A capability seals one binding, its - last hop's, and core re-reads only that one; a child therefore kept - verifying while a binding upstream of it narrowed or was withdrawn. - `ConfirmModuleChain` now holds each module hop to the binding it hopped - through (its module's binding addressed to the next hop's module, or to the - capability's audience for the last): the binding must still be installed, - and every scope the hop carries of a kind that binding governs must still lie - within it. A kind the chain carried down from an upstream hop is therefore - held to that hop's binding as it is now (rule A: a narrowing is never - recovered, and an upstream one takes effect below it). It runs on the host - interface's `Check`, `ListResources` and on a parent before a hop is issued - from it, and on `CheckAuthorizationRevision` for every hop before the last - (the subjects after the owner are read as the actor chain, in order). -- **One tenant-pin rule.** A principal pinned to a tenant is issued - capabilities in that tenant only, whatever `cross_tenant` says - (`ModulePrincipalGrant.IssuedIn`). The installation issue used to let a - pinned solution with `cross_tenant` be issued in another organisation while - the module mint refused; both now read the one rule. `cross_tenant` keeps - its one meaning: a principal's installed authority may serve installations - in other organisations (`moduleAvailableToOrganization`, which the - source-delegation and installation paths rely on); it never moves where the - principal itself is issued. -- **With no envelope, only a verified signature grants.** The accepted rule is - that the signed manifest is the ceiling when no envelope is written. What - stands in front of the grant is the signature: the applied manifest's - carrier is re-read from the delivery inbox and verified under the pinned - keys on every registry pass, and its digest must be the applied - generation's. The grant is now derived from the verified bytes themselves. - An unsigned carrier, no carrier, no pinned keys, a key the pins do not hold, - and an applied document the signature does not cover each grant nothing - (`TestWithNoEnvelopeOnlyAVerifiedSignatureGrants`, - `TestWithNoEnvelopeAnUnverifiedManifestGrantsNothing`). -- **Readers of a hop actor beyond the token.** No other service in this - repository compares a hop actor to a registry id (the auth gateway forwards - principals without reading them). Inside accounts, the delegated record - access and readable-sources paths hand a hop's actor to storage as a grant - subject; a module named by its name is now handed over as its registry id - there, as the journal already was. The remaining registry-id comparisons are - on capabilities that still carry the registry id (source delegations, - installed-content reads, the module's own identity and headless contexts). - -### Each hop is held to the scope set it was issued under (2026-10-26) - -The chain re-read compared a hop with the union of its binding's invoke, -lookup and headless sets. Lookup and headless lie within invoke by -declaration, so that union was invoke: a lookup hop upstream in a chain kept -standing after the binding's lookup was withdrawn, for as long as invoke -granted the action. The journal now records, for a module's hop through a -binding, the binding it went through and the set it was issued under -(migration 52: `operation_binding_id`, `operation`, both or neither, part of -the hop's content address when present). `ConfirmModuleChain` holds a recorded -hop to exactly that set: narrowing it, or withdrawing it, ends the hop even -while another set of the same binding still grants the action -(`TestAHopIsHeldToTheScopeSetItWasIssuedUnder`). The last hop's own binding is -also re-read by core, whose binding revision moves with any change to it. The -remote binding answer a callee's verifier asks for runs the same re-read and -answers a stale chain as revoked. - -The original `granted_scopes` in the same immutable journal identify which -kinds that hop granted. Removing a kind from every current binding set therefore -ends descendants carrying it; it cannot disappear from validation. Unrelated -kinds carried from other hops remain checked at their own origin. Missing -binding or scope provenance refuses the chain; there is no audience-based -fallback. The generated Connect regression `TestUpstreamKindRemovalEndsDescendant` -also checks missing provenance and refuses further issuance from a stale child. -`CheckAuthorizationRevision` names no hop, so a callee verifies through this -host's binding answer to obtain the exact chain check. - -## Review r136 repairs (2026-10-26) - -- **B1, one journal row per delegated issue.** The interface names a hop - issued from a delegation by the delegation's own id, so every issue from - one delegation carried the same hop id, and the journal (keyed by it, first - row wins) recorded only the first issue's binding and scope set. Later - issues were held to it: invoke then lookup left the lookup hop held to - invoke; lookup then invoke refused a fresh invoke token. A delegated hop is - now recorded under its own row, the delegation within the task its issue - opened (a delegation's issue starts a fresh task and every child keeps it), - and the readers ask for both a hop's own id and that row. The token still - carries the delegation's id, as the suite requires. Proven against real - Postgres in the harness ("every issue from one delegation is held to its own - scope set": both orders, each issue checked and its own row read back); with - the shared id restored the lookup-then-invoke issue is refused, as reported. - The unit-test journal now keeps the first row on a conflict, as Postgres does. -- **M2, the shared suite runs in CI, at authority main.** The conformance test - no longer waits for an environment switch nothing set: it runs wherever the - package's store-backed tests run (it replaces the fresh-store test, whose - assertions it contains). The library is pinned at authority main - (`d091cc7a`, which contains `f688c549`); this moved `codefly-dev/core` to - `8af4b787`, the version the library requires. Result: every case accounts is - offered passes, including the forwarded-context cases ("a module - authenticates the forwarded context against the published keys": expired, - forged, unsigned, wrong audience). The solution-principal cases are not - offered: the suite runs them only against a host whose - `host.installation.v1.Installation` carries `principal_id`, and accounts - serves the 0.2.0 installation interface, which does not. Passing them needs - the solution-principal contract (an installation's own principal owning its - data, owner-aware checking, role grants on the installation) re-vendored and - built; that is a feature, not a repair, and is not done here. -- **M3, the read oracles answer only about organisations the caller is - installed in.** `CheckAuthorizationRevision` and `AuthorizeEvidenceRead` - refuse, before reading anything, an organisation where the calling module - has no installation, with the same refusal as an organisation that does not - exist (`PermissionDenied`, "the calling module is not installed in - organization …"). -- **Minors.** Journal and chain-read refusals log the store error. A module - refused as itself on the host interface (`ErrModuleIdentityDenied`, an - unbound token) is `Unauthenticated` with the reason; a token the review - refused outright stays `PermissionDenied`, the code the shared suite holds a - forged workload credential to. `Delegate` refuses a module not installed in - the grantor's organisation (`FailedPrecondition`, by name). The store's - migration runner records every applied migration's name on its version table - and refuses a database whose recorded names this ledger contradicts (a - database without recorded names cannot be checked). **Local and validation - databases created before migrations 46–49 were renumbered to 49–52 must be - recreated.** The guard cannot identify those unstamped databases; no upgrade - path is supported, and a migration failure names this remedy. The harness - holds a listener bound to `:0`, reads its assigned port, and serves the - generated Connect catalog on that listener until cleanup. - - -### Review r153: explicit re-consent and actionable refusals - -An organization owner/admin's explicit module re-install is approval of the -current ceiling. Both Install and InstallSolution retain the installation and -write its current consent snapshot through the existing transaction; the -revision trigger invalidates the previous consent's credentials and work. -Non-admin requests do not change consent. The host's fixture setup consents at -creation and repairs its declared module installations when reseeded. -`INSTALLATION_CONSENT_REQUIRED` names both install RPCs as the recovery action. - -The read oracles and Delegate distinguish a missing installation from missing -consent and unreadable authority. Absent and uninstalled organizations, including -organizations outside the current tenant grant, still answer alike; a moved -tenant's cause is kept in the log. Both sites log the cause without presented -credentials. A rewritten delivery reports the stored and offered manifest, -payload and authority-document digests, so an authority-only change is legible. - -CI and library regeneration require released Codefly CLI **v0.1.215**. Runtime -initialization also passed with v0.1.214, which resolves the accounts service's Codefly-owned `egress` dependency. -Older development binaries may report “no configuration found for egress”. - -The source-read SDK is generated with v0.1.215 and -`--services ModuleCapabilitiesService`; its contract and facade exclude -unrelated services. The descriptor-closure regression is restored. The -TypeScript library is regenerated with the same CI pin through its owning -script; no generated file is hand-edited or selected out of its output. - -## Interface 0.3.3: the hop's admission keys (2026-10-26) - -`proto/host/authority/**` is re-copied at authority `49101ae1` (interface -0.3.3, authority PR #28), `DIGESTS.sha256` re-recorded, regenerated through -`codefly generate proto`, the catalog `go generate` steps, `codefly generate -contracts`/`runnables`, the SDK's `generate.mjs` and the source-read SDK's -documented `generate client`, with CLI v0.1.215. `module.codefly.yaml` -advertises `codefly.dev/authority@0.3.3`; composition, installation and -session stay where they were. - -- **`RegisterAdmissionKey`** (`host_interface_admission.go`, - `business/hop_admission_keys.go`). The caller's workload credential is - reviewed by the execution reviewer, the same review every workload-owned - call takes (`reviewWorkloadCredential`, shared with - `ModulePrefixOfWorkload`), and only the hop is admitted: service account - `hop` in the namespace the module namespace rule gives the `authority` - module. Any other workload is `PermissionDenied`, naming itself and the hop - (`the caller is documents/documents, not the hop (authority/hop)`); a - credential the review refuses is refused as every workload credential is - (`credentialRefusal`); a key that is not 32 bytes is `InvalidArgument`. -- **Leased rows, not memory.** A registration is a row of - `hop_admission_keys` (migration 55, control plane only), published for - `AdmissionKeyLease` (2 minutes) from the database's clock; the hop renews at - a quarter of what remains (30 s). Re-registering renews the one row, a - rotation publishes both keys while both are renewed, and a key no hop renews - lapses and is withdrawn. At most `MaxAdmissionKeys` (8) are published; a new - key past the bound is `FailedPrecondition` while a registered key still - renews. Registrations are serialised by a transaction-scoped advisory lock, - so two replicas cannot both take the last slot. Every replica reads the same - rows, so every replica publishes the same set - (`TestHopAdmissionKeysAreLeasedRowsEveryReplicaPublishes`, - `TestTheAdmissionKeyBoundHoldsAcrossReplicas`, against two stores over one - database). -- **Purpose separation.** `Keys` publishes the registered keys in - `admission_keys`, each with purpose `hop-admission`, and the Work Context key - in `keys` with purpose `work-context`; an admission key is never a Work - Context key, and the JSON Web Key Set is the sign-in key set, untouched. A - store that cannot be read fails `Keys` (`Unavailable`) rather than - publishing no admission key, which a verifier would read as "refuse every - admission". -- **Audit.** A key's first publication emits - `saas.platform.hop_admission_key_published` in the transaction that - publishes it, naming the key and the workload; a renewal is not an event, - and a key published again after it lapsed is. -- **In-cluster only.** The hop calls accounts directly, so the method is - public on the Connect listener with its credential in the request and is - never routed at the gateway (`ServedInClusterOnly`), like `ApplyManifest`. -- **What 0.3.0 added and this host does not do yet.** `AttachResource`, - `DetachResource` and `AuthorizeTransfer` are `Unimplemented`, naming the gap, - and an `Issue` or `Delegate` scope naming a `resource_owner_id` is refused - rather than read as the whole kind. - -**The shared suite.** The suite at authority `49101ae1` has no admission-key -case. Run against this branch with every library at `49101ae1`, it reported -**41 passed / 13 failed**: `Keys` and the forwarded-context case pass; the 13 -reds are contracts this host does not serve: composition 0.2.0's -`owner_aware` (the solution-role cases, the uninstalled-audience and lifecycle -cases), installation 0.2.0's `ListRoleGrants` and review, an absent -installation answered as `PermissionDenied`, and the installation-owned -resources case. The harness therefore stays on the suite's 0.2 revision -(`6870db87`) and adds the admission-key case over the listener (`the hop's -registered admission key is published in Keys`): 53 of 53 pass on this branch. - -**Labelled stopgap.** The authority client stays at `fff5385f`: the suite -revision the harness pins does not build against the client at `49101ae1` -(`forwarded.Authenticate` was removed there). So `business.AdmissionKeyID` -restates `stamp.KeyID` instead of importing it, pinned by a vector -`stamp.KeyID` produced at `49101ae1` (`TestAdmissionKeyIDIsTheStampDerivation`). -Delivery and resources move to `49101ae1` (no change in either). Both pins -move, and the restatement is deleted, when this host serves composition and -installation 0.2.0 and the harness can take the suite at main. +accounts implements the host interfaces published by `codefly-dev/authority`: +`codefly.dev/authority@0.3.4`, `codefly.dev/composition@0.2.0`, +`codefly.dev/installation@0.3.0` and `codefly.dev/session@0.2.0`. The local +authority implements the same four on a laptop. The shared conformance suite +(`libraries/conformance/go`) proves that the two agree. + +## One authority core, two thin hosts + +Every decision of the authority, composition and installation interfaces is +made by the shared authority core, `libraries/authority-core/go` in +`codefly-dev/authority`. The local authority runs that core over an in-memory +store. accounts runs it over Postgres. Neither host re-implements a rule: +child issuance (the owner's current grants within the binding, never widened), +owner-selected and installation-owned scopes, owner-aware audiences, consent +bound to a reviewed offer, role grants, delegations, transfer, method admission +and every revision and epoch that revokes are the core's. + +What accounts adds, and where: + +| Concern | Where | +|---|---| +| The core's records: modules, tombstones, solutions' installations, role grants, delegations, epochs, owner-aware revisions, consumed nonces | migration 56 (`authority_core_*`, control-plane relations) and `pkg/infra/postgres_authority_core.go` | +| A module's installation is accounts' own record (`installations`, kind `module`): an administrator's install with consent, within the module envelope, retired by an uninstall or by a ceiling that no longer allows the organization. The core is configured with `HostInstallsModules`, so it installs no module itself and reads those records live, with what each consent allows: an installation whose consent allows no capability is not active for the core, and the bindings it allows (the module's grant within the envelope, intersected with the consent, as the native mint reads it) bound every issue through them (`Installation.Bindings`). The revision the core seals to is accounts' own revision, plus the core's raise when a redelivery narrows the module (`authority_core_module_revisions`), plus the committed envelope revision, so a narrowed consent and an envelope write each end what was issued before them | `pkg/infra/postgres_authority_core.go` (`moduleInstallations`), `pkg/business/authority_core_consent.go` | +| Identity, read only: organizations, memberships, teams, and the organization roles a member holds, from accounts' RBAC (role assignments and live scope grants, directly or through a team, and the membership's administrator role) | `pkg/infra/postgres_authority_core.go` (`Member`, `OrgRole`) | +| Revocation levers shared with the product: the organization's authorization revision is accounts' own, and a person's epoch includes their principal revisions | same file | +| Who the caller is: the session the perimeter verified (`signedInPerson`), or a workload credential the execution reviewer reviewed (`workload`), bound to the organization the module envelope pins the module to | `pkg/adapters/host_interface.go` | +| The signing key the core signs with, the Work Context key `Keys` publishes | `pkg/business/authority_core.go` (`ConfigureAuthorityCore`) | +| Delivery: the signed carrier and the authority document are verified and recorded by accounts' delivery owner, then composed in the core through the core's `delivered` package; every applied delivery is composed again when the core is configured | `pkg/business/authority_delivery.go`, `pkg/business/authority_core_delivery.go` | +| Audit: each change the core journals is recorded in the core's own transaction (`saas.authority.*`, `saas.module_delegation.*`); each issue is recorded before it is handed out | `pkg/business/authority_core_audit.go` | +| The hop's admission keys: leased rows judged by the database's clock, published apart from the Work Context key; each registration proves its key once (its nonce is kept in `hop_admission_proofs`), and one key is pinned per hop instance (its pod) while its registration holds | migrations 55 and 58, `pkg/business/hop_admission_keys.go`, `pkg/adapters/host_interface_admission.go` | +| Tenancy at the perimeter: a member or an administrator of the organization a call names, before the core decides | `pkg/adapters/host_interface.go` (`requireOrgMember`, `requireOrgAdmin`) | + +## The interfaces, method by method + +- **authority**: `Issue` for a person (the signed-in session), a workload, an + installation acting for itself, a parent token (a module's hop through its + binding), or a delegation; `Check`, with a named method held to the audience's + delivered method policy (advisory answers the refusal beside the context); + `ListResources`; `Delegate`, `RevokeDelegation`, `GetDelegation`; + `AttachResource`, `DetachResource`, `AuthorizeTransfer`; `Keys`; + `RegisterAdmissionKey`. +- **composition**: `ApplyManifest`. A delivery the core would refuse to compose + (for example a solution that declares no installer role) is refused before it + is recorded. +- **installation**: `Current` (a module reads its own active installation in + the tenant of a current parent addressed to it, proving itself by its + workload credential), `Review`, `Install` (consent bound to the reviewed target, + generation, revision, offer digest and a fresh operation id), `GrantRole`, + `RevokeRole`, `ListRoleGrants`, `List`. `OfferService` (the local authority's + setup read) is not served here. +- **session**: `SignIn` (fixture sign-in), `Current`, `ListFixturePeople`: + accounts' own sessions. + +## Proof + +- The shared suite runs against accounts in + `pkg/adapters/host_interface_conformance_test.go`, with every package's + dependency tests, at the authority revision `go.mod` pins: accounts booted over + its test store, its Connect listener on an ephemeral port, modules and + solutions delivered as the CLI delivers them, and the fixture's installations + performed through the core. +- Every module the harness or the suite delivers is installed at both + fixture organizations by their administrators through accounts' own install + path (`Fixture.InstallModule`); `conformance-idle` stays installed nowhere. + The harness proves a module workload is issued nothing where it is not + installed, nothing past the organization the envelope pins it to, and + nothing after an administrator uninstalls it, when what it was issued fails + its next check. +- The solution-principal target (`RunSolutionPrincipalTarget`) runs in the same + harness. A person leaving a team or the organization goes through accounts' + own production paths (`RemoveTeamMember`, `RemoveOrgMember`): the narrowing + applies at once, the next issue no longer carries what the membership gave, + and the removal is in the audit journal naming the person removed. +- `.github/workflows/ci.yml` (`host-interface-conformance`, the required + context "Host interface conformance (authority main)" and a release gate) + runs the suite at authority main, whenever main contains the pinned revision, + on every pull request and merge-queue entry. +- The core's store contract (`authority-core/go/storetest`) runs against this + Postgres store in `pkg/infra/postgres_authority_core_test.go`. +- The core's own decisions are unit tested in `codefly-dev/authority`. + +## Not yet + +- No uninstall RPC exists for a solution's host-interface installation, in + either host (`codefly.dev/installation` declares none); the core's + `Uninstall` is reached by host setup alone. A module's installation is + uninstalled through accounts' own product surface, and the core sees it at + once. +- A solution's installation is recorded twice: by the product's own records + (`saas.accounts.v1.InstallationService`) and by the core; a fixture + installation is performed in both. A module's is recorded once, in + accounts' records, which the core reads. +- The hop's per-instance pin and its registration proofs are kept in + Postgres, shared across replicas; the instance is the pod the reviewed + credential names. +- A narrowing of accounts' own RBAC (removing a team member, revoking a scope + grant) runs on the control plane with an explicit organisation predicate at + each statement, as it did under the former witness; returning the + organisation-only ones to the tenant transaction is open. diff --git a/module/services/accounts/code/fixtures/installations.go b/module/services/accounts/code/fixtures/installations.go index 7654b6220..d30749f41 100644 --- a/module/services/accounts/code/fixtures/installations.go +++ b/module/services/accounts/code/fixtures/installations.go @@ -1,21 +1,24 @@ package fixtures import ( - "accounts/pkg/business" "context" "errors" "fmt" "strings" "sync" + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" "github.com/codefly-dev/core/wool" + + "accounts/pkg/business" ) // fixtureInstallation is one installation a fixture declares: the organisation // installing, the target by the name the composition gives it, the role it // holds, and the administrator who installs it and is its owner of record. It // is the host interface's Install by name, performed by a fixture person, then -// its GrantToTeam for each team named: the teams whose members hold its role. +// granted to each team named: the teams whose members hold its role. type fixtureInstallation struct { Org string `yaml:"org"` Target string `yaml:"target"` @@ -79,8 +82,8 @@ type pendingInstallation struct { declared fixtureInstallation orgID string ownerID string - // teamIDs are the declared teams not exposed yet. - teamIDs []string + // teamIDs are the declared teams not exposed yet, allTeamIDs every one. + teamIDs, allTeamIDs []string // waiting is set once the first pass found the target undelivered, so the // wait is said once rather than at every pass. waiting bool @@ -107,6 +110,7 @@ func newInstallations(service *business.Service, declared []fixtureInstallation, return nil, fmt.Errorf("installation %s references team %q, which the fixture does not seed in %q", installation.describe(), team, installation.Org) } row.teamIDs = append(row.teamIDs, teamID) + row.allTeamIDs = append(row.allTeamIDs, teamID) } installations.pending = append(installations.pending, row) } @@ -145,6 +149,11 @@ func (i *Installations) Converge(ctx context.Context) error { if installed { w.Info("fixture installation installed", wool.Field("installation", row.declared.describe()), wool.Field("installation_id", installation.GetId())) } + // The authority core holds the same installation, which the host + // interfaces decide with (coreInstall). + if err := i.coreInstall(ctx, row); err != nil { + failures = append(failures, fmt.Errorf("fixture installation %s: %w", row.declared.describe(), err)) + } // Each declared team is exposed once the installation admits it (an // installation still pending its install step does not); one not // exposed yet keeps the row for the next pass. @@ -163,3 +172,64 @@ func (i *Installations) Converge(ctx context.Context) error { i.pending = remaining return errors.Join(failures...) } + +// coreInstall performs the declared installation in the authority core as the +// host's own setup (InstallAsHost), then grants the installer role to its +// owner and its role to each declared team (GrantRoleAsHost), as the local +// authority's fixture does. Each step is idempotent, so a reseed repeats it +// harmlessly. A host whose core is not configured installs nothing there. +func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation) error { + core, err := i.service.AuthorityCore() + if errors.Is(err, business.ErrAuthorityCoreUnconfigured) { + return nil + } + if err != nil { + return err + } + // A module's installation is accounts' own record, which Converge's install + // made; the core reads it and installs no module itself. + if m, err := core.Composed(ctx, row.declared.Target); err == nil && m.Kind != domain.KindSolution { + return nil + } + inst, err := core.InstallAsHost(ctx, row.orgID, row.declared.Target, row.declared.Role, row.ownerID, i.service.AuthorityInstallStep()) + switch { + case errors.Is(err, domain.ErrAlreadyInstalled): + // Installed already: its grants are granted again, idempotently. + inst.ID = authority.StableInstallationID(row.orgID, row.declared.Target) + case err != nil: + return fmt.Errorf("the authority core: %w", err) + } + grants := []authority.RoleRequest{{Organization: row.orgID, InstallationID: inst.ID, Role: domain.InstallerRole, PersonID: row.ownerID}} + for _, teamID := range row.allTeamIDs { + grants = append(grants, authority.RoleRequest{Organization: row.orgID, InstallationID: inst.ID, Role: row.declared.Role, TeamID: teamID}) + } + for _, grant := range grants { + if _, err := core.GrantRoleAsHost(ctx, grant); err != nil { + return fmt.Errorf("the authority core: grant %s: %w", grant.Role, err) + } + } + return nil +} + +// ConvergeAuthority performs every declared installation in the authority core +// alone, for a host that serves the host interfaces without the product's own +// installation records (the conformance harness). A target the composition +// has not delivered yet is waited on. +func (i *Installations) ConvergeAuthority(ctx context.Context) error { + if i == nil { + return nil + } + i.mu.Lock() + defer i.mu.Unlock() + var failures []error + for _, row := range i.pending { + err := i.coreInstall(ctx, row) + if code, ok := domain.CodeOf(err); ok && code == domain.NotFound { + continue + } + if err != nil { + failures = append(failures, fmt.Errorf("fixture installation %s: %w", row.declared.describe(), err)) + } + } + return errors.Join(failures...) +} diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 020d1162a..ce9e5a969 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -7,12 +7,15 @@ require ( buf.build/go/protovalidate v1.4.0 connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 - github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261026111022-6870db87da8b - github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026193714-49101ae11a7f + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027063450-22b86ccbf2a5 + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261027063450-22b86ccbf2a5 + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261027063450-22b86ccbf2a5 + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261027063450-22b86ccbf2a5 + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261027063450-22b86ccbf2a5 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/interface-cache/go/cache v0.4.1 - github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 + github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04 github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 @@ -21,7 +24,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 - github.com/jackc/pgx/v5 v5.10.0 + github.com/jackc/pgx/v5 v5.11.0 github.com/redis/go-redis/v9 v9.22.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 @@ -61,8 +64,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheggaaa/pb/v3 v3.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 // indirect - github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026193714-49101ae11a7f // indirect + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027063450-22b86ccbf2a5 // indirect github.com/fatih/color v1.19.0 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/go-logr/logr v1.4.4 // indirect diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 573ab8b20..a01dafcff 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -56,24 +56,28 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= -github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261026111022-6870db87da8b h1:pFwwx9P+fL8HF/1eUOXIe4TJU3OtVI1f+rA2ydlhnh4= -github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261026111022-6870db87da8b/go.mod h1:x7qJp8jwM5qyu1ZtLo1XigUABHagdjqUEQHs0tgQrqs= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026193714-49101ae11a7f h1:R3BlevqYzEFWLL6XYmmaaHlwQ6lFerNMtXscadq8INA= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026193714-49101ae11a7f/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026193714-49101ae11a7f h1:H4/E8nIQwsz8oaEzfkRmFuySotqFfMK2+e1ODMUUGRQ= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026193714-49101ae11a7f/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027063450-22b86ccbf2a5 h1:91QJn4YL2YbtszshwHGdj3E/8wQCTseEV2gy0Q3zbOc= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261027063450-22b86ccbf2a5 h1:uUBeeLmucYQrp+HI84RZ5FECrgwu7K0wk/XwKl0p2RI= +github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:kTjeuNl03XSCkH3UhGKuHAKamkIzNsC2RPFu/g0fqSk= +github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261027063450-22b86ccbf2a5 h1:YDNfDRHcEyhItGuwu4EvrQz2EkjlzbVIb8ddw2S3xqI= +github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:or3dd9bqacXWMjke/n81l/QxRaLwq2lq/hL4V2qtAZo= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261027063450-22b86ccbf2a5 h1:MTxmkMVzBrUsh+XA+sqkVbHlJ+1aiUl0TT+kdu668cA= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027063450-22b86ccbf2a5 h1:pHywzlszDDjNJIOg52YhmDCpW+7N4c/Ftma/qx5HxxE= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:9WF8dd98HYX9g/49uhrTgaTbWXo3hBvYzS3UVWYiZHw= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261027063450-22b86ccbf2a5 h1:1l4tD5q1/sS59nQDgZ5QNGFqf92i1gyn8hQzJEhMVf4= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261027063450-22b86ccbf2a5/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= -github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.4.1 h1:S7a20cVs3ilW8/HSpzmsyfJx/GJHgJ1xW9Dm9MYXYPI= github.com/codefly-dev/interface-cache/go/cache v0.4.1/go.mod h1:ki57cBwXnpFLx1uO29KiDY06lzyG/gXXBkqgi5ZNKWQ= -github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= -github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= +github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04 h1:I3x8ihrzBMpv9UrLYc/3TmKl+lBvA5+xGcigENG/IR8= +github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04/go.mod h1:LZl0I41APmLxG3Awdd4gn/pIhMOOgJqLuMuNGyXUJMg= github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= @@ -211,8 +215,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= @@ -269,8 +273,8 @@ github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index b4e02945e..936c0d702 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -194,11 +194,6 @@ func (i *connectPolicyInterceptor) WrapUnary(next connect.UnaryFunc) connect.Una if err := enforceCentralPolicy(ctx, req.Spec().Procedure); err != nil { return nil, translateGRPCError(err) } - // The policy log's serving gate. After authorization, so the - // refusal reaches only callers this host would have served. - if err := enforcePolicyLogServing(ctx); err != nil { - return nil, translateGRPCError(err) - } shadowPolicyCoverage(ctx, req.Spec().Procedure) // One committed module-envelope revision read per unary request, // however many module authority decisions the handler makes. @@ -225,9 +220,6 @@ func (i *connectPolicyInterceptor) WrapStreamingHandler(next connect.StreamingHa if err := enforceCentralPolicy(ctx, conn.Spec().Procedure); err != nil { return translateGRPCError(err) } - if err := enforcePolicyLogServing(ctx); err != nil { - return translateGRPCError(err) - } shadowPolicyCoverage(ctx, conn.Spec().Procedure) return next(ctx, conn) } diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers_host_interface.go b/module/services/accounts/code/pkg/adapters/connect_handlers_host_interface.go index 59691fb7c..585a7fb80 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers_host_interface.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers_host_interface.go @@ -16,7 +16,7 @@ import ( sessionv1 "accounts/pkg/gen/host/session/v1" ) -var hostAuthoritySingleton = &HostAuthorityServer{workContexts: workContextSingleton, modules: moduleCapabilitiesSingleton} +var hostAuthoritySingleton = &HostAuthorityServer{workContexts: workContextSingleton} // HostAuthoritySingleton serves codefly.dev/authority over the Work Context singleton. func HostAuthoritySingleton() *HostAuthorityServer { return hostAuthoritySingleton } @@ -71,13 +71,25 @@ func (h *hostCompositionConnectHandler) ApplyManifest(ctx context.Context, req * type hostInstallationConnectHandler struct{ svc *business.Service } func (h *hostInstallationConnectHandler) server() *HostInstallationServer { - return NewHostInstallationServer(installationSingleton, h.svc) + return &HostInstallationServer{} } func (h *hostInstallationConnectHandler) Install(ctx context.Context, req *connect.Request[installationv1.InstallRequest]) (*connect.Response[installationv1.Installation], error) { return unary(ctx, req, h.server().Install) } -func (h *hostInstallationConnectHandler) GrantToTeam(ctx context.Context, req *connect.Request[installationv1.GrantToTeamRequest]) (*connect.Response[installationv1.Installation], error) { - return unary(ctx, req, h.server().GrantToTeam) +func (h *hostInstallationConnectHandler) Current(ctx context.Context, req *connect.Request[installationv1.CurrentInstallationRequest]) (*connect.Response[installationv1.CurrentInstallation], error) { + return unary(ctx, req, h.server().Current) +} +func (h *hostInstallationConnectHandler) Review(ctx context.Context, req *connect.Request[installationv1.ReviewRequest]) (*connect.Response[installationv1.ConsentApproval], error) { + return unary(ctx, req, h.server().Review) +} +func (h *hostInstallationConnectHandler) GrantRole(ctx context.Context, req *connect.Request[installationv1.GrantRoleRequest]) (*connect.Response[installationv1.Installation], error) { + return unary(ctx, req, h.server().GrantRole) +} +func (h *hostInstallationConnectHandler) RevokeRole(ctx context.Context, req *connect.Request[installationv1.RevokeRoleRequest]) (*connect.Response[installationv1.Installation], error) { + return unary(ctx, req, h.server().RevokeRole) +} +func (h *hostInstallationConnectHandler) ListRoleGrants(ctx context.Context, req *connect.Request[installationv1.ListRoleGrantsRequest]) (*connect.Response[installationv1.ListRoleGrantsResponse], error) { + return unary(ctx, req, h.server().ListRoleGrants) } func (h *hostInstallationConnectHandler) List(ctx context.Context, req *connect.Request[installationv1.ListRequest]) (*connect.Response[installationv1.ListResponse], error) { return unary(ctx, req, h.server().List) diff --git a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go index 081e7f716..0cc22971f 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go @@ -78,11 +78,6 @@ func grpcAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExposure) if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { return nil, err } - // The policy log's serving gate. After authorization, so the - // refusal reaches only callers this host would have served. - if err := enforcePolicyLogServing(ctx); err != nil { - return nil, err - } shadowPolicyCoverage(ctx, info.FullMethod) return handler(business.WithModuleEnvelopeRevisionMemo(ctx), req) } @@ -104,9 +99,6 @@ func grpcStreamAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExpo if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { return err } - if err := enforcePolicyLogServing(ctx); err != nil { - return err - } shadowPolicyCoverage(ctx, info.FullMethod) return handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx}) } diff --git a/module/services/accounts/code/pkg/adapters/host_interface.go b/module/services/accounts/code/pkg/adapters/host_interface.go index f86605a15..db83bf604 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface.go +++ b/module/services/accounts/code/pkg/adapters/host_interface.go @@ -1,23 +1,27 @@ package adapters -// The host authority interfaces (the authority interface repository), served by accounts as -// adapters over what it already does. Nothing here decides authority: every -// operation maps onto an existing path, and where no path exists the refusal -// names the gap instead of inventing one. The same four interfaces are served by -// the local authority for a local run, and the authority interface repository's pkg/conformance is -// the suite both must pass. +// The host interfaces (codefly.dev/authority, composition, installation, +// session) served by accounts. Every decision is the shared authority core's +// (codefly-dev/authority libraries/authority-core), the same core the local +// authority serves on a laptop, over this host's store (pkg/infra +// postgres_authority_core.go). What this file adds is the wire and what is +// this host's own: who the caller is (the session the perimeter verified, or +// a workload credential the execution reviewer reviewed), the signing key +// Keys publishes, delivery (composition.go's ApplyComposition) and the audit +// record of each issue. import ( - "cmp" "context" "encoding/base64" "encoding/json" "errors" "fmt" - "net/http" + "slices" "strings" "time" + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/workcontext" "github.com/google/uuid" @@ -28,50 +32,124 @@ import ( accountsauth "accounts/pkg/auth" "accounts/pkg/business" authorityv1 "accounts/pkg/gen/host/authority/v1" - compositionv1 "accounts/pkg/gen/host/composition/v1" installationv1 "accounts/pkg/gen/host/installation/v1" sessionv1 "accounts/pkg/gen/host/session/v1" gen "accounts/pkg/gen/saas/accounts/v1" ) -// HostAuthorityServer serves codefly.dev/authority over the Work Context authority. -type HostAuthorityServer struct { - authorityv1.UnimplementedAuthorityServiceServer - workContexts *WorkContextAuthorityServer - modules *ModuleCapabilitiesServer - // roles answers solution roles; nil reads the service. - roles solutionRoleReader - // methods holds a call to a delivered method policy; nil reads the service. - methods methodPolicyChecker - // admissions registers and publishes the hop's admission keys; nil reads - // the service. - admissions admissionKeys +// authorityCore is the core every host interface call is decided by. +func authorityCore() (*authority.Service, error) { + if service == nil { + return nil, status.Error(codes.Unavailable, business.ErrAuthorityCoreUnconfigured.Error()) + } + core, err := service.AuthorityCore() + if err != nil { + return nil, status.Error(codes.Unavailable, err.Error()) + } + return core, nil } -// methodPolicyChecker holds a call's scopes to what the audience module's -// applied delivery declares for one method. -type methodPolicyChecker interface { - CheckDeliveredMethod(ctx context.Context, audience, method string, held []*basev0.WorkScopeV1) error +// coreStatus maps a refusal to a status naming its reason: every refusal +// reaches the caller. The core's refusals carry their code; core's Work +// Context errors map by kind. +func coreStatus(err error) error { + if err == nil { + return nil + } + if _, ok := status.FromError(err); ok { + return err + } + if c, ok := domain.CodeOf(err); ok { + return status.Error(map[domain.Code]codes.Code{ + domain.Denied: codes.PermissionDenied, domain.NotFound: codes.NotFound, + domain.Invalid: codes.InvalidArgument, domain.Precondition: codes.FailedPrecondition, + }[c], err.Error()) + } + switch { + case errors.Is(err, workcontext.ErrNoBinding): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, workcontext.ErrRevoked), errors.Is(err, workcontext.ErrNoSeal): + return status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, workcontext.ErrInvalid), errors.Is(err, workcontext.ErrNotACoreToken), errors.Is(err, workcontext.ErrReplayed): + return status.Error(codes.Unauthenticated, err.Error()) + case errors.Is(err, authority.ErrConflict): + return status.Error(codes.Aborted, err.Error()) + case errors.Is(err, business.ErrAuthorityCoreUnconfigured): + return status.Error(codes.Unavailable, err.Error()) + } + return status.Error(codes.Internal, err.Error()) } -func (s *HostAuthorityServer) methodPolicies() methodPolicyChecker { - if s.methods != nil { - return s.methods +// signedInPerson is the person a session bearer authenticates, as the core +// takes them: their id, the organization the session is at, the session. +// Only an interactive session acts as a person here: never a credential +// confined to one consumer's ceiling, an identity acting on someone's behalf +// (X-Act), an administrator viewing as someone (impersonation) or an API key. +func signedInPerson(ctx context.Context, what string) (authority.Person, error) { + identity, ok := accountsauth.VerifiedRequestIdentity(ctx) + if !ok || identity.EffectiveSubject == uuid.Nil { + return authority.Person{}, status.Errorf(codes.Unauthenticated, "only the signed-in person may %s; the call carries no session", what) + } + if _, confined := accountsauth.VerifiedResourceConfinement(ctx); confined { + return authority.Person{}, status.Errorf(codes.PermissionDenied, "a resource-confined credential cannot %s: it acts only within its consumer's ceiling", what) + } + if _, acting := accountsauth.VerifiedActorFromContext(ctx); acting || identity.Delegation != nil { + return authority.Person{}, status.Errorf(codes.PermissionDenied, "an identity acting on someone's behalf (X-Act) cannot %s for them", what) + } + if identity.Impersonated() { + return authority.Person{}, status.Errorf(codes.PermissionDenied, "an impersonated session cannot %s for the person it views as", what) } + if kind := credentialKindFromContext(ctx); kind != credentialKindSession { + if kind == "" { + kind = "no credential kind" + } + return authority.Person{}, status.Errorf(codes.PermissionDenied, "only an interactive session may %s; this call presented %s", what, kind) + } + person := authority.Person{ID: identity.EffectiveSubjectID(), SessionID: identity.SessionID.String()} + if identity.OrgID != uuid.Nil { + person.Organization = identity.OrgID.String() + } + return person, nil +} + +// workload is the composed module a workload credential proves, reviewed by +// the execution reviewer and resolved to the one module whose delivered +// manifest declares that workload, bound to the organization the module +// envelope pins it to: a pinned workload is issued nothing elsewhere. A shared +// one acts only where an administrator installed it (the core reads accounts' +// installations). +func workload(ctx context.Context, credential, which string) (authority.Workload, error) { if service == nil { - return nil + return authority.Workload{}, status.Error(codes.Unavailable, "the execution reviewer is not wired") + } + module, err := service.ModulePrefixOfWorkload(ctx, credential) + if err != nil { + return authority.Workload{}, credentialRefusal(err, which) } - return service + tenant, err := service.ModuleTenant(ctx, module) + if err != nil { + return authority.Workload{}, status.Errorf(codes.Unavailable, "the module registry could not be read current: %v", err) + } + return authority.Workload{Module: module, Organization: tenant}, nil +} + +// HostAuthorityServer serves codefly.dev/authority. +type HostAuthorityServer struct { + authorityv1.UnimplementedAuthorityServiceServer + workContexts *WorkContextAuthorityServer + // admissions registers and publishes the hop's admission keys; nil reads + // the service. + admissions admissionKeys } -// NewHostAuthorityServer adapts the Work Context authority. -func NewHostAuthorityServer(workContexts *WorkContextAuthorityServer, modules *ModuleCapabilitiesServer) *HostAuthorityServer { - return &HostAuthorityServer{workContexts: workContexts, modules: modules} +// NewHostAuthorityServer serves the authority interface with the Work Context +// key workContexts holds. +func NewHostAuthorityServer(workContexts *WorkContextAuthorityServer) *HostAuthorityServer { + return &HostAuthorityServer{workContexts: workContexts} } -// Keys is the issuer and the one Work Context signing key, and apart from it -// the admission keys the hop registered (RegisterAdmissionKey), never among -// the Work Context keys. +// Keys is the issuer and the one Work Context signing key the core signs +// with, and apart from it the admission keys the hop registered. func (s *HostAuthorityServer) Keys(ctx context.Context, _ *authorityv1.KeysRequest) (*authorityv1.KeysResponse, error) { if !s.workContexts.configured() { return nil, status.Error(codes.FailedPrecondition, ErrWorkContextAuthorityUnconfigured.Error()) @@ -86,451 +164,480 @@ func (s *HostAuthorityServer) Keys(ctx context.Context, _ *authorityv1.KeysReque }}, AdmissionKeys: admission}, nil } -// Check verifies for the audience the caller is: core's full check with the -// host's live seals and revisions, exactly as a module capability is verified. +// Check verifies a capability for the audience the caller is and, when a +// method is named, applies what the audience's delivery declares for it. func (s *HostAuthorityServer) Check(ctx context.Context, req *authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { - if req.GetAudience() == "" { - return nil, status.Error(codes.InvalidArgument, "a check names the audience the caller is") - } - verified, err := s.workContexts.verifyFor(ctx, req.GetToken(), req.GetAudience()) + core, err := authorityCore() if err != nil { - return nil, interfaceStatus(err) - } - if err := s.workContexts.requireModuleChainStands(ctx, verified.Context()); err != nil { return nil, err } - // An installation's agent owns its capability by its installation's - // standing, which core's seal and revision do not wholly carry (a - // standing grant narrowed moves neither), so it is re-read here. - if verified.Context().GetOwnerPrincipalKind() == business.PrincipalKindAgent { - if _, _, err := s.workContexts.requireInstallationStands(ctx, verified.Context()); err != nil { - return nil, interfaceStatus(err) - } - } - // The capability is sound. A named method is then held to what the - // audience module's delivery declares for it, against what the call's - // current actor holds (the last hop's grant on a delegated capability, never - // the owner's authority above it); advisory reports that decision beside - // the capability instead of refusing. - refusal := "" - refusalCode := codes.PermissionDenied - if method := req.GetMethod(); method != "" { - if policies := s.methodPolicies(); policies == nil { - refusal = methodPolicyRefusal(req.GetAudience(), method) - } else if err := policies.CheckDeliveredMethod(ctx, req.GetAudience(), method, effectiveScopes(verified.Context())); err != nil { - refusal = err.Error() - if errors.Is(err, business.ErrDeliveredMethodUnknown) { - refusalCode = codes.NotFound - } - } - if refusal != "" && !req.GetAdvisory() { - return nil, status.Error(refusalCode, refusal) - } + verified, revision, refused, err := core.CheckMethod(ctx, req.GetToken(), req.GetAudience(), req.GetMethod(), req.GetAdvisory()) + if err != nil { + return nil, coreStatus(err) } wire, err := workContextToWire(verified.Context()) if err != nil { return nil, status.Error(codes.Internal, err.Error()) } - return &authorityv1.CheckResponse{Context: wire, AuthorizationRevision: verified.Context().GetAuthorizationRevision(), Refusal: refusal}, nil -} - -// methodPolicyRefusal names the fail-closed answer when no service is wired. -func methodPolicyRefusal(audience, method string) string { - return fmt.Sprintf("%s's delivery declares no method policy, so %s is refused", audience, method) + out := &authorityv1.CheckResponse{Context: wire, AuthorizationRevision: revision} + if refused != nil { + // The advisory answer names the refusal exactly as an enforced check reports it. + out.Refusal = status.Convert(coreStatus(refused)).Message() + } + return out, nil } -// Issue maps each owner shape onto the mint that already exists for it. Each -// shape is authenticated first (the bearer, the workload credential, the actor -// credential), and only then is its audience held to the composition, so a -// caller proving nothing learns nothing of which modules are composed. +// Issue signs a capability for one owner shape. Each shape is authenticated +// first (the session, the workload credential, the actor's credential), and +// its issue is recorded before the capability is handed out. func (s *HostAuthorityServer) Issue(ctx context.Context, req *authorityv1.IssueRequest) (*authorityv1.IssueResponse, error) { - if err := refuseOwnerSelected(req.GetScopes()); err != nil { - return nil, err - } - // An installation proves its solution's workload before anything it asks - // is read, so its audience is required there, once the caller is proven. - if _, installation := req.GetAs().(*authorityv1.IssueRequest_Installation); req.GetAudience() == "" && !installation { - derived, err := bindingAudience(ctx, req) - if err != nil { - return nil, err - } - req = proto.CloneOf(req) - req.Audience = derived - } - ttl, err := issueTTL(req) + core, err := authorityCore() if err != nil { return nil, err } - scopes := scopesToHost(req.GetScopes()) - var issued *gen.IssuedWorkContext + if req.GetTtlSeconds() < 0 { + return nil, status.Errorf(codes.InvalidArgument, "a lifetime is not negative: %d seconds", req.GetTtlSeconds()) + } + r := authority.IssueRequest{Audience: req.GetAudience(), Binding: req.GetOperationBindingId(), Operation: req.GetOperation(), + Organization: req.GetOrganizationId(), Scopes: scopesFromWire(req.GetScopes()), TTL: time.Duration(req.GetTtlSeconds()) * time.Second} + var out authority.Issued + child := false switch as := req.GetAs().(type) { case *authorityv1.IssueRequest_Person: - // The person is whoever the perimeter verified from the bearer on this - // call; the request names nobody, by the interface's design. - identity, ok := accountsauth.VerifiedRequestIdentity(ctx) - if !ok { - return nil, status.Error(codes.Unauthenticated, "a person-owned issue is made by the signed-in person") + person, perr := signedInPerson(ctx, "be issued a capability") + if perr != nil { + return nil, perr } - if err := requireComposed(req.GetAudience()); err != nil { + if err := requireOrgMember(ctx, person.ID, person.Organization); err != nil { return nil, err } - issued, err = s.personIssue(ctx, identity, req, scopes, ttl) - if err != nil { - return nil, interfaceStatus(err) + out, err = core.IssueForPerson(ctx, person, r) + case *authorityv1.IssueRequest_Workload: + w, werr := workload(ctx, as.Workload.GetCredential(), "workload") + if werr != nil { + return nil, werr } + out, err = core.IssueForWorkload(ctx, w, r) case *authorityv1.IssueRequest_Installation: - issued, err = s.forInstallation(ctx, as.Installation.GetInstallationId(), req, scopes, ttl) + w, werr := workload(ctx, req.GetActor().GetCredential(), "actor") + if werr != nil { + return nil, werr + } + out, err = core.IssueForInstallation(ctx, w, as.Installation.GetInstallationId(), r) case *authorityv1.IssueRequest_ParentToken: - if req.GetActor() == nil { - issued, err = s.exchangeOrRenew(ctx, as.ParentToken, req, scopes, ttl) - break + w, werr := workload(ctx, req.GetActor().GetCredential(), "actor") + if werr != nil { + return nil, werr } - issued, err = s.childHop(ctx, as.ParentToken, req) - case *authorityv1.IssueRequest_Workload: - issued, err = s.workloadIssue(ctx, as.Workload.GetCredential(), req, ttl) + out, err = core.IssueChild(ctx, as.ParentToken, w, r) + child = true case *authorityv1.IssueRequest_Delegation: - issued, err = s.delegationHop(ctx, as.Delegation.GetDelegationId(), req) + w, werr := workload(ctx, req.GetActor().GetCredential(), "actor") + if werr != nil { + return nil, werr + } + out, err = core.IssueForDelegation(ctx, as.Delegation.GetDelegationId(), w, r) default: return nil, status.Error(codes.InvalidArgument, "an issue names whom it is for: a person, a workload, an installation, a parent token or a delegation") } if err != nil { - return nil, interfaceStatus(err) + return nil, coreStatus(err) } - decoded, err := workcontext.Decode(issued.GetToken()) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) + return s.issued(ctx, out, child) +} + +// issued records the issue in the organization it was issued in and answers it. +func (s *HostAuthorityServer) issued(ctx context.Context, out authority.Issued, child bool) (*authorityv1.IssueResponse, error) { + wc := out.Context + actor := wc.GetOwnerPrincipalId() + if chain := wc.GetActorChain(); len(chain) > 0 { + actor = chain[len(chain)-1].GetPrincipalId() + } + if err := service.RecordAuthorityIssue(ctx, wc.GetTenantId(), actor, child, wc.GetTaskId()); err != nil { + return nil, status.Errorf(codes.Unavailable, "the issue could not be recorded, so it is not handed out: %v", err) } - wire, err := workContextToWire(decoded.Context()) + wire, err := workContextToWire(wc) if err != nil { return nil, status.Error(codes.Internal, err.Error()) } - return &authorityv1.IssueResponse{Token: issued.GetToken(), ExpiresAtUnix: issued.GetExpiresAt().GetSeconds(), Context: wire}, nil + return &authorityv1.IssueResponse{Token: out.Token, ExpiresAtUnix: wc.GetExpiresAtUnix(), Context: wire}, nil } -// requireComposed refuses, NotFound by name, an audience the composition does -// not declare: by the contract it does not exist, which is not the host's own -// "not one this host serves" denial. It runs only once the caller's shape is -// authenticated. -func requireComposed(audience string) error { - if service == nil || !service.IsDeclaredModulePrincipal(business.ModulePrincipalID(audience)) { - return status.Errorf(codes.NotFound, "audience %q is not composed", audience) +// ListResources answers which resources of one kind a checked capability +// covers for one action, decided now. +func (s *HostAuthorityServer) ListResources(ctx context.Context, req *authorityv1.ListResourcesRequest) (*authorityv1.ListResourcesResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - return nil -} - -// issueTTL is the lifetime an issue asks for, clamped to the issuer's ceiling -// as the interface states; zero leaves it to the mint. -func issueTTL(req *authorityv1.IssueRequest) (int32, error) { - ceiling := int64(workcontext.DefaultMaxTTL / time.Second) - switch seconds := req.GetTtlSeconds(); { - case seconds < 0: - return 0, status.Errorf(codes.InvalidArgument, "a lifetime is not negative: %d seconds", seconds) - case seconds > ceiling: - return int32(ceiling), nil - default: - return int32(seconds), nil + grants, err := core.ListResources(ctx, req.GetToken(), req.GetAudience(), req.GetResourceKind(), req.GetAction()) + if err != nil { + return nil, coreStatus(err) + } + out := &authorityv1.ListResourcesResponse{} + for _, g := range grants { + grant := &authorityv1.ResourceGrant{ResourceOwnerId: g.Owner} + switch { + case g.All: + grant.Match = &authorityv1.ResourceGrant_All{All: true} + case g.ID != "": + grant.Match = &authorityv1.ResourceGrant_Id{Id: g.ID} + case g.Prefix != "": + grant.Match = &authorityv1.ResourceGrant_Prefix{Prefix: g.Prefix} + default: + grant.Match = &authorityv1.ResourceGrant_Pattern{Pattern: g.Pattern} + } + out.Grants = append(out.Grants, grant) } + return out, nil } -// personIssue is a signed-in person's own capability at the audience. -func (s *HostAuthorityServer) personIssue(ctx context.Context, identity accountsauth.RequestIdentity, req *authorityv1.IssueRequest, scopes []*gen.WorkContextScope, ttl int32) (*gen.IssuedWorkContext, error) { - if err := requireOrgMember(ctx, identity.EffectiveSubject.String(), identity.OrgID.String()); err != nil { +// Delegate records the signed-in person's grant to a module, within what they hold. +func (s *HostAuthorityServer) Delegate(ctx context.Context, req *authorityv1.DelegateRequest) (*authorityv1.Delegation, error) { + core, err := authorityCore() + if err != nil { return nil, err } - // An audience the organisation never installed has no installation to seal - // a capability to, whatever would decide what the person holds there: that - // is the failed precondition, named, before a solution's roles read an - // installation that is not there and answer that the person holds nothing. - if _, err := s.workContexts.audienceInstallation(ctx, identity.OrgID.String(), req.GetAudience()); err != nil { + person, err := signedInPerson(ctx, "delegate") + if err != nil { return nil, err } - // A solution that declares roles decides what the person holds there. - authority, held, err := s.solutionRoles().PersonSolutionRoles(ctx, identity.OrgID.String(), identity.EffectiveSubject.String(), req.GetAudience()) + d, err := core.Delegate(ctx, person, req.GetModule(), scopesFromWire(req.GetScopes()), req.GetPurpose()) if err != nil { - return nil, interfaceStatus(err) + return nil, coreStatus(err) } - if authority != nil { - if binding := req.GetOperationBindingId(); binding != "" { - if operation := req.GetOperation(); operation != "" && operation != "invoke" { - return nil, status.Errorf(codes.InvalidArgument, "a person-owned issue carries invoke scopes for operation binding %q; operation %q is refused", binding, operation) - } - return s.solutionBindingIssue(ctx, identity.OrgID.String(), req.GetAudience(), binding, authority, held, scopes, ttl) - } - return s.solutionPersonIssue(ctx, identity.OrgID.String(), req.GetAudience(), authority, held, scopes, ttl) - } - var bindings []string - if binding := req.GetOperationBindingId(); binding != "" { - if operation := req.GetOperation(); operation != "" && operation != "invoke" { - return nil, status.Errorf(codes.InvalidArgument, "a person-owned issue carries invoke scopes for operation binding %q; operation %q is refused", binding, operation) - } - bindings = []string{binding} - } - if len(scopes) == 0 && len(bindings) == 0 { - // An empty ask is everything the person holds at the audience. - if scopes, err = s.workContexts.personHoldings(ctx, identity.OrgID.String(), req.GetAudience()); err != nil { - return nil, interfaceStatus(err) - } - } else if len(scopes) > 0 { - // An ask is met with what the person holds: what they do not hold of it - // is dropped, and an ask of which they hold nothing is refused. - if scopes, err = s.workContexts.personHeldOfAsk(ctx, identity.OrgID.String(), req.GetAudience(), scopes); err != nil { - return nil, interfaceStatus(err) - } - // Every kind of the audience the ask left out is dropped for the whole - // chain below this capability, not merely absent from it. - ctx = withDroppedKinds(ctx, droppedAudienceKinds(req.GetAudience(), scopes)) - } - // A fresh task per issue: the interface names no task, the host requires - // one as the identity tuple's third element. - return s.workContexts.StartTask(ctx, &gen.StartTaskWorkContextRequest{ - OrgId: identity.OrgID.String(), TaskId: uuid.NewString(), Audience: req.GetAudience(), AuthorityScopes: scopes, TtlSeconds: ttl, - OperationBindings: bindings, - }) + return delegationToWire(d), nil } -// exchangeOrRenew is one more issue from a capability, with no actor: the -// bearer of whoever holds it authorizes it. At another audience it is the -// host's audience exchange. At the parent's own audience it is a renewal, a -// fresh window on the same task: a delegated capability's current actor renews -// it (RenewWorkContext, which may narrow it), and an owner's own capability is -// re-issued as another root session of its task (StartRootSession), which -// keeps its authority as it stands and so takes no scopes. The organisation is -// the request's, or the parent's own when it names none. An empty ask is the -// parent's own authority: what its current actor holds. The parent's claims are -// only read to route the issue; each path verifies it. -func (s *HostAuthorityServer) exchangeOrRenew(ctx context.Context, parentToken string, req *authorityv1.IssueRequest, scopes []*gen.WorkContextScope, ttl int32) (*gen.IssuedWorkContext, error) { - if _, ok := accountsauth.VerifiedRequestIdentity(ctx); !ok { - return nil, status.Error(codes.Unauthenticated, "an exchange or a renewal is made by the capability's owner, signed in; the call carries no bearer") +// RevokeDelegation ends one of the signed-in person's delegations. +func (s *HostAuthorityServer) RevokeDelegation(ctx context.Context, req *authorityv1.RevokeDelegationRequest) (*authorityv1.RevokeDelegationResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - if err := requireComposed(req.GetAudience()); err != nil { + person, err := signedInPerson(ctx, "revoke a delegation") + if err != nil { return nil, err } - decoded, err := workcontext.Decode(parentToken) + d, err := core.RevokeDelegation(ctx, person, req.GetDelegationId()) if err != nil { - return nil, interfaceStatus(err) + return nil, coreStatus(err) + } + return &authorityv1.RevokeDelegationResponse{Delegation: delegationToWire(d)}, nil +} + +// GetDelegation reads one delegation for its module (by its workload +// credential) or its grantor (by their session); anyone else learns nothing. +func (s *HostAuthorityServer) GetDelegation(ctx context.Context, req *authorityv1.GetDelegationRequest) (*authorityv1.Delegation, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - parent := decoded.Context() - org := cmp.Or(req.GetOrganizationId(), parent.GetTenantId()) - if req.GetAudience() == parent.GetAudience() { - if len(parent.GetActorChain()) > 0 { - return s.workContexts.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ - OrgId: org, ParentWorkContextToken: parentToken, AttenuatedScopes: scopes, TtlSeconds: ttl, - }) + var d domain.Delegation + if credential := req.GetActor().GetCredential(); credential != "" { + w, werr := workload(ctx, credential, "actor") + if werr != nil { + return nil, werr } - if len(scopes) > 0 { - return nil, status.Errorf(codes.InvalidArgument, "a renewal of an owner's own capability at %s keeps its authority as it stands and names no scopes; an exchange narrows it", req.GetAudience()) + d, err = core.Delegation(ctx, req.GetDelegationId(), nil, &w) + } else { + person, perr := signedInPerson(ctx, "read a delegation") + if perr != nil { + return nil, perr } - // The request's session id is validated and never read: the issuer - // assigns the new session (StartRootSession). - return s.workContexts.StartRootSession(ctx, &gen.StartRootSessionWorkContextRequest{ - OrgId: org, ParentWorkContextToken: parentToken, SessionId: uuid.NewString(), Audience: req.GetAudience(), TtlSeconds: ttl, - }) + d, err = core.Delegation(ctx, req.GetDelegationId(), &person, nil) } - if len(scopes) == 0 { - scopes = hostScopesOf(effectiveScopes(parent)) + if err != nil { + return nil, coreStatus(err) } - return s.workContexts.ExchangeAudience(ctx, &gen.ExchangeWorkContextAudienceRequest{ - OrgId: org, ParentWorkContextToken: parentToken, Audience: req.GetAudience(), AttenuatedScopes: scopes, TtlSeconds: ttl, - }) + return delegationToWire(d), nil } -// credentialRefusal names why a workload credential did not prove a module, -// always with the reason: -// -// - the host could not establish what the caller runs: Unavailable, nothing -// was decided; -// - no credential, or the module's identity refused as itself (the pod it -// names no longer matches what its principal declares, its tenant moved): -// Unauthenticated, so the module drops what it holds and mints again, as -// the module tier answers it; -// - a token the review refused outright (forged, unknown): PermissionDenied, -// the code the shared conformance suite holds a forged workload credential -// to; -// - a workload declared by more than one principal: PermissionDenied, a -// composition fault no re-mint repairs. -func credentialRefusal(err error, which string) error { - switch { - case errors.Is(err, business.ErrExecutionUnbound): - return status.Errorf(codes.Unavailable, "%s credential: %v", which, err) - case errors.Is(err, business.ErrExecutionNotBoundToken), errors.Is(err, business.ErrModuleIdentityDenied): - return status.Errorf(codes.Unauthenticated, "%s credential refused: %v", which, err) - case errors.Is(err, business.ErrExecutionTokenRejected): - return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, business.ErrExecutionTokenRejected) - case errors.Is(err, business.ErrExecutionWorkloadAmbiguous): - return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, err) +// AttachResource records a resource created through the installation the +// presented capability was issued through; the kind's serving module asks. +func (s *HostAuthorityServer) AttachResource(ctx context.Context, req *authorityv1.AttachResourceRequest) (*authorityv1.AttachResourceResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, err) + w, err := workload(ctx, req.GetActor().GetCredential(), "actor") + if err != nil { + return nil, err + } + id, err := core.AttachResource(ctx, authority.AttachmentRequest{Token: req.GetToken(), Kind: req.GetResourceKind(), ResourceID: req.GetResourceId(), Store: w}) + if err != nil { + return nil, coreStatus(err) + } + return &authorityv1.AttachResourceResponse{InstallationId: id}, nil } -// bindingAudience is the audience of an issue that names none: an issue -// through a binding the acting module declares is addressed to that binding's -// audience, as the interface's callers send it (a hop's ForModule as the -// workload, its ForChild with the workload as actor). Any other shape names -// its audience. -func bindingAudience(ctx context.Context, req *authorityv1.IssueRequest) (string, error) { - var credential string - switch as := req.GetAs().(type) { - case *authorityv1.IssueRequest_Workload: - credential = as.Workload.GetCredential() - case *authorityv1.IssueRequest_ParentToken, *authorityv1.IssueRequest_Delegation: - credential = req.GetActor().GetCredential() - } - binding := req.GetOperationBindingId() - if credential == "" || binding == "" || service == nil { - return "", status.Error(codes.InvalidArgument, "an issue names its audience") +// DetachResource releases a resource its serving module deleted. +func (s *HostAuthorityServer) DetachResource(ctx context.Context, req *authorityv1.DetachResourceRequest) (*authorityv1.DetachResourceResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - prefix, err := service.ModulePrefixOfWorkload(ctx, credential) + w, err := workload(ctx, req.GetActor().GetCredential(), "actor") if err != nil { - return "", credentialRefusal(err, "workload") + return nil, err } - declared, ok := service.ModulePrincipals()[business.ModulePrincipalID(prefix)].OperationAudiences[binding] - if !ok || declared.Audience == "" { - return "", status.Errorf(codes.NotFound, "operation binding %q is not declared by %s", binding, prefix) + id, err := core.DetachResource(ctx, w, req.GetOrganizationId(), req.GetResourceKind(), req.GetResourceId()) + if err != nil { + return nil, coreStatus(err) } - return declared.Audience, nil + return &authorityv1.DetachResourceResponse{InstallationId: id}, nil } -// HostCompositionServer serves codefly.dev/composition. -type HostCompositionServer struct { - compositionv1.UnimplementedCompositionServiceServer -} - -// ApplyManifest authenticates the signed carrier at the delivery owner. -func (s *HostCompositionServer) ApplyManifest(ctx context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { - if service == nil { - return nil, status.Error(codes.Unavailable, "composition service is not wired") +// AuthorizeTransfer decides whether an existing resource joins a solution +// principal, and records it; the kind's serving module asks with the person's +// capability. +func (s *HostAuthorityServer) AuthorizeTransfer(ctx context.Context, req *authorityv1.AuthorizeTransferRequest) (*authorityv1.AuthorizeTransferResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err } - out, err := service.ApplyComposition(ctx, req) - if err == nil { - return out, nil + w, err := workload(ctx, req.GetActor().GetCredential(), "actor") + if err != nil { + return nil, err } - httpStatus, reason := solutionDeliveryStatus(err) - code := codes.Unavailable - switch httpStatus { - case http.StatusBadRequest: - code = codes.InvalidArgument - case http.StatusUnauthorized, http.StatusForbidden, http.StatusUnprocessableEntity: - code = codes.PermissionDenied - case http.StatusConflict: - code = codes.FailedPrecondition + d, err := core.AuthorizeTransfer(ctx, authority.TransferRequest{Token: req.GetToken(), Store: w, Kind: req.GetResourceKind(), + ResourceID: req.GetResourceId(), From: req.GetFromOwnerId(), To: req.GetToPrincipalId()}) + if err != nil { + return nil, coreStatus(err) } - return nil, status.Error(code, reason) + return &authorityv1.AuthorizeTransferResponse{PrincipalId: d.PrincipalID, AuthorizedBy: d.AuthorizedBy}, nil } -// HostInstallationServer serves codefly.dev/installation over the installation service. +// HostInstallationServer serves codefly.dev/installation. type HostInstallationServer struct { installationv1.UnimplementedInstallationServiceServer - installations *InstallationServer - service *business.Service } -// NewHostInstallationServer adapts the installation service. -func NewHostInstallationServer(installations *InstallationServer, service *business.Service) *HostInstallationServer { - return &HostInstallationServer{installations: installations, service: service} +// Current reads only the authenticated module's own active installation, in +// the tenant of a current parent addressed to it: the module proves itself by +// its workload credential and the core verifies the parent. It needs no +// session and exposes no roles or grants. +func (s *HostInstallationServer) Current(ctx context.Context, req *installationv1.CurrentInstallationRequest) (*installationv1.CurrentInstallation, error) { + core, err := authorityCore() + if err != nil { + return nil, err + } + // Current answers a module about itself alone, so a credential that proves + // no module is refused the read, whatever made it fail to. + actor, err := workload(ctx, req.GetActorCredential(), "the actor") + if err != nil { + return nil, status.Error(codes.PermissionDenied, status.Convert(err).Message()) + } + inst, err := core.CurrentInstallation(ctx, req.GetParentToken(), actor) + if err != nil { + return nil, coreStatus(err) + } + return &installationv1.CurrentInstallation{Id: inst.ID, OrganizationId: inst.Organization, Target: inst.Target, + Revision: inst.Revision, Namespace: inst.Organization + "." + inst.Target}, nil } -// List answers what one organisation has installed: an administrator's read, -// over the host's organisation-installation listing. -func (s *HostInstallationServer) List(ctx context.Context, req *installationv1.ListRequest) (*installationv1.ListResponse, error) { - actorID, err := requireAuth(ctx) +// Review reads the exact authority offer an administrator consents to, +// without recording consent. +func (s *HostInstallationServer) Review(ctx context.Context, req *installationv1.ReviewRequest) (*installationv1.ConsentApproval, error) { + core, err := authorityCore() if err != nil { return nil, err } - if err := requireOrgAdmin(ctx, actorID, req.GetOrganizationId()); err != nil { + caller, err := signedInPerson(ctx, "review an installation") + if err != nil { return nil, err } - resp := &installationv1.ListResponse{} - for token := ""; ; { - out, err := s.installations.ListOrganizationInstallations(ctx, &gen.ListOrganizationInstallationsRequest{OrgId: req.GetOrganizationId(), PageToken: token}) - if err != nil { - return nil, err - } - for _, row := range out.GetInstallations() { - details, err := s.service.ReadHostInstallation(ctx, req.GetOrganizationId(), row.GetInstallation().GetId()) - if err != nil { - return nil, mapInstallationError(err) - } - resp.Installations = append(resp.Installations, hostInstallationToWire(details)) - } - if out.GetNextPageToken() == "" { - break - } - token = out.GetNextPageToken() + if err := requireOrgAdmin(ctx, caller.ID, req.GetOrganizationId()); err != nil { + return nil, err + } + a, err := core.Review(ctx, caller, req.GetOrganizationId(), req.GetTarget()) + if err != nil { + return nil, coreStatus(err) } - return resp, nil + return &installationv1.ConsentApproval{TargetId: a.TargetID, Generation: a.Generation, ExpectedRevision: a.ExpectedRevision, Digest: a.Digest}, nil } -// Install names the target and the role by name; the host's resolver -// (InstallTargetByName) reads them from the organisation's catalogue and role -// catalogue and installs through the host's own install path. A module -// re-install explicitly approves its current ceiling. A solution the -// organisation already holds keeps its existing installation lifecycle. +// Install installs a solution at the caller's organization under the consent +// they reviewed; nobody holds a role on it until granted one. func (s *HostInstallationServer) Install(ctx context.Context, req *installationv1.InstallRequest) (*installationv1.Installation, error) { - actorID, err := requireAuth(ctx) + core, err := authorityCore() if err != nil { return nil, err } - if err := requireOrgAdmin(ctx, actorID, req.GetOrganizationId()); err != nil { + caller, err := signedInPerson(ctx, "install") + if err != nil { return nil, err } - approval, err := installationApproval(req.GetApproval()) + if err := requireOrgAdmin(ctx, caller.ID, req.GetOrganizationId()); err != nil { + return nil, err + } + var approval *domain.ConsentApproval + if a := req.GetApproval(); a != nil { + approval = &domain.ConsentApproval{TargetID: a.GetTargetId(), Generation: a.GetGeneration(), ExpectedRevision: a.GetExpectedRevision(), + OperationID: a.GetOperationId(), Digest: a.GetDigest()} + } + inst, err := core.Install(ctx, caller, req.GetOrganizationId(), req.GetTarget(), req.GetRole(), req.GetOwnerPersonId(), approval, service.AuthorityInstallStep()) + if err != nil { + return nil, coreStatus(err) + } + return installationToWire(core, ctx, inst), nil +} + +// GrantRole grants a person or a team one of the solution's roles. +func (s *HostInstallationServer) GrantRole(ctx context.Context, req *installationv1.GrantRoleRequest) (*installationv1.Installation, error) { + return s.change(ctx, "grant a role", (*authority.Service).GrantRole, authority.RoleRequest{Organization: req.GetOrganizationId(), + InstallationID: req.GetInstallationId(), Role: req.GetRole(), PersonID: req.GetPersonId(), TeamID: req.GetTeamId()}) +} + +// RevokeRole ends one grant; its subject's capabilities through the installation end with it. +func (s *HostInstallationServer) RevokeRole(ctx context.Context, req *installationv1.RevokeRoleRequest) (*installationv1.Installation, error) { + return s.change(ctx, "revoke a role", (*authority.Service).RevokeRole, authority.RoleRequest{Organization: req.GetOrganizationId(), + InstallationID: req.GetInstallationId(), Role: req.GetRole(), PersonID: req.GetPersonId(), TeamID: req.GetTeamId()}) +} + +func (s *HostInstallationServer) change(ctx context.Context, what string, do func(*authority.Service, context.Context, authority.Person, authority.RoleRequest) (domain.Installation, error), r authority.RoleRequest) (*installationv1.Installation, error) { + core, err := authorityCore() if err != nil { return nil, err } - installation, installed, err := s.service.InstallTargetByName(ctx, actorID, req.GetOrganizationId(), req.GetTarget(), req.GetRole(), req.GetOwnerPersonId(), approval) - switch { - case errors.Is(err, business.ErrInstallTargetNotPresent): - return nil, status.Error(codes.FailedPrecondition, err.Error()) - case err != nil: - return nil, mapInstallationError(err) - case !installed: - return nil, status.Errorf(codes.PermissionDenied, "%q is already installed in organization %s, as installation %s", req.GetTarget(), req.GetOrganizationId(), installation.GetId()) + caller, err := signedInPerson(ctx, what) + if err != nil { + return nil, err } - details, err := s.service.ReadHostInstallation(ctx, req.GetOrganizationId(), installation.GetId()) + inst, err := do(core, ctx, caller, r) if err != nil { - return nil, mapInstallationError(err) + return nil, coreStatus(err) + } + return installationToWire(core, ctx, inst), nil +} + +// ListRoleGrants answers who holds which role on an installation. +func (s *HostInstallationServer) ListRoleGrants(ctx context.Context, req *installationv1.ListRoleGrantsRequest) (*installationv1.ListRoleGrantsResponse, error) { + core, err := authorityCore() + if err != nil { + return nil, err + } + caller, err := signedInPerson(ctx, "list role grants") + if err != nil { + return nil, err + } + grants, err := core.ListRoleGrants(ctx, caller, req.GetOrganizationId(), req.GetInstallationId()) + if err != nil { + return nil, coreStatus(err) + } + out := &installationv1.ListRoleGrantsResponse{} + for _, g := range grants { + out.Grants = append(out.Grants, &installationv1.RoleGrant{Role: g.Role, PersonId: g.PersonID, TeamId: g.TeamID}) } - out := hostInstallationToWire(details) - out.Target = req.GetTarget() return out, nil } -// GrantToTeam resolves the installation's standing role through the grant owner. -func (s *HostInstallationServer) GrantToTeam(ctx context.Context, req *installationv1.GrantToTeamRequest) (*installationv1.Installation, error) { - actorID, err := requireAuth(ctx) +// List answers what one organization has installed, for its administrator. +func (s *HostInstallationServer) List(ctx context.Context, req *installationv1.ListRequest) (*installationv1.ListResponse, error) { + core, err := authorityCore() if err != nil { return nil, err } - if err := requireOrgAdmin(ctx, actorID, req.GetOrganizationId()); err != nil { + caller, err := signedInPerson(ctx, "list installations") + if err != nil { return nil, err } - // Only an active installation is exposed: one still pending its - // acknowledgement, or revoked, is refused naming which it is. - current, err := s.service.ReadHostInstallation(ctx, req.GetOrganizationId(), req.GetInstallationId()) + if err := requireOrgAdmin(ctx, caller.ID, req.GetOrganizationId()); err != nil { + return nil, err + } + all, err := core.Installations(ctx, caller, req.GetOrganizationId()) if err != nil { - return nil, mapInstallationError(err) + return nil, coreStatus(err) } - if st := current.Installation.GetStatus(); st != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE { - return nil, status.Errorf(codes.PermissionDenied, "installation %s is %s; only an active installation can be exposed", req.GetInstallationId(), installationStatusWord(st)) + slices.SortFunc(all, func(a, b domain.Installation) int { return strings.Compare(a.ID, b.ID) }) + out := &installationv1.ListResponse{} + for _, inst := range all { + out.Installations = append(out.Installations, installationToWire(core, ctx, inst)) } - details, err := s.service.ExposeHostInstallation(ctx, actorID, req.GetOrganizationId(), req.GetInstallationId(), req.GetTeamId()) + return out, nil +} + +// installationToWire is an installation as the interface states it, with the +// roles its solution declares. +func installationToWire(core *authority.Service, ctx context.Context, i domain.Installation) *installationv1.Installation { + st := map[domain.Status]installationv1.Installation_Status{ + domain.Pending: installationv1.Installation_STATUS_PENDING, domain.Active: installationv1.Installation_STATUS_ACTIVE, domain.Revoked: installationv1.Installation_STATUS_REVOKED, + }[i.Status] + out := &installationv1.Installation{Id: i.ID, OrganizationId: i.Organization, Target: i.Target, Status: st, Revision: i.Revision, + Role: i.AgentRole, PrincipalId: i.PrincipalID, Namespace: i.Organization + "." + i.Target} + if m, err := core.Composed(ctx, i.Target); err == nil { + for name := range m.Roles { + out.Roles = append(out.Roles, name) + } + slices.Sort(out.Roles) + } + return out +} + +func delegationToWire(d domain.Delegation) *authorityv1.Delegation { + return &authorityv1.Delegation{DelegationId: d.ID, OrganizationId: d.Organization, Grantor: d.Grantor, Module: d.Module, Purpose: d.Purpose, + Active: !d.Revoked, Scopes: scopesToWire(d.Scopes)} +} + +// scopesFromWire converts interface scopes to core's, byte for byte. +func scopesFromWire(in []*authorityv1.WorkScopeV1) []*basev0.WorkScopeV1 { + out := make([]*basev0.WorkScopeV1, 0, len(in)) + for _, sc := range in { + raw, _ := proto.Marshal(sc) + c := &basev0.WorkScopeV1{} + _ = proto.Unmarshal(raw, c) + out = append(out, c) + } + return out +} + +// scopesToWire converts core's scopes to the interface's, byte for byte. +func scopesToWire(in []*basev0.WorkScopeV1) []*authorityv1.WorkScopeV1 { + out := make([]*authorityv1.WorkScopeV1, 0, len(in)) + for _, sc := range in { + raw, _ := proto.Marshal(sc) + c := &authorityv1.WorkScopeV1{} + _ = proto.Unmarshal(raw, c) + out = append(out, c) + } + return out +} + +func workContextToWire(in proto.Message) (*authorityv1.WorkContextV1, error) { + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(in) if err != nil { - return nil, mapInstallationError(err) + return nil, err } - return hostInstallationToWire(details), nil + out := &authorityv1.WorkContextV1{} + return out, proto.Unmarshal(raw, out) } -// installationStatusWord is an installation's status as a refusal names it. -func installationStatusWord(st gen.InstallationStatus) string { - switch st { - case gen.InstallationStatus_INSTALLATION_STATUS_PENDING: - return "pending" - case gen.InstallationStatus_INSTALLATION_STATUS_REVOKED: - return "revoked" - case gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE: - return "active" +// credentialRefusal names why a workload credential did not prove a module, +// always with the reason: +// +// - the host could not establish what the caller runs: Unavailable, nothing +// was decided; +// - no credential, or the module's identity refused as itself (the pod it +// names no longer matches what its principal declares, its tenant moved): +// Unauthenticated, so the module drops what it holds and mints again, as +// the module tier answers it; +// - a token the review refused outright (forged, unknown): PermissionDenied, +// the code the shared conformance suite holds a forged workload credential +// to; +// - a workload declared by more than one principal: PermissionDenied, a +// composition fault no re-mint repairs. +func credentialRefusal(err error, which string) error { + switch { + case errors.Is(err, business.ErrExecutionUnbound): + return status.Errorf(codes.Unavailable, "%s credential: %v", which, err) + case errors.Is(err, business.ErrExecutionNotBoundToken), errors.Is(err, business.ErrModuleIdentityDenied): + return status.Errorf(codes.Unauthenticated, "%s credential refused: %v", which, err) + case errors.Is(err, business.ErrExecutionTokenRejected): + return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, business.ErrExecutionTokenRejected) + case errors.Is(err, business.ErrExecutionWorkloadAmbiguous): + return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, err) } - return "not active" + return status.Errorf(codes.PermissionDenied, "%s credential refused: %v", which, err) } // HostSessionServer serves codefly.dev/session over fixture authentication. @@ -672,115 +779,13 @@ func accessClaimsOf(token string) (accessClaims, error) { return claims, json.Unmarshal(raw, &claims) } -// hostScopesOf is core scopes in the host's own request shape. -func hostScopesOf(in []*basev0.WorkScopeV1) []*gen.WorkContextScope { - out := make([]*gen.WorkContextScope, 0, len(in)) - for _, sc := range in { - out = append(out, &gen.WorkContextScope{ResourceKind: sc.GetResourceKind(), Actions: sc.GetActions(), ResourceIds: sc.GetResourceIds()}) - } - return out -} - -func scopesToHost(in []*authorityv1.WorkScopeV1) []*gen.WorkContextScope { - out := make([]*gen.WorkContextScope, 0, len(in)) - for _, sc := range in { - out = append(out, &gen.WorkContextScope{ResourceKind: sc.GetResourceKind(), Actions: sc.GetActions(), ResourceIds: sc.GetResourceIds()}) - } - return out -} - -func workContextToWire(in proto.Message) (*authorityv1.WorkContextV1, error) { - raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(in) - if err != nil { - return nil, err - } - out := &authorityv1.WorkContextV1{} - return out, proto.Unmarshal(raw, out) -} - -// installationToWire is an installation as the interface states it. Its target -// is the name the composition gives what is installed (the presence's route -// alias, "notes"), the name Install takes and a frontend binds to, never this -// host's row id for the presence; a row whose presence is gone keeps the id. -func installationToWire(in *gen.Installation, target string) *installationv1.Installation { - st := installationv1.Installation_STATUS_UNSPECIFIED - switch in.GetStatus() { - case gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE: - st = installationv1.Installation_STATUS_ACTIVE - case gen.InstallationStatus_INSTALLATION_STATUS_PENDING: - st = installationv1.Installation_STATUS_PENDING - case gen.InstallationStatus_INSTALLATION_STATUS_REVOKED: - st = installationv1.Installation_STATUS_REVOKED - } - return &installationv1.Installation{ - Id: in.GetId(), OrganizationId: in.GetOrgId(), Target: cmp.Or(target, in.GetTargetId()), Status: st, - AgentPrincipalId: in.GetAgentPrincipalId(), - } -} - -// interfaceStatus maps a refusal to a status naming its reason, as the suite -// requires: every refusal reaches the caller. +// interfaceStatus maps a session refusal to a status naming its reason. func interfaceStatus(err error) error { if _, ok := status.FromError(err); ok { return err } - switch { - case errors.Is(err, accountsauth.ErrUnknownIdentity): - return status.Error(codes.NotFound, err.Error()) - case errors.Is(err, workcontext.ErrRevoked), errors.Is(err, workcontext.ErrNoSeal), - // The issuer looked and its owner no longer stands: a person who left, - // an installation revoked under its agent. A decision, not an outage. - errors.Is(err, business.ErrWorkContextPrincipalUnknown), errors.Is(err, business.ErrWorkContextSealUnheld): - return status.Error(codes.PermissionDenied, err.Error()) - case errors.Is(err, workcontext.ErrInvalid), errors.Is(err, workcontext.ErrNotACoreToken), errors.Is(err, workcontext.ErrReplayed): - return status.Error(codes.Unauthenticated, err.Error()) - case errors.Is(err, workcontext.ErrNoBinding): + if errors.Is(err, accountsauth.ErrUnknownIdentity) { return status.Error(codes.NotFound, err.Error()) } return status.Error(codes.Internal, fmt.Sprintf("%v", err)) } - -// childHop derives a module's hop on a context addressed to it behind the -// interface: the parent token is the authentication, the actor's credential -// names the module, and the hop names the binding it exercises (bindingHop: -// the owner's current grants through that binding). There is one rule for a -// module hop: a hop naming no binding is refused, as is a parent addressed to -// another module, both before the parent is verified, so a refused hop spends -// no single-use parent. -func (s *HostAuthorityServer) childHop(ctx context.Context, parentToken string, req *authorityv1.IssueRequest) (*gen.IssuedWorkContext, error) { - prefix, err := service.ModulePrefixOfWorkload(ctx, req.GetActor().GetCredential()) - if err != nil { - return nil, credentialRefusal(err, "actor") - } - if err := requireComposed(req.GetAudience()); err != nil { - return nil, err - } - if req.GetOperationBindingId() == "" { - return nil, status.Errorf(codes.PermissionDenied, "a module's hop names the binding it exercises; %s named none", prefix) - } - // The parent's claims are only read to refuse it; nothing is issued on them. - if decoded, derr := workcontext.Decode(parentToken); derr == nil && decoded.Context().GetAudience() != prefix { - return nil, status.Errorf(codes.PermissionDenied, "%s hops only on a token addressed to it; this one is addressed to %s", prefix, decoded.Context().GetAudience()) - } - if err := requireDeclaredHopBinding(prefix, req); err != nil { - return nil, err - } - // The audience guards every mint runs, before the parent is spent. - if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { - return nil, err - } - parent, err := s.workContexts.verifyFor(ctx, parentToken, prefix) - if err != nil { - return nil, interfaceStatus(err) - } - if err := s.workContexts.requireModuleChainStands(ctx, parent.Context()); err != nil { - return nil, err - } - return s.bindingHop(ctx, prefix, parent, req) -} - -func hostInstallationToWire(details *business.HostInstallationDetails) *installationv1.Installation { - out := installationToWire(details.Installation, details.Target) - out.Role, out.Revision, out.TeamIds = details.Role, details.Revision, details.TeamIDs - return out -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_actor_name_test.go b/module/services/accounts/code/pkg/adapters/host_interface_actor_name_test.go deleted file mode 100644 index 1dff2f5ef..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_actor_name_test.go +++ /dev/null @@ -1,326 +0,0 @@ -package adapters - -import ( - "context" - "strings" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "accounts/pkg/business" - "accounts/pkg/business/moduleregistrytest" - authorityv1 "accounts/pkg/gen/host/authority/v1" - - "google.golang.org/grpc/codes" -) - -// A hop names its actor by the module's NAME (the host interface's decision): -// the binding it exercises is answered as granted to that name, and every -// comparison a verifier makes against it holds one module to its own binding. -// These are the comparisons, each with the module that must not pass it. - -// mintExercising mints, through the issuer's own path (so core's binding check -// runs against this host's answer), a capability owned by owner that exercises -// the tools module's "records" binding: by a hop whose actor is actor, or by -// the owner itself when actor is empty. -func mintExercising(t *testing.T, f hopFixture, owner, ownerKind, actor string) error { - t.Helper() - sealed := business.ModuleOperationBindingID(moduleWorkContextTenant, "tools", "records") - binding, err := service.WorkContextOperationBinding(context.Background(), moduleWorkContextTenant, sealed) - require.NoError(t, err) - scopes := []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})} - start := workcontext.StartInput{ - TenantID: moduleWorkContextTenant, OwnerPrincipalID: owner, OwnerPrincipalKind: ownerKind, - OrganizationID: moduleWorkContextTenant, TaskID: uuid.NewString(), Audience: "documents", - AuthorityScopes: scopes, InstallationID: binding.InstallationID, - } - request := mintRequest{Start: start, Replay: workcontext.ReplayIdempotent} - if actor == "" { - request.Start.OperationBindingID = sealed - } else { - request.Hops = []delegationHop{{ - PrincipalID: actor, PrincipalKind: business.PrincipalKindService, DelegationID: uuid.NewString(), - GrantedScopes: scopes, OperationBindingID: sealed, - }} - } - _, _, err = f.issuer.mint(context.Background(), request) - return err -} - -func TestAHopNamesItsActorByTheModuleNameAndJournalsItsRegistryID(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - journal := &moduleActorJournal{} - f.issuer.journal = journal - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, "tools", wc.GetActorChain()[0].GetPrincipalId()) - - // The journal's principal columns are ids: the same module, stored by the - // identity storage holds it under, never a name the column would drop. - require.Len(t, journal.hops, 1) - require.Equal(t, business.ModulePrincipalID("tools"), journal.hops[0].ActorPrincipalID) - require.Equal(t, wc.GetActorChain()[0].GetDelegationId(), journal.hops[0].ID) -} - -// The operation-binding principal core compares to the hop: the binding is -// granted to the module's name, so only that module, by that name, exercises it. -func TestOnlyTheBindingsModuleByNameExercisesItThroughAHop(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - sealed := business.ModuleOperationBindingID(moduleWorkContextTenant, "tools", "records") - binding, err := service.WorkContextOperationBinding(context.Background(), moduleWorkContextTenant, sealed) - require.NoError(t, err) - require.Equal(t, "tools", binding.PrincipalID) - require.Equal(t, business.ModulePrincipalID("tools"), binding.ModuleRegistryID) - - require.NoError(t, mintExercising(t, f, hopOwner, business.PrincipalKindHuman, "tools")) - for _, impostor := range []string{ - "documents", // another declared module, by name - business.ModulePrincipalID("documents"), // another declared module, by id - business.ModulePrincipalID("tools"), // this module, by the spelling a hop does not use - "Tools", // a name that is not declared - } { - err := mintExercising(t, f, hopOwner, business.PrincipalKindHuman, impostor) - require.Error(t, err, "a hop actored by %q exercised tools's binding", impostor) - } -} - -// A module's own headless context is owned by its registry id; the binding is -// answered in that owner's spelling only when the owner is the binding's module. -func TestOnlyTheBindingsModuleOwnsAHeadlessContextOnIt(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - require.NoError(t, mintExercising(t, f, business.ModulePrincipalID("tools"), business.PrincipalKindService, "")) - require.Error(t, mintExercising(t, f, business.ModulePrincipalID("documents"), business.PrincipalKindService, ""), - "another module's headless context exercised tools's binding") - - // The answer core asks for while verifying, as the remote verifier reads it. - sealed := business.ModuleOperationBindingID(moduleWorkContextTenant, "tools", "records") - for owner, want := range map[string]string{ - hopOwner: "tools", - business.ModulePrincipalID("tools"): business.ModulePrincipalID("tools"), - business.ModulePrincipalID("documents"): "tools", - } { - seals, _, _ := f.issuer.moduleAuthoritySources(moduleWorkContextTenant, owner) - live, err := seals.OperationBinding(context.Background(), sealed) - require.NoError(t, err) - require.Equal(t, want, live.PrincipalID, "binding answered to owner %s", owner) - } -} - -// perModuleAuthority answers each module its own installation, so a seal read -// under the wrong module is told apart from the right one. -type perModuleAuthority struct{ currentModuleAuthority } - -func (f perModuleAuthority) LiveModuleAuthority(ctx context.Context, principal, org, installation string) (*business.LiveModuleAuthority, error) { - live, err := f.currentModuleAuthority.LiveModuleAuthority(ctx, principal, org, installation) - if err != nil { - return nil, err - } - live.InstallationID = "installation-" + principal - if installation != "" && installation != live.InstallationID { - return nil, business.ErrModuleInstallationInactive - } - return live, nil -} - -// The seal and epoch answers: a module's name resolves to that module, and to -// no other module's installation. -func TestSealAndEpochAnswerAModuleByItsName(t *testing.T) { - newHopFixture(t, "tool.items:read", "example-records:read") - service.SetModuleAuthorityReads(perModuleAuthority{currentModuleAuthority{service: service}}, nil) - ctx := context.Background() - installation, revision, err := service.WorkContextSeal(ctx, moduleWorkContextTenant, "tools", "") - require.NoError(t, err) - byID, byIDRevision, err := service.WorkContextSeal(ctx, moduleWorkContextTenant, business.ModulePrincipalID("tools"), "") - require.NoError(t, err) - require.Equal(t, "installation-"+business.ModulePrincipalID("tools"), installation) - require.Equal(t, byID, installation) - require.Equal(t, byIDRevision, revision) - - _, _, err = service.WorkContextSeal(ctx, moduleWorkContextTenant, "documents", installation) - require.Error(t, err, "documents was answered tools's installation") - - epoch, err := service.WorkContextPrincipalEpoch(ctx, moduleWorkContextTenant, "tools") - require.NoError(t, err) - byIDEpoch, err := service.WorkContextPrincipalEpoch(ctx, moduleWorkContextTenant, business.ModulePrincipalID("tools")) - require.NoError(t, err) - require.Equal(t, byIDEpoch, epoch) - - require.True(t, service.IsDeclaredModulePrincipal("tools")) - require.False(t, service.IsDeclaredModulePrincipal("Tools")) - require.False(t, service.IsDeclaredModulePrincipal(uuid.NewString())) -} - -// The revision check a consumer runs on a hop resolves the module by name and -// holds an undeclared name to nothing. -func TestARevisionCheckConfirmsAHopByItsModulesName(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - wc := claims(t, out) - scopes := operationScopesFromCore(wc.GetActorChain()[0].GetGrantedScopes()) - sealed := wc.GetOperationBinding().GetBindingId() - require.NoError(t, service.ConfirmModuleHopBinding(context.Background(), moduleWorkContextTenant, "tools", sealed, scopes)) - require.ErrorIs(t, service.ConfirmModuleHopBinding(context.Background(), moduleWorkContextTenant, "not-composed", sealed, scopes), business.ErrModuleHopStale) -} - -// recompose replaces the composition with hopPrincipals as edited, the way a -// new delivery or envelope narrows a binding while capabilities are live. -func recompose(t *testing.T, edit func(string) string) { - t.Helper() - registry, err := moduleregistrytest.Parse(edit(hopPrincipals)) - require.NoError(t, err) - service.SetModuleCapabilities(nil, nil, registry) -} - -// A binding narrowed or withdrawn UPSTREAM of the last hop takes effect on the -// child below it: core re-reads only the binding the child seals (the last -// hop's), so every hop is re-read against the binding it went through. -func TestABindingNarrowedUpstreamEndsTheChildBelowIt(t *testing.T) { - for name, edit := range map[string]func(string) string{ - "narrowed": func(s string) string { - s = strings.Replace(s, `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke","read"]}]`, `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke"]}]`, 1) - return strings.Replace(s, `,"lookup_scopes":[{"resource_kind":"example-records","actions":["read"]}]`, ``, 1) - }, - "withdrawn": func(s string) string { - return strings.Replace(s, `"audience":"documents"`, `"audience":"elsewhere"`, 1) - }, - } { - t.Run(name, func(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - first, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - second, err := f.hop(first.GetToken(), f.docs, "items", "tools") - require.NoError(t, err) - check := func() error { - _, err := f.server.Check(context.Background(), &authorityv1.CheckRequest{Token: second.GetToken(), Audience: "tools"}) - return err - } - require.NoError(t, check()) - wc := claims(t, second) - chain := []business.ModuleChainHop{} - for _, hop := range wc.GetActorChain() { - recorded, err := f.issuer.journal.(business.ActorChainHopBindingReader).ActorChainHopBindings(context.Background(), moduleWorkContextTenant, []string{hop.GetDelegationId()}) - require.NoError(t, err) - chain = append(chain, business.ModuleChainHop{Principal: hop.GetPrincipalId(), Scopes: operationScopesFromCore(hop.GetGrantedScopes()), Through: recorded[hop.GetDelegationId()]}) - } - require.NoError(t, service.ConfirmModuleChain(context.Background(), moduleWorkContextTenant, "", chain)) - - // tools's binding to documents moves; documents' binding back, the - // one the child seals, does not. - recompose(t, edit) - requireRefused(t, check(), codes.PermissionDenied, "no longer grants") - require.ErrorIs(t, service.ConfirmModuleChain(context.Background(), moduleWorkContextTenant, "", chain), business.ErrModuleHopStale) - _, err = f.hop(second.GetToken(), f.tool, "records", "documents") - require.Error(t, err, "a hop on a child whose upstream binding moved was issued") - }) - } -} - -// The installation issue reads the same pin rule: a pinned solution with -// cross_tenant still names only its own organisation. -func TestAnInstallationIssueNeverLeavesItsSolutionsPin(t *testing.T) { - installModuleWorkContextService(t, strings.Replace(hopPrincipals, `"resources":["tool.items"]`, `"cross_tenant":true,"resources":["tool.items"]`, 1)) - _, err := installationOrganization("tools", "00000000-0000-7000-8000-0000000000b2") - requireRefused(t, err, codes.PermissionDenied, "acts in organization") - org, err := installationOrganization("tools", moduleWorkContextTenant) - require.NoError(t, err) - require.Equal(t, moduleWorkContextTenant, org) -} - -// bindingJournal records hops, and answers the binding each went through, as -// the actor-chain journal does. -type bindingJournal struct { - business.ActorChainJournal - hops map[string]business.ActorChainHopInput -} - -// AppendActorChainHop keeps the first row under an id, as Postgres does -// (ON CONFLICT (id) DO NOTHING): a second append under the same id is absorbed, -// never recorded over the first. -func (j *bindingJournal) AppendActorChainHop(_ context.Context, hop business.ActorChainHopInput) (*business.ActorChainHop, error) { - if _, kept := j.hops[hop.ID]; !kept { - j.hops[hop.ID] = hop - } - return &business.ActorChainHop{}, nil -} - -func (j *bindingJournal) AnyActorChainHopRevoked(context.Context, string, []string) (bool, error) { - return false, nil -} - -func (j *bindingJournal) ActorChainHopBindings(_ context.Context, _ string, ids []string) (map[string]business.ActorChainHopBinding, error) { - out := map[string]business.ActorChainHopBinding{} - for _, id := range ids { - if hop, ok := j.hops[id]; ok && hop.OperationBindingID != "" { - out[id] = business.ActorChainHopBinding{BindingID: hop.OperationBindingID, Operation: hop.Operation, GrantedScopes: hop.GrantedScopes} - } - } - return out, nil -} - -// A hop upstream in a chain is held to the scope set it was issued under, not -// to the union of its binding's sets (the last hop's own binding is re-read by -// core, whose revision moves with any change to it). Lookup and headless lie within invoke by declaration, so the -// union was invoke itself: a lookup hop stood for as long as invoke granted its -// action, though the binding's lookup had been withdrawn. Now withdrawing -// lookup ends a lookup hop and leaves an invoke hop standing, and narrowing -// invoke ends an invoke hop. -func TestAHopIsHeldToTheScopeSetItWasIssuedUnder(t *testing.T) { - withdrawLookup := func(s string) string { - return strings.Replace(s, `,"lookup_scopes":[{"resource_kind":"example-records","actions":["read"]}]`, ``, 1) - } - dropInvokeRead := func(s string) string { - return strings.Replace(withdrawLookup(s), `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke","read"]}]`, `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke"]}]`, 1) - } - for name, tc := range map[string]struct { - operation string - edit func(string) string - stands bool - }{ - "lookup hop, lookup withdrawn while invoke still grants read": {"lookup", withdrawLookup, false}, - "invoke hop, lookup withdrawn": {"", withdrawLookup, true}, - "invoke hop, invoke narrowed": {"", dropInvokeRead, false}, - "lookup hop, invoke narrowed": {"lookup", dropInvokeRead, false}, - } { - t.Run(name, func(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - journal := &bindingJournal{hops: map[string]business.ActorChainHopInput{}} - f.issuer.journal = journal - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - out, err := f.server.Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, Actor: &authorityv1.WorkloadCredential{Credential: f.tool}, - OperationBindingId: "records", Operation: tc.operation, Audience: "documents", - Scopes: []*authorityv1.WorkScopeV1{ask("example-records", []string{"read"})}, - }) - require.NoError(t, err) - hop := claims(t, out).GetActorChain()[0] - recorded := journal.hops[hop.GetDelegationId()] - require.Equal(t, moduleWorkContextTenant+"/tools/records", recorded.OperationBindingID) - require.Equal(t, hopOperation(tc.operation), recorded.Operation) - - // The child below it seals documents' binding back to tools, which - // does not move: only the re-read of the upstream hop can end it. - below, err := f.hop(out.GetToken(), f.docs, "items", "tools") - require.NoError(t, err) - check := func() error { - _, err := f.server.Check(context.Background(), &authorityv1.CheckRequest{Token: below.GetToken(), Audience: "tools"}) - return err - } - require.NoError(t, check()) - recompose(t, tc.edit) - if tc.stands { - require.NoError(t, check()) - return - } - requireRefused(t, check(), codes.PermissionDenied, "no longer grants") - }) - } -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_admission.go b/module/services/accounts/code/pkg/adapters/host_interface_admission.go index 2eb31690d..b26797fa4 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_admission.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_admission.go @@ -1,9 +1,9 @@ package adapters -// What authority 0.3.x adds to the interface: the hop's admission keys -// (0.3.2, 0.3.3), served; and installation-owned resources (0.3.0), which -// this host does not record yet, so each of those methods and an -// owner-selected ask is refused by name rather than read as something wider. +// The hop's admission keys (codefly.dev/authority 0.3.2 through 0.3.4): the hop +// registers the key it signs call admissions with under its workload +// credential, and Keys publishes it apart from the Work Context key. The +// leased registrations are accounts' own store (hop_admission_keys). import ( "context" @@ -26,7 +26,7 @@ const ( // admissionKeys registers and publishes the hop's admission keys. type admissionKeys interface { - RegisterAdmissionKey(ctx context.Context, credential string, public []byte) (business.AdmissionKey, error) + RegisterAdmissionKey(ctx context.Context, credential string, public []byte, proof string) (business.AdmissionKey, error) PublishedAdmissionKeys(ctx context.Context) ([]business.AdmissionKey, error) } @@ -48,10 +48,10 @@ func (s *HostAuthorityServer) RegisterAdmissionKey(ctx context.Context, req *aut if keys == nil { return nil, status.Error(codes.Unavailable, business.ErrAdmissionKeysUnwired.Error()) } - key, err := keys.RegisterAdmissionKey(ctx, req.GetActor().GetCredential(), req.GetPublicKey()) + key, err := keys.RegisterAdmissionKey(ctx, req.GetActor().GetCredential(), req.GetPublicKey(), req.GetProof()) switch { case err == nil: - case errors.Is(err, business.ErrAdmissionKeyNotTheHop): + case errors.Is(err, business.ErrAdmissionKeyNotTheHop), errors.Is(err, business.ErrAdmissionKeyProof), errors.Is(err, business.ErrAdmissionKeyPinned): return nil, status.Error(codes.PermissionDenied, err.Error()) case errors.Is(err, business.ErrAdmissionKeyMalformed): return nil, status.Error(codes.InvalidArgument, err.Error()) @@ -92,33 +92,3 @@ func admissionKeyToWire(key business.AdmissionKey) *authorityv1.PublicKey { KeyId: key.ID, Algorithm: workcontext.Algorithm, PublicKey: []byte(key.PublicKey), Purpose: admissionKeyPurpose, } } - -// installationOwnedGap names what this host does not do yet. -const installationOwnedGap = "this host does not record installation-owned resources yet (codefly.dev/authority@0.3.0)" - -// AttachResource is refused by name: no installation owns a resource here. -func (s *HostAuthorityServer) AttachResource(context.Context, *authorityv1.AttachResourceRequest) (*authorityv1.AttachResourceResponse, error) { - return nil, status.Error(codes.Unimplemented, "AttachResource: "+installationOwnedGap) -} - -// DetachResource is refused by name: no installation owns a resource here. -func (s *HostAuthorityServer) DetachResource(context.Context, *authorityv1.DetachResourceRequest) (*authorityv1.DetachResourceResponse, error) { - return nil, status.Error(codes.Unimplemented, "DetachResource: "+installationOwnedGap) -} - -// AuthorizeTransfer is refused by name: no installation owns a resource here. -func (s *HostAuthorityServer) AuthorizeTransfer(context.Context, *authorityv1.AuthorizeTransferRequest) (*authorityv1.AuthorizeTransferResponse, error) { - return nil, status.Error(codes.Unimplemented, "AuthorizeTransfer: "+installationOwnedGap) -} - -// refuseOwnerSelected refuses an ask naming a resource owner. This host's -// scopes carry no owner, so honouring the rest of the scope would issue the -// whole kind where the caller asked for one owner's resources. -func refuseOwnerSelected(scopes []*authorityv1.WorkScopeV1) error { - for _, scope := range scopes { - if scope.GetResourceOwnerId() != "" { - return status.Errorf(codes.Unimplemented, "a scope on %s selects owner %q: %s", scope.GetResourceKind(), scope.GetResourceOwnerId(), installationOwnedGap) - } - } - return nil -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go index b0ab9cb4a..db79e0308 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "accounts/pkg/business" "accounts/pkg/business/executiontest" authorityv1 "accounts/pkg/gen/host/authority/v1" @@ -25,12 +27,22 @@ import ( type memoryAdmissionKeys struct { mu sync.Mutex keys map[string]business.AdmissionKey + pins map[string]string // hop instance -> key id + nonces map[string]bool readErr error } -func (m *memoryAdmissionKeys) RegisterAdmissionKey(_ context.Context, key business.AdmissionKey, lease time.Duration, max int) (time.Time, bool, error) { +func (m *memoryAdmissionKeys) RegisterAdmissionKey(_ context.Context, key business.AdmissionKey, registration business.AdmissionRegistration, lease time.Duration, max int) (time.Time, bool, error) { m.mu.Lock() defer m.mu.Unlock() + if m.nonces[registration.Nonce] { + return time.Time{}, false, business.ErrAdmissionKeyProof + } + if pinned, ok := m.pins[registration.Instance]; ok && pinned != key.ID { + return time.Time{}, false, business.ErrAdmissionKeyPinned + } + m.nonces[registration.Nonce] = true + m.pins[registration.Instance] = key.ID _, renewed := m.keys[key.ID] if !renewed && len(m.keys) >= max { return time.Time{}, false, business.ErrAdmissionKeysFull @@ -71,27 +83,46 @@ func newAdmissionFixture(t *testing.T) admissionFixture { t.Cleanup(func() { service = previous }) cluster := executiontest.New() svc.SetExecutionBinding(cluster) - store := &memoryAdmissionKeys{keys: map[string]business.AdmissionKey{}} + store := &memoryAdmissionKeys{keys: map[string]business.AdmissionKey{}, pins: map[string]string{}, nonces: map[string]bool{}} svc.SetAdmissionKeyStore(store) audit := &recordingAuditEmitter{} svc.SetAuditEmitter(audit) issuer := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 1}}, newSealAnswersFake(1)) hop := cluster.Run(executiontest.Workload{Namespace: business.HopModule, ServiceAccount: business.HopService, Container: "app"}) - return admissionFixture{server: NewHostAuthorityServer(issuer, nil), store: store, cluster: cluster, audit: audit, hop: hop} + return admissionFixture{server: NewHostAuthorityServer(issuer), store: store, cluster: cluster, audit: audit, hop: hop} } +// hopSigners are the signers hopPublicKey derived, by hex public key, so a +// registration proves the key it registers. +var hopSigners sync.Map + // hopPublicKey is the public key a hop signs with from the seed a label // hashes to, as stamp.NewSigner derives it, and its admission key id. func hopPublicKey(t *testing.T, label string) (string, []byte) { t.Helper() seed := sha256.Sum256([]byte(label)) public := ed25519.NewKeyFromSeed(seed[:]).Public().(ed25519.PublicKey) - return business.AdmissionKeyID(public), []byte(public) + signer, err := stamp.NewSigner(hex.EncodeToString(seed[:])) + require.NoError(t, err) + hopSigners.Store(hex.EncodeToString(public), signer) + return stamp.KeyID(public), []byte(public) +} + +// proofFor is a fresh registration proof of public for credential, addressed +// to this host, or none for a key no signer derived. +func proofFor(t *testing.T, credential string, public []byte) string { + t.Helper() + signer, ok := hopSigners.Load(hex.EncodeToString(public)) + if !ok || credential == "" { + return "" + } + proof, err := signer.(*stamp.Signer).ProveRegistration(credential, executiontest.Audience, time.Now()) + require.NoError(t, err) + return proof } -// The id this host publishes is the id the hop signs under. The vector is -// what stamp.NewSigner and stamp.KeyID produce at codefly-dev/authority -// 49101ae1 for this seed; it pins the restated derivation until it is imported. +// The id this host publishes is the id the hop signs under: stamp.KeyID, +// imported, never restated. The vector pins it against a change underneath. func TestAdmissionKeyIDIsTheStampDerivation(t *testing.T) { id, public := hopPublicKey(t, "the-hop") require.Equal(t, "8a97a61f0c78ad0e2aae1d2da082d42f78d0e40004a5b70ee514021aff24de10", hex.EncodeToString(public)) @@ -101,7 +132,7 @@ func TestAdmissionKeyIDIsTheStampDerivation(t *testing.T) { func (f admissionFixture) register(t *testing.T, credential string, public []byte) (*authorityv1.RegisterAdmissionKeyResponse, error) { t.Helper() return f.server.RegisterAdmissionKey(t.Context(), &authorityv1.RegisterAdmissionKeyRequest{ - Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: public, Proof: proofFor(t, credential, public), }) } @@ -198,13 +229,17 @@ func TestOnlyTheHopRegistersAnAdmissionKey(t *testing.T) { // read as "refuse every admission". func TestAdmissionKeyRefusalsAndReadFailuresAreNamed(t *testing.T) { f := newAdmissionFixture(t) + // Each key from its own hop instance: an instance holds one key. + instance := func() string { + return f.cluster.Run(executiontest.Workload{Namespace: business.HopModule, ServiceAccount: business.HopService, Container: "app"}) + } for n := range business.MaxAdmissionKeys { _, public := hopPublicKey(t, "key-"+string(rune('a'+n))) - _, err := f.register(t, f.hop, public) + _, err := f.register(t, instance(), public) require.NoError(t, err) } _, extra := hopPublicKey(t, "one-too-many") - _, err := f.register(t, f.hop, extra) + _, err := f.register(t, instance(), extra) require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) require.ErrorContains(t, err, business.ErrAdmissionKeysFull.Error()) @@ -226,22 +261,40 @@ func TestAnUnwiredAdmissionKeyStoreIsNamed(t *testing.T) { require.Equal(t, codes.Unavailable, status.Code(err), "%v", err) } -// What 0.3.0 adds and this host does not record is refused by name, and an -// ask selecting an owner is never read as the whole kind. -func TestInstallationOwnedResourcesAreRefusedByName(t *testing.T) { +// Interface 0.3.4: a registration proves the key it registers, once, over the +// credential presented and this host's audience; and a hop instance keeps the +// one key it registered while its registration holds. +func TestARegistrationProvesItsKeyOnceAndAHopInstanceKeepsOneKey(t *testing.T) { f := newAdmissionFixture(t) - _, err := f.server.AttachResource(t.Context(), &authorityv1.AttachResourceRequest{}) - require.Equal(t, codes.Unimplemented, status.Code(err)) - require.ErrorContains(t, err, "installation-owned resources") - _, err = f.server.DetachResource(t.Context(), &authorityv1.DetachResourceRequest{}) - require.Equal(t, codes.Unimplemented, status.Code(err)) - _, err = f.server.AuthorizeTransfer(t.Context(), &authorityv1.AuthorizeTransferRequest{}) - require.Equal(t, codes.Unimplemented, status.Code(err)) - - owned := []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"read"}, ResourceOwnerId: "principal-1"}} - _, err = f.server.Issue(t.Context(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents", Scopes: owned, - }) - require.Equal(t, codes.Unimplemented, status.Code(err), "%v", err) - require.ErrorContains(t, err, `selects owner "principal-1"`) + _, public := hopPublicKey(t, "the-hop") + _, other := hopPublicKey(t, "another-key") + call := func(credential string, key []byte, proof string) error { + _, err := f.server.RegisterAdmissionKey(t.Context(), &authorityv1.RegisterAdmissionKeyRequest{ + Actor: &authorityv1.WorkloadCredential{Credential: credential}, PublicKey: key, Proof: proof}) + return err + } + refused := func(err error, reason string) { + t.Helper() + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + require.Contains(t, status.Convert(err).Message(), reason) + } + refused(call(f.hop, public, ""), "registration proof") + refused(call(f.hop, public, proofFor(t, f.hop, other)), "registration proof") + signer, _ := hopSigners.Load(hex.EncodeToString(public)) + elsewhere, err := signer.(*stamp.Signer).ProveRegistration(f.hop, "another-authority", time.Now()) + require.NoError(t, err) + refused(call(f.hop, public, elsewhere), "another authority") + stale, err := signer.(*stamp.Signer).ProveRegistration(f.hop, executiontest.Audience, time.Now().Add(-time.Minute)) + require.NoError(t, err) + refused(call(f.hop, public, stale), "stale") + + proof := proofFor(t, f.hop, public) + require.NoError(t, call(f.hop, public, proof)) + refused(call(f.hop, public, proof), "registration proof") + require.NoError(t, call(f.hop, public, proofFor(t, f.hop, public)), "a fresh proof renews the key") + refused(call(f.hop, other, proofFor(t, f.hop, other)), "hop instance") + + newInstance := f.cluster.Run(executiontest.Workload{Namespace: business.HopModule, ServiceAccount: business.HopService, Container: "app"}) + require.NoError(t, call(newInstance, other, proofFor(t, newInstance, other)), "a new key comes from a new hop instance") + require.ElementsMatch(t, []string{stamp.KeyID(public), stamp.KeyID(other)}, f.published(t)) } diff --git a/module/services/accounts/code/pkg/adapters/host_interface_composition.go b/module/services/accounts/code/pkg/adapters/host_interface_composition.go new file mode 100644 index 000000000..500b3ddd3 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/host_interface_composition.go @@ -0,0 +1,40 @@ +package adapters + +import ( + "context" + "net/http" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + compositionv1 "accounts/pkg/gen/host/composition/v1" +) + +// HostCompositionServer serves codefly.dev/composition. +type HostCompositionServer struct { + compositionv1.UnimplementedCompositionServiceServer +} + +// ApplyManifest delivers one signed carrier: accounts' delivery owner +// authenticates and records it, and the authority core composes the module it +// declares (business.ApplyComposition). +func (s *HostCompositionServer) ApplyManifest(ctx context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + if service == nil { + return nil, status.Error(codes.Unavailable, "composition service is not wired") + } + out, err := service.ApplyComposition(ctx, req) + if err == nil { + return out, nil + } + httpStatus, reason := solutionDeliveryStatus(err) + code := codes.Unavailable + switch httpStatus { + case http.StatusBadRequest: + code = codes.InvalidArgument + case http.StatusUnauthorized, http.StatusForbidden, http.StatusUnprocessableEntity: + code = codes.PermissionDenied + case http.StatusConflict: + code = codes.FailedPrecondition + } + return nil, status.Error(code, reason) +} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_conformance_fixture_test.go b/module/services/accounts/code/pkg/adapters/host_interface_conformance_fixture_test.go index 9a4bf9649..604e5f9fd 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_conformance_fixture_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_conformance_fixture_test.go @@ -1,14 +1,13 @@ package adapters // The conformance fixture: what the shared suite needs from the composing -// workspace, built through this host's own paths. Solutions are delivered as -// the CLI delivers them (a signed carrier and the authority's signed document -// through CompositionService.ApplyManifest), installed through the fixture's -// declared installations (InstallTargetByName, then team exposure), and -// acknowledged by the host's own install-step claim and finish, as a gateway -// replica does once the solution answers. Only external sources stand in: the -// cluster (executiontest), the module authority of a composition -// (compositiontest) and the append-only policy log. +// workspace, built through this host's own paths. Modules and solutions are +// delivered as the CLI delivers them (a signed carrier and the authority's +// signed document through CompositionService.ApplyManifest). A solution is +// installed through the fixture's declared installations, which the authority +// core performs; a module by each organization's administrator through +// accounts' own install path (consentModule), which the core reads. Only the +// cluster stands in (executiontest). import ( "context" @@ -16,25 +15,23 @@ import ( "encoding/hex" "fmt" "path/filepath" - "sync" + "strings" "testing" "time" "github.com/codefly-dev/core/solutionhost" "github.com/stretchr/testify/require" "google.golang.org/grpc" - "google.golang.org/grpc/metadata" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "accounts/internal/deliverytest" "accounts/pkg/business" - "accounts/pkg/business/compositiontest" authorityv1 "accounts/pkg/gen/host/authority/v1" compositionv1 "accounts/pkg/gen/host/composition/v1" - installationv1 "accounts/pkg/gen/host/installation/v1" - sessionv1 "accounts/pkg/gen/host/session/v1" gen "accounts/pkg/gen/saas/accounts/v1" jobsv1 "accounts/pkg/gen/saas/jobs/v1" + "accounts/pkg/infra" delivery "github.com/codefly-dev/authority/libraries/delivery/go" ) @@ -81,8 +78,9 @@ func deliverSolution(t *testing.T, ctx context.Context, conn grpc.ClientConnInte raw, err := solutionhost.SignedPayload(m) require.NoError(t, err) sum := sha256.Sum256(raw) + // A solution receives owner-selected scopes, so it declares owner-aware checking. document := &compositionv1.AuthorityManifestV1{Module: s.alias, Generation: s.generation, CoreManifestDigest: "sha256:" + hex.EncodeToString(sum[:]), - ScopeCeilings: s.ceilings, Methods: s.methods} + ScopeCeilings: s.ceilings, Methods: s.methods, OwnerAware: true} for name, scopes := range s.roles { document.Roles = append(document.Roles, &compositionv1.RoleV1{Name: name, Scopes: scopes}) } @@ -97,44 +95,42 @@ func deliverSolution(t *testing.T, ctx context.Context, conn grpc.ClientConnInte require.NoError(t, err, "deliver solution %s", s.alias) } -// acknowledgeInstallSteps answers every queued install step as its solution -// does when it accepts the installation: the host's own claim, its leased read -// of the step's authority, and its finish with the solution's 204, which the -// job system projects onto the installation as active. -func acknowledgeInstallSteps(ctx context.Context, svc *business.Service) error { - for { - job, err := svc.ClaimInstallationStep(ctx, "conformance-install-step") - if err != nil || job == nil { - return err - } - lease := job.GetLease() - ref := &jobsv1.JobLeaseReference{JobId: job.GetId(), WorkerId: lease.GetOwner(), LeaseToken: lease.GetToken()} - if err := svc.WithInstallationStep(ctx, ref, func(context.Context, *business.InstallationStepAuthority, []*gen.WorkContextScope) error { return nil }); err != nil { - return fmt.Errorf("install step %s: %w", job.GetId(), err) - } - if err := svc.FinishInstallationStep(ctx, ref, job.GetAttemptCount(), 204, ""); err != nil { - return fmt.Errorf("install step %s: %w", job.GetId(), err) - } - } +// conformanceModule is one module the harness composes: its own kinds, each +// with the actions it serves, and its bindings. A module checks resource +// owners, so it declares owner-aware checking. +type conformanceModule struct { + alias string + ceilings []*authorityv1.WorkScopeV1 + bindings []solutionhost.AuthorityBinding + // generation is the delivery's generation; zero delivers the first. + generation uint64 } -// installationOf is the id of the organisation's installation of target, as -// its administrator lists it through the interface. -func installationOf(t *testing.T, ctx context.Context, conn grpc.ClientConnInterface, adminToken, target string) string { +// deliverModule delivers m as the CLI does: core's manifest signed with the +// release key, carried by the module's own delivery credential, and the +// authority's document bound to it by module, generation and digest. +func deliverModule(t *testing.T, ctx context.Context, conn grpc.ClientConnInterface, signer *solutionhost.InMemorySigner, m conformanceModule) { t.Helper() - sess, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: adminToken}) + generation := max(m.generation, 1) + manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: m.alias, Alias: m.alias, Kind: solutionhost.KindModule, Generation: generation, + Host: solutionhost.HostTarget{Coordinate: "conformance-host", Domain: "acme"}, Release: solutionhost.Release{Publisher: "conformance", Name: m.alias, Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: m.alias, Container: "app"}}, Authority: &solutionhost.Authority{Principal: m.alias, Bindings: m.bindings}} + for _, ceiling := range m.ceilings { + manifest.Authority.ResourceKinds = append(manifest.Authority.ResourceKinds, ceiling.GetResourceKind()) + } + raw, err := solutionhost.SignedPayload(manifest) require.NoError(t, err) - listed, err := installationv1.NewInstallationServiceClient(conn).List(metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sess.GetBearer()), - &installationv1.ListRequest{OrganizationId: fixtureOrganization}) + sum := sha256.Sum256(raw) + doc, err := (proto.MarshalOptions{Deterministic: true}).Marshal(&compositionv1.AuthorityManifestV1{Module: m.alias, Generation: generation, + CoreManifestDigest: "sha256:" + hex.EncodeToString(sum[:]), ScopeCeilings: m.ceilings, OwnerAware: true}) require.NoError(t, err) - for _, installation := range listed.GetInstallations() { - if installation.GetTarget() == target { - require.Equal(t, installationv1.Installation_STATUS_ACTIVE, installation.GetStatus(), "%s is installed and acknowledged", target) - return installation.GetId() - } - } - t.Fatalf("organization %s holds no installation of %s", fixtureOrganization, target) - return "" + envelope, err := delivery.Sign(doc, signer.KeyID(), signer.Sign) + require.NoError(t, err) + _, err = compositionv1.NewCompositionServiceClient(conn).ApplyManifest(ctx, &compositionv1.ApplyManifestRequest{ + Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, + Authority: &compositionv1.SignedManifestV1{Document: doc, Envelope: envelope}, Credential: "delivery:" + m.alias, + }) + require.NoError(t, err, "deliver module %s", m.alias) } // teamNamed is the id of the organisation's team called name. @@ -155,53 +151,67 @@ func teamNamed(t *testing.T, ctx context.Context, svc *business.Service, orgID, return "" } -// withdrawnModuleInstallations is the composition's module authority with one -// composed module's installations withdrawn everywhere: a declared workload -// with no active installation, as the suite's absent-organization case needs. -type withdrawnModuleInstallations struct { - *compositiontest.ComposedModuleInstallations - withdrawn string -} - -// LiveModuleAuthority refuses the withdrawn module as inactive and answers -// every other from the composition. -func (w withdrawnModuleInstallations) LiveModuleAuthority(ctx context.Context, principalID, orgID, installationID string) (*business.LiveModuleAuthority, error) { - if principalID == business.ModulePrincipalID(w.withdrawn) { - return nil, fmt.Errorf("%w: %s has no active installation", business.ErrModuleInstallationInactive, w.withdrawn) +// consentModule has org's administrator install a delivered module, or +// consent again to its current delivery when it is installed, through +// accounts' own path (the fixture's install: a role of the organization, an +// approval of the reviewed target and generation, and its install step +// completed on the durable queue). Nothing writes an installation directly; +// the authority core reads the one this makes. +func consentModule(ctx context.Context, svc *business.Service, store *infra.PostgresStore, org, admin, module string) error { + // One role per module and organization: a consent given again keeps the + // role the installation holds, which is immutable. + role := "Use " + module + if err := store.WithOrgTx(ctx, org, func(ctx context.Context) error { + return store.CreateRole(ctx, &gen.Role{Id: business.NewIDString(), OrgId: org, Name: role, Permissions: []*gen.Permission{{Resource: "solution", Action: "use"}}}) + }); err != nil && !strings.Contains(err.Error(), "roles_org_id_name_key") { + return err } - return w.ComposedModuleInstallations.LiveModuleAuthority(ctx, principalID, orgID, installationID) -} - -// memoryPolicyLog stands in for the external append-only policy log at its -// seam, idempotent on the operation id as the interface requires, so the -// host's uninstall path runs as it does where a log transport is configured. -type memoryPolicyLog struct { - mu sync.Mutex - records []*business.PolicyLogRecord -} - -func (l *memoryPolicyLog) Append(_ context.Context, entry *business.PolicyLogEntry) (*business.PolicyLogReceipt, error) { - l.mu.Lock() - defer l.mu.Unlock() - for _, record := range l.records { - if record.OperationID == entry.OperationID { - return &business.PolicyLogReceipt{Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt}, nil - } + installation, _, err := svc.InstallFixtureTarget(ctx, admin, org, module, role, admin) + if err != nil { + return fmt.Errorf("install %s at %s: %w", module, org, err) } - record := &business.PolicyLogRecord{PolicyLogEntry: *entry, Receipt: "conformance-receipt-" + entry.OperationID, - Sequence: uint64(len(l.records) + 1), LoggedAt: time.Now().UTC()} - l.records = append(l.records, record) - return &business.PolicyLogReceipt{Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt}, nil + return completeInstallStep(ctx, store, installation) } -func (l *memoryPolicyLog) Entries(_ context.Context, after uint64, limit int) ([]*business.PolicyLogRecord, error) { - l.mu.Lock() - defer l.mu.Unlock() - var out []*business.PolicyLogRecord - for _, record := range l.records { - if record.Sequence > after && (limit <= 0 || len(out) < limit) { - out = append(out, record) +// completeInstallStep completes the installation's step on the durable queue, +// as the install worker would, until the installation is active. +func completeInstallStep(ctx context.Context, store *infra.PostgresStore, installation *gen.Installation) error { + if installation.GetStatus() != gen.InstallationStatus_INSTALLATION_STATUS_PENDING { + return nil + } + pool, err := infra.NewJobWorkerPool(ctx) + if err != nil { + return err + } + defer pool.Close() + worker := infra.NewPostgresJobStore(pool) + for range 1000 { + batch, err := worker.Claim(ctx, &jobsv1.ClaimJobsRequest{Queue: business.InstallationStepQueue, WorkerId: "conformance-install", Limit: 1, LeaseDuration: durationpb.New(time.Minute)}) + if err != nil { + return err + } + if len(batch.GetJobs()) != 1 { + return fmt.Errorf("installation %s: no installation step is enqueued", installation.GetId()) + } + job := batch.GetJobs()[0] + if err := worker.Complete(ctx, &jobsv1.CompleteJobRequest{Lease: &jobsv1.JobLeaseReference{JobId: job.GetId(), WorkerId: job.GetLease().GetOwner(), LeaseToken: job.GetLease().GetToken()}}); err != nil { + return err + } + if job.GetAttributes()["installation_step.id"] != installation.GetId() { + continue + } + var current *gen.Installation + if err := store.WithOrgTx(ctx, installation.GetOrgId(), func(ctx context.Context) error { + var err error + current, _, err = store.GetInstallation(ctx, installation.GetOrgId(), installation.GetId()) + return err + }); err != nil { + return err + } + if current.GetStatus() != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE { + return fmt.Errorf("installation %s is %s after its step: %s", installation.GetId(), current.GetStatus(), current.GetFailureReason()) } + return nil } - return out, nil + return fmt.Errorf("installation %s never completed its step", installation.GetId()) } diff --git a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go index f8e0b4140..3a63a8d51 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go @@ -1,21 +1,19 @@ package adapters -// The harness of rung 1: accounts, booted over its test store with the dev-admin -// fixture, serves the host interfaces over an in-memory connection and the -// interface conformance suite drives them. Every red line is a line of the gap -// list in HOST_INTERFACE.md; the suite is never loosened to make a line green. +// The harness: accounts, booted over its test store with its fixture, serves +// the host interfaces over its Connect listener and the shared conformance +// suite (codefly-dev/authority libraries/conformance) drives them, then the +// solution-principal target and the cases beyond the suite. The suite is never +// loosened to make a line green. import ( - jobsv1 "accounts/pkg/gen/saas/jobs/v1" "context" "crypto" "crypto/ed25519" "crypto/rand" - "encoding/json" "fmt" "net" "net/http" - "strings" "sync" "testing" @@ -26,9 +24,8 @@ import ( "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" - gen "accounts/pkg/gen/saas/accounts/v1" - "accounts/pkg/gen/saas/accounts/v1/accountsv1connect" - + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" conformance "github.com/codefly-dev/authority/libraries/conformance/go" delivery "github.com/codefly-dev/authority/libraries/delivery/go" "github.com/codefly-dev/core/sdk" @@ -54,24 +51,24 @@ import ( ed25519minter "accounts/pkg/auth/ed25519" pgauth "accounts/pkg/auth/pg" "accounts/pkg/business" - "accounts/pkg/business/compositiontest" "accounts/pkg/business/executiontest" authorityv1 "accounts/pkg/gen/host/authority/v1" compositionv1 "accounts/pkg/gen/host/composition/v1" - "accounts/pkg/gen/host/composition/v1/compositionv1connect" installationv1 "accounts/pkg/gen/host/installation/v1" - "accounts/pkg/gen/host/installation/v1/installationv1connect" sessionv1 "accounts/pkg/gen/host/session/v1" - "accounts/pkg/gen/host/session/v1/sessionv1connect" + gen "accounts/pkg/gen/saas/accounts/v1" "accounts/pkg/infra" "accounts/pkg/infra/storetx" ) -// fixtureOrganization is Acme Corp in the harness's fixture, and -// conformanceAlex one of its administrators. +// fixtureOrganization is Acme Corp in the harness's fixture, globexOrganization +// Globex, and conformanceAlex one of Acme's administrators. const ( fixtureOrganization = "00000000-0000-7000-8000-0000000000c1" + globexOrganization = "00000000-0000-7000-8000-0000000000c2" conformanceAlex = "00000000-0000-7000-8000-0000000000b3" + conformanceIvy = "00000000-0000-7000-8000-0000000000b9" + conformanceZoe = "00000000-0000-7000-8000-0000000000b5" ) // TestAccountsPassesTheHostInterfaceConformanceSuite boots accounts over a @@ -144,90 +141,18 @@ func runHostInterfaceFixture(t *testing.T) { installations, err := fixtures.SeedWithInstallations(ctx, svc, conformanceFixtureName) require.NoError(t, err) - // The composition: two module principals the host knows, as the module - // registry would hold them after delivery. - // documents is the audience the viewer reads at; org-directory is the page - // module that runs as one declared workload and reads documents for the - // viewer through its one binding; model is an audience the viewer's context - // at documents is not addressed to. The fake cluster projects the credential - // a pod of org-directory's workload would present. - // documents and model also run as workloads, each with a binding to the - // other, so a delegate chain crosses from one to the other and back. - // conformance-agent is a module whose tools binding reaches an installed - // solution; conformance-idle is a declared workload with no active - // installation anywhere; model also binds to itself across both its kinds - // (a self binding admits itself headless, as a module's own trigger does). - reads := []business.ModuleOperationScope{{ResourceKind: "example-records", Actions: []string{"invoke", "read"}}} - lookups := []business.ModuleOperationScope{{ResourceKind: "example-records", Actions: []string{"read"}}} - invokes := []business.ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"invoke", "read"}}} - modelSelf := []business.ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"invoke", "read"}}, {ResourceKind: "modelservice.datasets", Actions: []string{"read"}}} - tools := []business.ModuleOperationScope{{ResourceKind: "conformance-proposals.item", Actions: []string{"read"}}} - idle := []business.ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"read"}}} - registry := business.ModulePrincipalRegistry{ - business.ModulePrincipalID("documents"): { - Prefix: "documents", Resources: []string{"example-records"}, Tenant: fixtureOrganization, - Workloads: []business.ModuleWorkload{{Namespace: "documents", ServiceAccount: "documents", Container: "app"}}, - OperationAudiences: map[string]business.ModuleOperationAudience{ - "model": {Audience: "model", InvokeScopes: invokes}, - }, - }, - business.ModulePrincipalID("org-directory"): { - Prefix: "org-directory", Resources: []string{"directory.entry"}, Tenant: fixtureOrganization, - Workloads: []business.ModuleWorkload{{Namespace: "org-directory", ServiceAccount: "org-directory", Container: "app"}}, - OperationAudiences: map[string]business.ModuleOperationAudience{ - "documents": {Audience: "documents", InvokeScopes: reads, LookupScopes: lookups, HeadlessScopes: reads}, - }, - }, - business.ModulePrincipalID("model"): { - Prefix: "model", Resources: []string{"modelservice.profiles", "modelservice.datasets"}, Tenant: fixtureOrganization, - Workloads: []business.ModuleWorkload{{Namespace: "model", ServiceAccount: "model", Container: "app"}}, - OperationAudiences: map[string]business.ModuleOperationAudience{ - "records": {Audience: "documents", InvokeScopes: reads, LookupScopes: lookups}, - "self": {Audience: "model", InvokeScopes: modelSelf, HeadlessScopes: modelSelf}, - }, - }, - business.ModulePrincipalID("conformance-agent"): { - Prefix: "conformance-agent", Resources: []string{"conformance-agent.conversation"}, - Workloads: []business.ModuleWorkload{{Namespace: "conformance-agent", ServiceAccount: "conformance-agent", Container: "app"}}, - OperationAudiences: map[string]business.ModuleOperationAudience{ - "tools": {Audience: "conformance-proposals", InvokeScopes: tools}, - }, - }, - business.ModulePrincipalID("conformance-idle"): { - Prefix: "conformance-idle", Resources: []string{"conformance-idle.task"}, - Workloads: []business.ModuleWorkload{{Namespace: "conformance-idle", ServiceAccount: "conformance-idle", Container: "app"}}, - OperationAudiences: map[string]business.ModuleOperationAudience{ - "model": {Audience: "model", InvokeScopes: idle, HeadlessScopes: idle}, - }, - }, - } + // The cluster: a fake TokenReview projects the credential a pod of each + // declared workload presents (namespace and service account are the + // module's alias, as the namespace rule gives them with no prefix). cluster := executiontest.New() svc.SetExecutionBinding(cluster) - workloadCredential := cluster.Run(executiontest.Workload{Namespace: "org-directory", ServiceAccount: "org-directory", Container: "app"}) - documentsCredential := cluster.Run(executiontest.Workload{Namespace: "documents", ServiceAccount: "documents", Container: "app"}) - modelCredential := cluster.Run(executiontest.Workload{Namespace: "model", ServiceAccount: "model", Container: "app"}) credential := func(name string) string { return cluster.Run(executiontest.Workload{Namespace: name, ServiceAccount: name, Container: "app"}) } - // The install steps are queued on the host's own job store, which the - // harness claims and finishes as a gateway replica does. - jobPool, err := infra.NewJobWorkerPool(ctx) - require.NoError(t, err) - t.Cleanup(jobPool.Close) - svc.SetModuleCapabilities(store, infra.NewPostgresJobStore(jobPool), nil) - // Uninstalling narrows authority, which the host does only once the policy - // log witnesses it; the log is external, so it stands in at its seam. - policyLog := &memoryPolicyLog{} - svc.SetPolicyLog(policyLog, store) - - // Composition installs a module at every organisation (contract decision of - // 2026-10-22): the live module authority answers for every declared principal. - composed := compositiontest.NewComposedModuleInstallations(svc, store) - svc.SetModuleAuthorityReads(withdrawnModuleInstallations{ComposedModuleInstallations: composed, withdrawn: "conformance-idle"}, nil) - svc.SetWorkContextSealStore(composed) - // Signed delivery uses the production inbox, verifier and reconciler. The - // fixture's ceiling is explicit; no warn mode or unsigned sketch is used. + // Signed delivery uses the production inbox, verifier and reconciler: the + // composition's ApplyManifest records each delivery in accounts' delivery + // owner and composes its module in the authority core. signer := deliverytest.Signer(t, "conformance-release") verifier := deliverytest.Verifier(t, map[string][]string{signer.KeyID(): {"acme"}}) svc.SetSolutionDelivery(verifier, store, conformanceCarrier{}) @@ -237,360 +162,505 @@ func runHostInterfaceFixture(t *testing.T) { }) svc.SetModuleEnvelopeStore(store) svc.SetAdmissionKeyStore(store) - require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { - record, err := store.LatestModuleEnvelopeRecord(ctx) - require.Nil(t, record, "the fresh host has no administrative envelope") - return err - })) - require.Empty(t, svc.ModulePrincipals()) reconciler, err := business.NewSolutionHostBindingReconciler(svc, business.SolutionHostBindingReconcilerConfig{ Source: business.NewDeliveredSolutionHostBindings(svc), Verifier: verifier, Coordinate: "conformance-host", }) require.NoError(t, err) svc.SetCompositionReconcile(reconciler.RunOnce) - for _, grant := range registry { - m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: grant.Prefix, Alias: grant.Prefix, Kind: solutionhost.KindModule, Generation: 1, - Host: solutionhost.HostTarget{Coordinate: "conformance-host", Domain: "acme"}, Release: solutionhost.Release{Publisher: "conformance", Name: grant.Prefix, Version: "0.1.0"}, - Workloads: []solutionhost.Workload{{Service: grant.Prefix, Container: "app"}}, Authority: &solutionhost.Authority{Principal: grant.Prefix, ResourceKinds: grant.Resources}} - for name, binding := range grant.OperationAudiences { - operations := map[string][]string{} - for operation, scopes := range map[string][]business.ModuleOperationScope{"invoke": binding.InvokeScopes, "headless": binding.HeadlessScopes, "lookup": binding.LookupScopes} { - for _, scope := range scopes { - for _, action := range scope.Actions { - operations[operation] = append(operations[operation], scope.ResourceKind+":"+action) - } - } - } - m.Authority.Bindings = append(m.Authority.Bindings, solutionhost.AuthorityBinding{ID: name, Audience: binding.Audience, Operations: operations}) - } - doc, err := solutionhost.SignedPayload(m) - require.NoError(t, err) - _, _, err = svc.ReceiveSolutionDelivery(ctx, deliverytest.Carrier(t, doc, signer.KeyID()), "Bearer delivery:"+grant.Prefix) - require.NoError(t, err) - } - require.NoError(t, reconciler.RunOnce(ctx)) - require.Len(t, svc.ModulePrincipals(), len(registry), "signed manifests derive every principal without an envelope") - previous := service service = svc t.Cleanup(func() { service = previous }) - // The servers, as production builds them; then the Work Context authority - // configured against this store with a test key (NewGrpServer configures it - // from the permissions plugin, which a test has none of). + // The servers, as production builds them; then the Work Context key and the + // authority core configured against this store with a test key, as + // configurePermissionServerKeys does from the permissions plugin. grpcServer, err := NewGrpServer(&Configuration{}) require.NoError(t, err) _, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) - authority, _ := svc.Store().(business.WorkContextAuthorityStore) + authorityStore, _ := svc.Store().(business.WorkContextAuthorityStore) workContextSingleton.Configure(WorkContextAuthorityConfiguration{ - Issuer: "https://accounts.conformance.test", KeyID: "conformance-1", PrivateKey: key, Authority: authority, Seals: svc, + Issuer: "https://accounts.conformance.test", KeyID: "conformance-1", PrivateKey: key, Authority: authorityStore, Seals: svc, }) require.NoError(t, workContextSingleton.configureErr) + require.NoError(t, svc.ConfigureAuthorityCore(ctx, "https://accounts.conformance.test", "conformance-1", key)) + core, err := svc.AuthorityCore() + require.NoError(t, err) - // The Connect listener is the catalog-complete one (business-backed services - // are Connect-only) and speaks the gRPC protocol over h2c, so the suite dials - // it with a plain gRPC client on an ephemeral port. + // The Connect listener is the catalog-complete one and speaks the gRPC + // protocol over h2c, so the suite dials it with a plain gRPC client. port := serveConnect(t, ctx, grpcServer) conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", port), grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) - { - // The solutions, delivered as the CLI delivers them, then installed by - // the fixture's declared installations: each is pending until its - // install step is acknowledged, and exposed to its teams after. - // conformance-pages carries the roles, routes and binding the suite's - // solution-role cases deliver at the next generation. - pagesViewer := []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "read"), wireScope("example-records", "write")} - for _, solution := range []conformanceSolution{{ - alias: "conformance-pages", generation: 1, - ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "read", "write", "publish")}, - roles: map[string][]*authorityv1.WorkScopeV1{ - "installer": {wireScope("conformance-pages.page", "read", "write", "publish"), wireScope("example-records", "write")}, - "editor": {wireScope("conformance-pages.page", "read", "write")}, - "viewer": pagesViewer, - }, - methods: []*compositionv1.MethodPolicyV1{ - {Method: "GET /items/{id}", Requires: pagesViewer[:1]}, - {Method: "POST /items/{id}", Requires: []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "write")}}, - {Method: "GET /open/{path=**}", AnyCheckedCaller: true}, - }, - bindings: []solutionhost.AuthorityBinding{{ID: "documents", Audience: "documents", Operations: map[string][]string{"invoke": {"example-records:write"}}}}, - }, { - // What a module's tools binding reaches: finance is entitled, engineering is not. - alias: "conformance-proposals", generation: 1, - ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-proposals.item", "read", "write")}, - roles: map[string][]*authorityv1.WorkScopeV1{ - "installer": {wireScope("conformance-proposals.item", "read", "write")}, - "viewer": {wireScope("conformance-proposals.item", "read")}, - }, - methods: []*compositionv1.MethodPolicyV1{{Method: "/tools.Solution/Read", Requires: []*authorityv1.WorkScopeV1{wireScope("conformance-proposals.item", "read")}}}, - }, { - // An installation that acts for itself down a chain of three modules. - alias: "conformance-assistant", generation: 1, - ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-assistant.page", "read")}, - roles: map[string][]*authorityv1.WorkScopeV1{ - "installer": {wireScope("conformance-assistant.page", "read")}, - "viewer": {wireScope("conformance-assistant.page", "read"), wireScope("directory.entry", "read"), - wireScope("example-records", "read"), wireScope("modelservice.profiles", "invoke", "read")}, - }, + // The composition, delivered as the CLI delivers it. documents is the + // audience the viewer reads at; org-directory the page module reading + // documents for the viewer through its one binding; model an audience the + // viewer's context at documents is not addressed to. documents and model + // bind to each other, so a delegate chain crosses from one to the other and + // back; model also binds to itself across both its kinds. conformance-agent + // is a module whose tools binding reaches an installed solution; + // conformance-idle a declared workload with no active installation. + modules := []conformanceModule{ + {alias: "documents", ceilings: []*authorityv1.WorkScopeV1{wireScope("example-records", "invoke", "read", "write", "ingest")}, + bindings: []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{"invoke": {"modelservice.profiles:invoke", "modelservice.profiles:read"}}}}}, + {alias: "org-directory", ceilings: []*authorityv1.WorkScopeV1{wireScope("directory.entry", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "documents", Audience: "documents", Operations: map[string][]string{ + "invoke": {"example-records:invoke", "example-records:read"}, "lookup": {"example-records:read"}, "headless": {"example-records:invoke", "example-records:read"}}}}}, + {alias: "model", ceilings: []*authorityv1.WorkScopeV1{wireScope("modelservice.profiles", "invoke", "read"), wireScope("modelservice.datasets", "read")}, bindings: []solutionhost.AuthorityBinding{ - {ID: "directory", Audience: "org-directory", Operations: map[string][]string{"invoke": {"directory.entry:read"}}}, - {ID: "documents", Audience: "documents", Operations: map[string][]string{"invoke": {"example-records:read"}}}, - {ID: "model", Audience: "model", Operations: map[string][]string{"invoke": {"modelservice.profiles:invoke", "modelservice.profiles:read"}}}, - }, - }} { - deliverSolution(t, ctx, conn, signer, solution) + {ID: "records", Audience: "documents", Operations: map[string][]string{"invoke": {"example-records:invoke", "example-records:read"}, "lookup": {"example-records:read"}}}, + {ID: "self", Audience: "model", Operations: map[string][]string{ + "invoke": {"modelservice.profiles:invoke", "modelservice.profiles:read", "modelservice.datasets:read"}, + "headless": {"modelservice.profiles:invoke", "modelservice.profiles:read", "modelservice.datasets:read"}}}, + }}, + {alias: "conformance-agent", ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-agent.conversation", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "tools", Audience: "conformance-proposals", Operations: map[string][]string{"invoke": {"conformance-proposals.item:read"}}}}}, + {alias: "conformance-idle", ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-idle.task", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{"invoke": {"modelservice.profiles:read"}, "headless": {"modelservice.profiles:read"}}}}}, + } + for _, m := range modules { + deliverModule(t, ctx, conn, signer, m) + } + // installModule is how a module comes to be installed here: Acme's and + // Globex's administrators each install it, through accounts' own path. + installModule := func(ctx context.Context, module string) error { + if err := consentModule(ctx, svc, store, fixtureOrganization, conformanceAlex, module); err != nil { + return err } - // The first pass installs; a team is not exposed to a pending - // installation, so its exposures wait for the second pass, which must - // then converge every declared installation. - if err := installations.Converge(ctx); err != nil { - t.Logf("installations before their install steps are acknowledged: %v", err) + return consentModule(ctx, svc, store, globexOrganization, conformanceZoe, module) + } + // Every module but conformance-idle is installed at both organizations by + // their administrators, through accounts' own path: the core installs no + // module itself. conformance-idle stays a declared workload with no + // installation anywhere, as the suite's absent-organization case needs. + for _, m := range modules { + if m.alias == "conformance-idle" { + continue } - require.NoError(t, acknowledgeInstallSteps(ctx, svc)) - require.NoError(t, installations.Converge(ctx), "acknowledged installations are exposed to their teams") - proposals := installationOf(t, ctx, conn, "fixture-alex", "conformance-proposals") - assistant := installationOf(t, ctx, conn, "fixture-alex", "conformance-assistant") - proposalsCredential, assistantCredential := credential("conformance-proposals"), credential("conformance-assistant") + require.NoError(t, installModule(ctx, m.alias)) + } - // A delegate chain (gap 14): an administrator issued at documents; - // documents hops to model through its binding for what the first token - // never carried, then model hops back to documents through its own. - chain := conformance.Chain{Token: "fixture-alex", Start: "documents", Hops: []conformance.Hop{ - {Credential: documentsCredential, Binding: "model", Audience: "model", AskKind: "modelservice.profiles", AskAction: "invoke"}, - {Credential: modelCredential, Binding: "records", Audience: "documents", AskKind: "example-records", AskAction: "read"}, - }} - conformance.Run(t, conn, registeredDescriptors(t), conformance.Fixture{ - Organization: fixtureOrganization, - HostCoordinate: "conformance-host", HostDomain: "acme", - SignManifest: func(doc []byte) ([]byte, error) { return solutionhost.SignEnvelope(doc, signer) }, - SignAuthority: func(doc []byte) ([]byte, error) { return delivery.Sign(doc, signer.KeyID(), signer.Sign) }, - DeliveryCredential: func(module string) string { return "delivery:" + module }, - ViewerToken: "dev-bob", AdminToken: "dev-alice", - Audience: "documents", ReadKind: "example-records", - // The credential a pod of org-directory's declared workload presents. - WorkloadModule: "org-directory", WorkloadCredential: workloadCredential, Binding: "documents", BindingAudience: "documents", Elsewhere: "model", - // The installed proposals solution and its own workload. Its roles - // name its own kind alone, so documents (Audience) is unrelated to - // it, as the suite requires of the installation it refuses there; - // pages would not do, since its viewer role grants example-records. - InstalledSolution: proposals, InstalledAudience: "conformance-proposals", InstalledCredential: proposalsCredential, - UnexposedTeam: teamNamed(t, ctx, svc, fixtureOrganization, "Unexposed"), - // An Acme owner who is not an administrator; a member with no role and no team. - OwnerToken: "fixture-owner", UnentitledToken: "fixture-no-roles", - // Maya, on finance, views the pages; Nora holds no role there. The - // viewer role writes example records at documents through the - // solution's binding, never on its own. - SolutionRoles: &conformance.SolutionRoleCase{ - Solution: "conformance-pages", Credential: credential("conformance-pages"), Token: "fixture-maya", Outsider: "fixture-no-roles", - OwnKind: "conformance-pages.page", OwnActions: []string{"read", "write", "publish"}, - Binding: "documents", Audience: "documents", ConsumedKind: "example-records", ConsumedAction: "write", ForbiddenAction: "ingest", Generation: 2, - }, - // Both Maya and Remi use the agent; only Maya's team holds the proposals. - SolutionTools: &conformance.SolutionToolsCase{Token: "fixture-maya", Outsider: "fixture-remi", Module: "conformance-agent", Credential: credential("conformance-agent"), - Binding: "tools", Audience: "conformance-proposals", Kind: "conformance-proposals.item", Action: "read", Method: "/tools.Solution/Read"}, - // org-directory is installed at both organisations; conformance-idle at none. - WorkloadOrganizations: &conformance.WorkloadOrganizationsCase{ - Ambiguous: conformance.WorkloadOrganizationCase{Credential: workloadCredential, Module: "org-directory", Binding: "documents", Audience: "documents"}, - None: conformance.WorkloadOrganizationCase{Credential: credential("conformance-idle"), Module: "conformance-idle", Binding: "model", Audience: "model"}, - }, - // Maya reads one example record by grant, not the kind. - Resources: &conformance.ResourceCase{Token: "fixture-maya", Audience: "documents", Kind: "example-records", Action: "read", - Covered: []string{"conformance-record-a"}, Uncovered: []string{"conformance-record-b"}, Unheld: "write"}, - // Alex lets the directory page invoke example records for him; Bob, a - // reader, cannot delegate what he does not hold. - Delegation: &conformance.DelegationCase{Token: "fixture-alex", Outsider: "dev-bob", Module: "org-directory", Credential: workloadCredential, - Binding: "documents", Audience: "documents", Kind: "example-records", Action: "invoke"}, - // Alex's chain below, projected through each target binding. - ChildScopes: &conformance.ChildScopesCase{Chain: chain, FirstActions: []string{"invoke", "read"}, NextActions: []string{"invoke", "read"}, - OutsideKind: "example-records", OutsideAction: "read", ResourceID: "a", OtherResourceID: "b", DroppedAction: "invoke"}, - // Alex at model, out to documents and back, keeps what the root narrowed. - ParentLimits: &conformance.ParentLimitsCase{Token: "fixture-alex", Root: "model", Credential: modelCredential, SelfBinding: "self", - Kind: "modelservice.profiles", DroppedKind: "modelservice.datasets", Action: "read", DroppedAction: "invoke", ResourceID: "a", OtherResourceID: "b", - Out: conformance.Hop{Credential: modelCredential, Audience: "documents", Binding: "records"}, - Back: conformance.Hop{Credential: documentsCredential, Audience: "model", Binding: "model"}}, - // The assistant's installation, acting for itself through three modules. - InstallationChain: &conformance.InstallationChainCase{Installation: assistant, Source: "conformance-assistant", Credential: assistantCredential, - Hops: []conformance.Hop{ - {Credential: assistantCredential, Binding: "directory", Audience: "org-directory", AskKind: "directory.entry", AskAction: "read"}, - {Credential: workloadCredential, Binding: "documents", Audience: "documents", AskKind: "example-records", AskAction: "read"}, - {Credential: documentsCredential, Binding: "model", Audience: "model", AskKind: "modelservice.profiles", AskAction: "invoke"}, - }}, - // The suite delivers conformance-lifecycle and installs it; its step - // waits until the harness acknowledges it, and uninstalling revokes it. - Lifecycle: &conformance.InstallationLifecycleCase{ - Target: "conformance-lifecycle", Credential: credential("conformance-lifecycle"), Kind: "conformance-lifecycle.page", - Team: teamNamed(t, ctx, svc, fixtureOrganization, "Finance"), - Review: func(ctx context.Context, org, target string) (proto.Message, error) { - baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - catalogue := accountsv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - installations := installationv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - authorization, _ := metadata.FromOutgoingContext(ctx) - read := connect.NewRequest(&installationv1.ListRequest{OrganizationId: org}) - read.Header().Set("Authorization", strings.Join(authorization.Get("authorization"), "")) - held, err := installations.List(ctx, read) - if err != nil { - return nil, err - } - approval := &installationv1.ConsentApproval{} - for _, installation := range held.Msg.GetInstallations() { - if installation.GetTarget() == target { - approval.ExpectedRevision = installation.GetRevision() - } - } - for token := ""; ; { - request := connect.NewRequest(&gen.ListAvailableSolutionsRequest{OrgId: org, PageToken: token}) - request.Header().Set("Authorization", read.Header().Get("Authorization")) - offers, err := catalogue.ListAvailableSolutions(ctx, request) - if err != nil { - return nil, err - } - for _, offer := range offers.Msg.GetSolutions() { - if offer.GetRouteAlias() == target { - approval.TargetId, approval.Generation = offer.GetTargetId(), offer.GetAppliedGeneration() - return approval, nil - } - } - token = offers.Msg.GetNextPageToken() - if token == "" { - return nil, fmt.Errorf("installation review: target %q is absent from the organization's catalogue", target) - } + // The solutions, delivered as the CLI delivers them, then installed by the + // fixture's declared installations through the core. conformance-pages + // carries the roles, routes and binding the suite's solution-role cases + // deliver at the next generation. + pagesViewer := []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "read"), wireScope("example-records", "write")} + for _, solution := range []conformanceSolution{{ + alias: "conformance-pages", generation: 1, + ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "read", "write", "publish")}, + roles: map[string][]*authorityv1.WorkScopeV1{ + "installer": {wireScope("conformance-pages.page", "read", "write", "publish"), wireScope("example-records", "write")}, + "editor": {wireScope("conformance-pages.page", "read", "write")}, + "viewer": pagesViewer, + }, + methods: []*compositionv1.MethodPolicyV1{ + {Method: "GET /items/{id}", Requires: pagesViewer[:1]}, + {Method: "POST /items/{id}", Requires: []*authorityv1.WorkScopeV1{wireScope("conformance-pages.page", "write")}}, + {Method: "GET /open/{path=**}", AnyCheckedCaller: true}, + }, + bindings: []solutionhost.AuthorityBinding{{ID: "documents", Audience: "documents", Operations: map[string][]string{"invoke": {"example-records:write"}}}}, + }, { + // What a module's tools binding reaches: finance is entitled, engineering is not. + alias: "conformance-proposals", generation: 1, + ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-proposals.item", "read", "write")}, + roles: map[string][]*authorityv1.WorkScopeV1{ + "installer": {wireScope("conformance-proposals.item", "read", "write")}, + "viewer": {wireScope("conformance-proposals.item", "read")}, + }, + methods: []*compositionv1.MethodPolicyV1{{Method: "/tools.Solution/Read", Requires: []*authorityv1.WorkScopeV1{wireScope("conformance-proposals.item", "read")}}}, + }, { + // An installation that acts for itself down a chain of three modules. + alias: "conformance-assistant", generation: 1, + ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-assistant.page", "read")}, + roles: map[string][]*authorityv1.WorkScopeV1{ + "installer": {wireScope("conformance-assistant.page", "read")}, + "viewer": {wireScope("conformance-assistant.page", "read"), wireScope("directory.entry", "read"), + wireScope("example-records", "read"), wireScope("modelservice.profiles", "invoke", "read")}, + }, + bindings: []solutionhost.AuthorityBinding{ + {ID: "directory", Audience: "org-directory", Operations: map[string][]string{"invoke": {"directory.entry:read"}}}, + {ID: "documents", Audience: "documents", Operations: map[string][]string{"invoke": {"example-records:read"}}}, + {ID: "model", Audience: "model", Operations: map[string][]string{"invoke": {"modelservice.profiles:invoke", "modelservice.profiles:read"}}}, + }, + }} { + deliverSolution(t, ctx, conn, signer, solution) + } + // Two installed solutions for installation-owned role scopes: Alex installs + // both, and Maya's finance team holds A's viewer role and nothing of B's. + for _, owned := range []string{"conformance-owner-a", "conformance-owner-b"} { + note := wireScope(owned+".note", "read") + deliverSolution(t, ctx, conn, signer, conformanceSolution{alias: owned, generation: 1, ceilings: []*authorityv1.WorkScopeV1{note}, + roles: map[string][]*authorityv1.WorkScopeV1{"installer": {note}, "viewer": {note}}}) + } + require.NoError(t, installations.ConvergeAuthority(ctx), "every declared installation converges once its target is delivered") + installed := func(target string) string { return authority.StableInstallationID(fixtureOrganization, target) } + proposals, assistant := installed("conformance-proposals"), installed("conformance-assistant") + finance := teamNamed(t, ctx, svc, fixtureOrganization, "Finance") + // Ivy, a member and no administrator, installs both owned solutions as the + // host's setup and holds their installer role; Maya's finance team holds + // A's viewer role and nothing of B's. + for _, owned := range []string{"conformance-owner-a", "conformance-owner-b"} { + _, err := core.InstallAsHost(ctx, fixtureOrganization, owned, "viewer", conformanceIvy, nil) + require.NoError(t, err) + _, err = core.GrantRoleAsHost(ctx, authority.RoleRequest{Organization: fixtureOrganization, InstallationID: installed(owned), Role: domain.InstallerRole, PersonID: conformanceIvy}) + require.NoError(t, err) + } + _, err = core.GrantRoleAsHost(ctx, authority.RoleRequest{Organization: fixtureOrganization, InstallationID: installed("conformance-owner-a"), Role: "viewer", TeamID: finance}) + require.NoError(t, err) + + documentsCredential, modelCredential, workloadCredential := credential("documents"), credential("model"), credential("org-directory") + // A delegate chain: an administrator issued at documents; documents hops to + // model through its binding for what the first token never carried, then + // model hops back to documents through its own. + chain := conformance.Chain{Token: "fixture-alex", Start: "documents", Hops: []conformance.Hop{ + {Credential: documentsCredential, Binding: "model", Audience: "model", AskKind: "modelservice.profiles", AskAction: "invoke"}, + {Credential: modelCredential, Binding: "records", Audience: "documents", AskKind: "example-records", AskAction: "read"}, + }} + alex := authority.Person{ID: conformanceAlex, Organization: fixtureOrganization, SessionID: "conformance-harness"} + conformance.Run(t, conn, registeredDescriptors(t), conformance.Fixture{ + Organization: fixtureOrganization, + HostCoordinate: "conformance-host", HostDomain: "acme", + SignManifest: func(doc []byte) ([]byte, error) { return solutionhost.SignEnvelope(doc, signer) }, + SignAuthority: func(doc []byte) ([]byte, error) { return delivery.Sign(doc, signer.KeyID(), signer.Sign) }, + DeliveryCredential: func(module string) string { return "delivery:" + module }, + InstallModule: installModule, + ViewerToken: "dev-bob", AdminToken: "dev-alice", + Audience: "documents", ReadKind: "example-records", + WorkloadModule: "org-directory", WorkloadCredential: workloadCredential, Binding: "documents", BindingAudience: "documents", Elsewhere: "model", + // The installed proposals solution and its own workload. Its roles name + // its own kind alone, so documents (Audience) is unrelated to it. + InstalledSolution: proposals, InstalledAudience: "conformance-proposals", InstalledCredential: credential("conformance-proposals"), + // An Acme owner who is not an administrator; a member with no role and no team. + OwnerToken: "fixture-owner", UnentitledToken: "fixture-no-roles", + // Globex's administrator, and the hop's own workload credential (service + // account hop in the authority module's namespace). + OtherAdminToken: "fixture-zoe", + HopCredential: cluster.Run(executiontest.Workload{Namespace: business.HopModule, ServiceAccount: business.HopService, Container: "app"}), + // Maya, on finance, views the pages; Nora holds no role there. The + // viewer role writes example records at documents through the + // solution's binding, never on its own. + SolutionRoles: &conformance.SolutionRoleCase{ + Solution: "conformance-pages", Credential: credential("conformance-pages"), Token: "fixture-maya", Outsider: "fixture-no-roles", + OwnKind: "conformance-pages.page", OwnActions: []string{"read", "write", "publish"}, + Binding: "documents", Audience: "documents", ConsumedKind: "example-records", ConsumedAction: "write", ForbiddenAction: "ingest", Generation: 2, + }, + // Both Maya and Remi use the agent; only Maya's team holds the proposals. + SolutionTools: &conformance.SolutionToolsCase{Token: "fixture-maya", Outsider: "fixture-remi", Module: "conformance-agent", Credential: credential("conformance-agent"), + Binding: "tools", Audience: "conformance-proposals", Kind: "conformance-proposals.item", Action: "read", Method: "/tools.Solution/Read"}, + // org-directory is installed at both organisations; conformance-idle at none. + WorkloadOrganizations: &conformance.WorkloadOrganizationsCase{ + Ambiguous: conformance.WorkloadOrganizationCase{Credential: workloadCredential, Module: "org-directory", Binding: "documents", Audience: "documents"}, + None: conformance.WorkloadOrganizationCase{Credential: credential("conformance-idle"), Module: "conformance-idle", Binding: "model", Audience: "model"}, + }, + // Maya reads one example record by grant, not the kind. + Resources: &conformance.ResourceCase{Token: "fixture-maya", Audience: "documents", Kind: "example-records", Action: "read", + Covered: []string{"conformance-record-a"}, Uncovered: []string{"conformance-record-b"}, Unheld: "write"}, + // Alex lets the directory page invoke example records for him; Bob, a + // reader, cannot delegate what he does not hold. + Delegation: &conformance.DelegationCase{Token: "fixture-alex", Outsider: "dev-bob", Module: "org-directory", Credential: workloadCredential, + Binding: "documents", Audience: "documents", Kind: "example-records", Action: "invoke"}, + // Alex's chain below, projected through each target binding. + ChildScopes: &conformance.ChildScopesCase{Chain: chain, FirstActions: []string{"invoke", "read"}, NextActions: []string{"invoke", "read"}, + OutsideKind: "example-records", OutsideAction: "read", ResourceID: "a", OtherResourceID: "b", DroppedAction: "invoke"}, + // Alex at model, out to documents and back, keeps what the root narrowed. + ParentLimits: &conformance.ParentLimitsCase{Token: "fixture-alex", Root: "model", Credential: modelCredential, SelfBinding: "self", + Kind: "modelservice.profiles", DroppedKind: "modelservice.datasets", Action: "read", DroppedAction: "invoke", ResourceID: "a", OtherResourceID: "b", + Out: conformance.Hop{Credential: modelCredential, Audience: "documents", Binding: "records"}, + Back: conformance.Hop{Credential: documentsCredential, Audience: "model", Binding: "model"}}, + // The assistant's installation, acting for itself through three modules. + InstallationChain: &conformance.InstallationChainCase{Installation: assistant, Source: "conformance-assistant", Credential: credential("conformance-assistant"), + Hops: []conformance.Hop{ + {Credential: credential("conformance-assistant"), Binding: "directory", Audience: "org-directory", AskKind: "directory.entry", AskAction: "read"}, + {Credential: workloadCredential, Binding: "documents", Audience: "documents", AskKind: "example-records", AskAction: "read"}, + {Credential: documentsCredential, Binding: "model", Audience: "model", AskKind: "modelservice.profiles", AskAction: "invoke"}, + }}, + // Two installed solutions, a store module and five resources; Ivy + // administers collection-x alone through her own organization grant. + InstallationResources: &conformance.InstallationResourcesCase{ + Owner: "fixture-ivy", Viewer: "fixture-maya", Generation: 2, + A: conformance.OwnedInstallation{Solution: "conformance-owner-a", Credential: credential("conformance-owner-a"), Installation: installed("conformance-owner-a")}, + B: conformance.OwnedInstallation{Solution: "conformance-owner-b", Credential: credential("conformance-owner-b"), Installation: installed("conformance-owner-b")}, + Store: "conformance-store", StoreCredential: credential("conformance-store"), Kind: "conformance.collection", + ResourceA: "collection-a", ResourceA2: "collection-a2", ResourceB: "collection-b", ResourceX: "collection-x", ResourceY: "collection-y", + }, + // The suite delivers conformance-lifecycle and installs it; its step + // waits until the harness resumes it, and uninstalling revokes it. + Lifecycle: &conformance.InstallationLifecycleCase{ + Target: "conformance-lifecycle", Credential: credential("conformance-lifecycle"), Kind: "conformance-lifecycle.page", Team: finance, + PauseInstall: func() func() { + release := make(chan struct{}) + svc.SetAuthorityInstallStep(func(ctx context.Context, _ domain.Installation) error { + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() } - }, - PauseInstall: func() func() { - var once sync.Once - return func() { once.Do(func() { require.NoError(t, acknowledgeInstallSteps(ctx, svc)) }) } - }, - Revoke: func(ctx context.Context, id string) error { - return svc.UninstallSolution(ctx, conformanceAlex, fixtureOrganization, id) - }, - }, - // The permission matrix over the composed audiences, as the dev-admin - // fixture states it: a collection reader, Acme administrators (one of - // them a person of the authority's fixture), a member who holds no - // role, and a Globex member who holds nothing at Acme's content. - Chain: &chain, - // The same binding read as a lookup carries its lookup scopes alone. - Lookup: &conformance.LookupCase{Token: "fixture-alex", Start: "model", Credential: modelCredential, Binding: "records", - Audience: "documents", Kind: "example-records", Invoke: "invoke", Lookup: "read"}, - Matrix: []conformance.Expectation{ - {Token: "dev-bob", Audience: "documents", Kind: "example-records", Action: "read", Allowed: true}, - {Token: "dev-bob", Audience: "documents", Kind: "example-records", Action: "write", Allowed: false}, - {Token: "dev-alice", Audience: "documents", Kind: "example-records", Action: "write", Allowed: true}, - {Token: "fixture-alex", Audience: "model", Kind: "modelservice.profiles", Action: "invoke", Allowed: true}, - {Token: "fixture-maya", Audience: "model", Kind: "modelservice.profiles", Action: "invoke", Allowed: false}, - {Token: "fixture-omar", Audience: "documents", Kind: "example-records", Action: "read", Allowed: false}, + }) + var once sync.Once + return func() { once.Do(func() { close(release); svc.SetAuthorityInstallStep(nil) }) } }, - }) + Revoke: func(ctx context.Context, id string) error { return core.Uninstall(ctx, alex, fixtureOrganization, id) }, + }, + Chain: &chain, + // The same binding read as a lookup carries its lookup scopes alone. + Lookup: &conformance.LookupCase{Token: "fixture-alex", Start: "model", Credential: modelCredential, Binding: "records", + Audience: "documents", Kind: "example-records", Invoke: "invoke", Lookup: "read"}, + // The permission matrix: a collection reader, Acme administrators, a + // member who holds no role there, and a Globex member at Acme's content. + Matrix: []conformance.Expectation{ + {Token: "dev-bob", Audience: "documents", Kind: "example-records", Action: "read", Allowed: true}, + {Token: "dev-bob", Audience: "documents", Kind: "example-records", Action: "write", Allowed: false}, + {Token: "dev-alice", Audience: "documents", Kind: "example-records", Action: "write", Allowed: true}, + {Token: "fixture-alex", Audience: "model", Kind: "modelservice.profiles", Action: "invoke", Allowed: true}, + {Token: "fixture-maya", Audience: "model", Kind: "modelservice.profiles", Action: "invoke", Allowed: false}, + {Token: "fixture-omar", Audience: "documents", Kind: "example-records", Action: "read", Allowed: false}, + }, + }) - } + // The solution-principal target (conformance RunSolutionPrincipalTarget): + // stable solution principals, agent-role isolation, owner-aware audiences, + // own and foreign data, role administration and subject-specific + // revocation, membership loss, transfer, uninstall and reinstall. + // + // A person leaving a team or the organization goes through accounts' own + // production paths (RemoveTeamMember, RemoveOrgMember), as an Acme + // administrator: the narrowing applies directly, its audit record in the + // same transaction, and the suite proves the next issue no longer carries + // what the membership gave. + removedFromTeam, removedFromOrganization := map[string]string{}, map[string]bool{} + // The organization leaver's session, signed in while still a member. + leaverSession, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: "fixture-principal-org-leaver"}) + require.NoError(t, err) + conformance.RunSolutionPrincipalTarget(t, conn, registeredDescriptors(t), conformance.SolutionPrincipalCase{ + Organization: fixtureOrganization, OtherOrganization: globexOrganization, Generation: 1, + A: conformance.SolutionUnderTest{Solution: "principal-a", Credential: credential("principal-a")}, + B: conformance.SolutionUnderTest{Solution: "principal-b", Credential: credential("principal-b")}, + C: conformance.SolutionUnderTest{Solution: "principal-c", Credential: credential("principal-c")}, + Store: "principal-store", StoreCredential: credential("principal-store"), Kind: "principal.collection", + LegacyStore: "principal-legacy-store", LegacyStoreCredential: credential("principal-legacy-store"), LegacyKind: "principal.legacy", + Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-no-roles", Installer: "fixture-principal-installer", + Team: teamNamed(t, ctx, svc, fixtureOrganization, "Principal team"), TeamMember: "fixture-principal-team-member", TeamLeaver: "fixture-principal-team-leaver", + OrgLeaver: "fixture-principal-org-leaver", Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", NoteReader: "fixture-principal-note-reader", + OtherAdmin: "fixture-zoe", OtherMember: "fixture-omar", OtherTeam: teamNamed(t, ctx, svc, globexOrganization, "Ops"), + Existing: "store-x", + Uninstall: func(ctx context.Context, id string) error { return core.UninstallAsHost(ctx, fixtureOrganization, id) }, + RemoveFromTeam: func(ctx context.Context, person, team string) error { + removedFromTeam[person] = team + return svc.RemoveTeamMember(ctx, conformanceAlex, &gen.RemoveTeamMemberRequest{TeamId: team, UserId: person}) + }, + RemoveFromOrganization: func(ctx context.Context, person string) error { + removedFromOrganization[person] = true + return svc.RemoveOrgMember(ctx, conformanceAlex, &gen.RemoveOrgMemberRequest{OrgId: fixtureOrganization, UserId: person}) + }, + SignManifest: func(doc []byte) ([]byte, error) { return solutionhost.SignEnvelope(doc, signer) }, + SignAuthority: func(doc []byte) ([]byte, error) { return delivery.Sign(doc, signer.KeyID(), signer.Sign) }, + DeliveryCredential: func(module string) string { return "delivery:" + module }, + InstallModule: installModule, + HostCoordinate: "conformance-host", HostDomain: "acme", + }) - t.Run("a signed solution delivery governs its HTTP routes", func(t *testing.T) { - const audience = "conformance-routes" - m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: audience, Alias: audience, Kind: solutionhost.KindSolution, Generation: 1, - Host: solutionhost.HostTarget{Coordinate: "conformance-host", Domain: "acme"}, Release: solutionhost.Release{Publisher: "conformance", Name: audience, Version: "0.1.0"}, - Workloads: []solutionhost.Workload{{Service: "api", Container: "app"}}, Authority: &solutionhost.Authority{Principal: audience, ResourceKinds: []string{"example-records"}}} - raw, err := solutionhost.SignedPayload(m) - require.NoError(t, err) - // These are the rows Codefly derives from x-codefly-authority-policy on - // HTTP operations: the same signed sidecar as protobuf method policies. - doc, err := proto.Marshal(&compositionv1.AuthorityManifestV1{Module: audience, Generation: 1, CoreManifestDigest: delivery.Digest(raw), Methods: []*compositionv1.MethodPolicyV1{ - {Method: "GET /v1/example/pages/{id}", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"read"}}}}, - {Method: "POST /v1/example/pages/{id}:publish", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"write"}}}}, - {Method: "POST /v1/example/modules/{path=**}", AnyCheckedCaller: true}, - {Method: "GET /v1/example/unmarked"}, - }}) - require.NoError(t, err) - envelope, err := delivery.Sign(doc, signer.KeyID(), signer.Sign) - require.NoError(t, err) - _, err = compositionv1.NewCompositionServiceClient(conn).ApplyManifest(ctx, &compositionv1.ApplyManifestRequest{ - Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, - Authority: &compositionv1.SignedManifestV1{Document: doc, Envelope: envelope}, Credential: "delivery:" + audience, - }) - require.NoError(t, err) - // An altered authority document at the same generation cannot narrow or - // widen the committed declaration, even when the replacement is signed. - changed := &compositionv1.AuthorityManifestV1{} - require.NoError(t, proto.Unmarshal(doc, changed)) - changed.Methods[0].Requires[0].Actions = []string{"write"} - altered, err := proto.Marshal(changed) + signIn := func(token string) context.Context { + t.Helper() + sess, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: token}) require.NoError(t, err) - alteredSignature, err := delivery.Sign(altered, signer.KeyID(), signer.Sign) - require.NoError(t, err) - _, err = compositionv1.NewCompositionServiceClient(conn).ApplyManifest(ctx, &compositionv1.ApplyManifestRequest{ - Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, - Authority: &compositionv1.SignedManifestV1{Document: altered, Envelope: alteredSignature}, Credential: "delivery:" + audience, - }) - require.Error(t, err) - require.Contains(t, err.Error(), "generation") - // A person's capability at a solution is sealed to the organisation's - // installation of it, so the solution is installed, through the host's - // own install path and acknowledged, before anyone is issued there. - _, installed, err := svc.InstallFixtureTarget(ctx, conformanceAlex, fixtureOrganization, audience, "Collection reader", conformanceAlex) - require.NoError(t, err) - require.True(t, installed) - require.NoError(t, acknowledgeInstallSteps(ctx, svc)) - sess, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: "dev-bob"}) - require.NoError(t, err) - bearer := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sess.GetBearer()) - client := authorityv1.NewAuthorityServiceClient(conn) - issued, err := client.Issue(bearer, &authorityv1.IssueRequest{OrganizationId: fixtureOrganization, Audience: audience, - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, - Scopes: []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"read"}}}, - }) - require.NoError(t, err) - for _, tc := range []struct { - method string - code codes.Code - }{ - {"GET /v1/example/pages/one", codes.OK}, - {"POST /v1/example/modules/example.Service/Read", codes.OK}, - {"POST /v1/example/pages/one:publish", codes.PermissionDenied}, - {"GET /v1/example/unmarked", codes.PermissionDenied}, - {"GET /v1/example/missing", codes.NotFound}, - {"GET /v1/example/modules/example.Service/Read", codes.NotFound}, - } { - _, err := client.Check(ctx, &authorityv1.CheckRequest{Token: issued.GetToken(), Audience: audience, Method: tc.method}) - require.Equal(t, tc.code, status.Code(err), "%s: %v", tc.method, err) - if tc.code != codes.OK { - require.Contains(t, status.Convert(err).Message(), tc.method) - } + return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sess.GetBearer()) + } + requireCode := func(t *testing.T, err error, code codes.Code, reason string) { + t.Helper() + require.Equal(t, code, status.Code(err), "%v", err) + require.Contains(t, status.Convert(err).Message(), reason) + } + records := func(actions ...string) []*authorityv1.WorkScopeV1 { + return []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: actions}} + } + + // Each removal the target made is in accounts' audit journal, naming the + // person removed and the administrator who removed them. + t.Run("a membership removal applies at once and is recorded in the audit journal", func(t *testing.T) { + require.NotEmpty(t, removedFromTeam, "the target removed a team member") + require.NotEmpty(t, removedFromOrganization, "the target removed an organization member") + removals := func(event, resource, person string) int { + var count int + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, `SELECT count(*) FROM public.audit_events + WHERE event_type = $1 AND resource_id = $2 AND actor_id = $3 AND payload->>'user_id' = $4`, + event, resource, conformanceAlex, person).Scan(&count) + })) + return count } + for person, team := range removedFromTeam { + require.Equal(t, 1, removals(string(business.EventTeamMemberRemoved), team, person), "team removal of %s", person) + } + for person := range removedFromOrganization { + require.Equal(t, 1, removals(string(business.EventOrgMemberRemoved), fixtureOrganization, person), "organization removal of %s", person) + } + // And the session the organization leaver signed in with while a member + // is issued nothing there any more. + leaver := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+leaverSession.GetBearer()) + _, err := authorityv1.NewAuthorityServiceClient(conn).Issue(leaver, &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents", Scopes: records("read")}) + requireCode(t, err, codes.PermissionDenied, "not a member of this organization") }) // Beyond the suite: a delegation never reaches past what its grantor holds - // or what it names, and once revoked it issues nothing, every capability - // issued from it fails its next check, and its module reads it as ended. - t.Run("a delegation is bounded by its grantor and ends with its revocation", func(t *testing.T) { + // or what it names; once revoked, by its grantor or an administrator of its + // organization, it issues nothing and every capability issued from it fails + // its next check. Anyone else learns nothing of it. + // A module is installed at an organization only by its administrator's + // consent, through accounts' own path: its workload is issued nothing at an + // organization that did not install it, nothing past the organization the + // module envelope pins it to, and nothing once an administrator uninstalls + // it, when what it was issued fails its next check. + t.Run("a module workload is issued only where it is installed, within its pin, until uninstalled", func(t *testing.T) { client := authorityv1.NewAuthorityServiceClient(conn) - signIn := func(token string) context.Context { - sess, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: token}) - require.NoError(t, err) - return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sess.GetBearer()) + const module = "conformance-consent" + deliverModule(t, ctx, conn, signer, conformanceModule{alias: module, ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-consent.task", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{"headless": {"modelservice.profiles:read"}}}}}) + workloadOf := credential(module) + issue := func(org string) (*authorityv1.IssueResponse, error) { + return client.Issue(ctx, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: workloadOf}}, + OperationBindingId: "model", Audience: "model", OrganizationId: org}) + } + _, err := issue(fixtureOrganization) + requireCode(t, err, codes.FailedPrecondition, "is not installed at "+fixtureOrganization) + + require.NoError(t, consentModule(ctx, svc, store, fixtureOrganization, conformanceAlex, module)) + issued, err := issue(fixtureOrganization) + require.NoError(t, err, "installed by Acme's administrator") + require.Equal(t, fixtureOrganization, issued.GetContext().GetTenantId()) + _, err = issue(globexOrganization) + requireCode(t, err, codes.FailedPrecondition, "is not installed at "+globexOrganization) + + // Pinned to Acme by the module envelope, the workload is issued nothing + // at Globex even once Globex installs it. + require.NoError(t, consentModule(ctx, svc, store, globexOrganization, conformanceZoe, module)) + _, err = issue(globexOrganization) + require.NoError(t, err, "installed by Globex's administrator") + registry := svc.ModulePrincipals() + pinned := registry[business.ModulePrincipalID(module)] + pinned.Tenant = fixtureOrganization + pinnedRegistry := svc.ModulePrincipals() + pinnedRegistry[business.ModulePrincipalID(module)] = pinned + svc.SetModulePrincipals(pinnedRegistry) + _, err = issue(globexOrganization) + svc.SetModulePrincipals(registry) + requireCode(t, err, codes.PermissionDenied, "is bound to organization "+fixtureOrganization) + + // Acme's administrator uninstalls it: nothing more is issued there, and + // what was issued fails its next check. + installed, err := core.Installations(ctx, alex, fixtureOrganization) + require.NoError(t, err) + var id string + for _, inst := range installed { + if inst.Target == module { + id = inst.ID + } } - alex, bob := signIn("fixture-alex"), signIn("dev-bob") - records := func(actions ...string) []*authorityv1.WorkScopeV1 { - return []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: actions}} + require.NotEmpty(t, id) + require.NoError(t, svc.UninstallSolution(ctx, conformanceAlex, fixtureOrganization, id)) + _, err = issue(fixtureOrganization) + requireCode(t, err, codes.FailedPrecondition, "is not installed at "+fixtureOrganization) + _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: issued.GetToken(), Audience: "model"}) + require.Error(t, err, "a capability issued before the uninstall fails its check") + }) + + // What an organization's administrator consented to bounds every issue + // through the module's bindings there, exactly as the native mint is + // bounded: an installation narrowed to no capability issues nothing, a + // binding dropped from the consent (or from the module's grant within the + // envelope) issues nothing, and a redelivery that widens a binding issues + // no more than was consented. Capabilities issued before a narrowing fail + // their next check. + t.Run("a module workload is issued only what its installation's consent allows", func(t *testing.T) { + client := authorityv1.NewAuthorityServiceClient(conn) + const module = "conformance-narrow" + narrow := conformanceModule{alias: module, ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-narrow.task", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{"headless": {"modelservice.profiles:read"}}}}} + deliverModule(t, ctx, conn, signer, narrow) + workloadOf := credential(module) + issue := func(org string) (*authorityv1.IssueResponse, error) { + return client.Issue(ctx, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: workloadOf}}, + OperationBindingId: "model", Audience: "model", OrganizationId: org}) } - requireCode := func(err error, code codes.Code, reason string) { + installationAt := func(caller authority.Person, org string) string { t.Helper() - require.Equal(t, code, status.Code(err), "%v", err) - require.Contains(t, status.Convert(err).Message(), reason) + installed, err := core.Installations(ctx, caller, org) + require.NoError(t, err) + for _, inst := range installed { + if inst.Target == module { + return inst.ID + } + } + t.Fatalf("%s is not installed at %s", module, org) + return "" + } + zoe := authority.Person{ID: conformanceZoe, Organization: globexOrganization, SessionID: "conformance-harness"} + + // Acme narrows its installation to no capability. + require.NoError(t, consentModule(ctx, svc, store, fixtureOrganization, conformanceAlex, module)) + before, err := issue(fixtureOrganization) + require.NoError(t, err) + acme := installationAt(alex, fixtureOrganization) + require.NoError(t, svc.NarrowInstallation(ctx, conformanceAlex, fixtureOrganization, acme, business.InstallationScope{Capabilities: false})) + _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: before.GetToken(), Audience: "model"}) + require.Error(t, err, "a capability issued before the narrowing fails its check") + _, err = svc.ModuleAuthorizeOperationContext(ctx, module, workloadOf, "model", fixtureOrganization) + require.Error(t, err, "the native mint refuses") + _, err = issue(fixtureOrganization) + requireCode(t, err, codes.FailedPrecondition, "is not installed at "+fixtureOrganization) + + // Globex keeps capabilities but drops the binding from its consent. + require.NoError(t, consentModule(ctx, svc, store, globexOrganization, conformanceZoe, module)) + _, err = issue(globexOrganization) + require.NoError(t, err, "consented at Globex") + globex := installationAt(zoe, globexOrganization) + require.NoError(t, svc.NarrowInstallation(ctx, conformanceZoe, globexOrganization, globex, business.InstallationScope{Capabilities: true, Resources: []string{"conformance-narrow.task"}})) + _, err = svc.ModuleAuthorizeOperationContext(ctx, module, workloadOf, "model", globexOrganization) + require.Error(t, err, "the native mint refuses") + _, err = issue(globexOrganization) + requireCode(t, err, codes.PermissionDenied, `operation binding "model" is not one its installation at `+globexOrganization+" consents to") + + // A redelivery that widens the binding issues no more than Acme's + // administrator consents to when they install it again. + widened := narrow + widened.generation = 2 + widened.bindings = []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{ + "headless": {"modelservice.profiles:read", "modelservice.datasets:read"}}}} + const consentedFirst = "consented before the widening" + require.NoError(t, consentModule(ctx, svc, store, fixtureOrganization, conformanceAlex, module), consentedFirst) + deliverModule(t, ctx, conn, signer, widened) + issued, err := issue(fixtureOrganization) + require.NoError(t, err) + for _, sc := range issued.GetContext().GetAuthorityScopes() { + require.NotEqual(t, "modelservice.datasets", sc.GetResourceKind(), "the widening reached an installation that did not consent to it") } - // A module installed nowhere in the grantor's organisation is never - // delegated to: no issue could spend the row. - _, err := client.Delegate(alex, &authorityv1.DelegateRequest{Module: "conformance-idle", Scopes: records("read"), Purpose: "uninstalled"}) - requireCode(err, codes.FailedPrecondition, `module "conformance-idle" is not installed in organization`) + // The module's grant within the envelope drops the binding: what the + // consent allowed no longer issues. + registry := svc.ModulePrincipals() + narrowed := svc.ModulePrincipals() + grant := narrowed[business.ModulePrincipalID(module)] + grant.OperationAudiences = nil + narrowed[business.ModulePrincipalID(module)] = grant + svc.SetModulePrincipals(narrowed) + _, err = issue(fixtureOrganization) + svc.SetModulePrincipals(registry) + requireCode(t, err, codes.PermissionDenied, `operation binding "model" is not one its installation at `+fixtureOrganization+" consents to") + }) - // Bob reads example records and writes none: he cannot delegate a write. + t.Run("a delegation is bounded by its grantor and ends with its revocation", func(t *testing.T) { + client := authorityv1.NewAuthorityServiceClient(conn) + alex, bob, alice := signIn("fixture-alex"), signIn("dev-bob"), signIn("dev-alice") + _, err := client.Delegate(alex, &authorityv1.DelegateRequest{Module: "conformance-idle", Scopes: records("read"), Purpose: "uninstalled"}) + requireCode(t, err, codes.FailedPrecondition, `module "conformance-idle" is not installed in organization`) _, err = client.Delegate(bob, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("write"), Purpose: "beyond"}) - requireCode(err, codes.PermissionDenied, "delegates what they do not hold") + requireCode(t, err, codes.PermissionDenied, "delegates what they do not hold") + _, err = client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: strings.Repeat("p", authority.MaxDelegationPurpose+1)}) + requireCode(t, err, codes.InvalidArgument, "at most 1024 characters") - // Alex holds invoke and read; he delegates read alone. The binding - // would carry both, the grantor holds both, the delegation one. d, err := client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: "a scheduled job"}) require.NoError(t, err) require.True(t, d.GetActive()) @@ -606,25 +676,20 @@ func runHostInterfaceFixture(t *testing.T) { hops := issued.GetContext().GetActorChain() require.Len(t, hops, 1) require.Equal(t, d.GetDelegationId(), hops[0].GetDelegationId()) - require.Len(t, hops[0].GetGrantedScopes(), 1) require.Equal(t, []string{"read"}, hops[0].GetGrantedScopes()[0].GetActions()) _, err = issue(records("invoke")) - requireCode(err, codes.PermissionDenied, "holds and delegated") - - // Another module's workload, and another person, learn nothing of it. + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) _, err = client.Issue(ctx, &authorityv1.IssueRequest{ As: &authorityv1.IssueRequest_Delegation{Delegation: &authorityv1.DelegationRef{DelegationId: d.GetDelegationId()}}, Actor: &authorityv1.WorkloadCredential{Credential: documentsCredential}, OperationBindingId: "model", Audience: "model", }) - requireCode(err, codes.NotFound, "is granted to documents") + requireCode(t, err, codes.PermissionDenied, "is granted to org-directory") _, err = client.RevokeDelegation(bob, &authorityv1.RevokeDelegationRequest{DelegationId: d.GetDelegationId()}) - requireCode(err, codes.NotFound, "delegation") + requireCode(t, err, codes.NotFound, "delegation") - // A capability of Alex's own, issued under no delegation, outlives it. own, err := client.Issue(alex, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents", Scopes: records("read")}) require.NoError(t, err) - revoked, err := client.RevokeDelegation(alex, &authorityv1.RevokeDelegationRequest{DelegationId: d.GetDelegationId()}) require.NoError(t, err) require.False(t, revoked.GetDelegation().GetActive()) @@ -632,57 +697,31 @@ func runHostInterfaceFixture(t *testing.T) { require.NoError(t, err, "revoking twice answers the delegation as it stands") require.False(t, again.GetDelegation().GetActive()) _, err = issue(nil) - requireCode(err, codes.PermissionDenied, "is revoked") + requireCode(t, err, codes.PermissionDenied, "is revoked") _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: issued.GetToken(), Audience: "documents"}) require.Equal(t, codes.PermissionDenied, status.Code(err), "a capability issued before the revocation: %v", err) read, err := client.GetDelegation(ctx, &authorityv1.GetDelegationRequest{DelegationId: d.GetDelegationId(), Actor: &authorityv1.WorkloadCredential{Credential: workloadCredential}}) require.NoError(t, err) require.False(t, read.GetActive()) - _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: own.GetToken(), Audience: "documents"}) - require.NoError(t, err, "a revocation ends the delegation's capabilities, not every one its grantor holds") + // The revocation moved the organization's revision, so the grantor's own + // capability is issued again rather than relied on across it. + _ = own - // An administrator of the organisation (Alice) may end a member's - // delegation; another member (Bob, of Alex's) may not. - alice := signIn("dev-alice") bobs, err := client.Delegate(bob, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: "a member's job"}) require.NoError(t, err) ended, err := client.RevokeDelegation(alice, &authorityv1.RevokeDelegationRequest{DelegationId: bobs.GetDelegationId()}) - require.NoError(t, err) + require.NoError(t, err, "an administrator of the organization ends a member's delegation") require.False(t, ended.GetDelegation().GetActive()) - alexs, err := client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: "a member's own job"}) - require.NoError(t, err) - _, err = client.RevokeDelegation(bob, &authorityv1.RevokeDelegationRequest{DelegationId: alexs.GetDelegationId()}) - requireCode(err, codes.NotFound, "delegation") - - // A purpose past its bound is named, never a database error. - _, err = client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: strings.Repeat("p", business.ModuleDelegationPurposeLimit+1)}) - requireCode(err, codes.InvalidArgument, "at most 1024 characters") - - // A host that cannot witness a revocation records no delegation: one - // it could never end is refused, by name, before anything is written. - svc.SetPolicyLog(nil, nil) - t.Cleanup(func() { svc.SetPolicyLog(policyLog, store) }) - _, err = client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Scopes: records("read"), Purpose: "unrevocable"}) - requireCode(err, codes.FailedPrecondition, "cannot witness a revocation") - svc.SetPolicyLog(policyLog, store) }) - // Beyond the suite: every issue from one delegation is its own hop. The - // interface names a delegated hop by the delegation's id, so the journal - // records each issue under its own row (the delegation within the task the - // issue opened), and each capability is held to the scope set it was issued - // under, whichever order the issues come in. Real Postgres keeps the first - // row on a conflict, which is what an id shared by every issue would hit. + // Beyond the suite: every issue from one delegation is held to its own + // scope set, whichever order the issues come in. t.Run("every issue from one delegation is held to its own scope set", func(t *testing.T) { client := authorityv1.NewAuthorityServiceClient(conn) - sess, err := sessionv1.NewSessionServiceClient(conn).SignIn(ctx, &sessionv1.SignInRequest{FixtureToken: "fixture-alex"}) - require.NoError(t, err) - alex := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+sess.GetBearer()) - type issue struct{ delegation, operation, task string } - var issues []issue + alex := signIn("fixture-alex") for _, order := range [][]string{{"invoke", "lookup"}, {"lookup", "invoke"}} { d, err := client.Delegate(alex, &authorityv1.DelegateRequest{Module: "org-directory", Purpose: "two issues, " + strings.Join(order, " then "), - Scopes: []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"invoke", "read"}}}}) + Scopes: records("invoke", "read")}) require.NoError(t, err) for _, operation := range order { issued, err := client.Issue(ctx, &authorityv1.IssueRequest{ @@ -690,50 +729,13 @@ func runHostInterfaceFixture(t *testing.T) { Actor: &authorityv1.WorkloadCredential{Credential: workloadCredential}, OperationBindingId: "documents", Operation: operation, Audience: "documents", }) require.NoError(t, err, "%v: issue under %s", order, operation) - wc := issued.GetContext() - require.Equal(t, d.GetDelegationId(), wc.GetActorChain()[0].GetDelegationId()) - issues = append(issues, issue{d.GetDelegationId(), operation, wc.GetTaskId()}) + granted := issued.GetContext().GetActorChain()[0].GetGrantedScopes() + want := map[string][]string{"invoke": {"invoke", "read"}, "lookup": {"read"}}[operation] + require.Equal(t, want, granted[0].GetActions(), "%v: the %s issue carries its own scope set", order, operation) _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: issued.GetToken(), Audience: "documents"}) - require.NoError(t, err, "%v: the %s issue checks against its own scope set", order, operation) + require.NoError(t, err, "%v: the %s issue checks", order, operation) } } - for _, i := range issues { - recorded, err := store.ActorChainHopBindings(ctx, fixtureOrganization, []string{delegatedHopRowID(i.delegation, i.task)}) - require.NoError(t, err) - require.Len(t, recorded, 1, "the %s issue has its own row", i.operation) - for _, row := range recorded { - require.Equal(t, i.operation, row.Operation, "the %s issue's own row", i.operation) - } - } - }) - - // Beyond the suite, which has no admission-key case yet (authority - // 49101ae1): the hop registers its admission key over the listener under - // its own workload credential, Keys publishes it apart from the Work - // Context keys, and another workload is refused by name. - t.Run("the hop's registered admission key is published in Keys", func(t *testing.T) { - client := authorityv1.NewAuthorityServiceClient(conn) - hop := cluster.Run(executiontest.Workload{Namespace: business.HopModule, ServiceAccount: business.HopService, Container: "app"}) - _, public := hopPublicKey(t, "conformance-hop") - registered, err := client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ - Actor: &authorityv1.WorkloadCredential{Credential: hop}, PublicKey: public, - }) - require.NoError(t, err) - require.Equal(t, "hop-admission", registered.GetKey().GetPurpose()) - keys, err := client.Keys(ctx, &authorityv1.KeysRequest{}) - require.NoError(t, err) - require.Len(t, keys.GetAdmissionKeys(), 1) - require.Equal(t, registered.GetKey().GetKeyId(), keys.GetAdmissionKeys()[0].GetKeyId()) - require.Equal(t, public, keys.GetAdmissionKeys()[0].GetPublicKey()) - for _, key := range keys.GetKeys() { - require.Equal(t, "work-context", key.GetPurpose()) - require.NotEqual(t, registered.GetKey().GetKeyId(), key.GetKeyId()) - } - _, err = client.RegisterAdmissionKey(ctx, &authorityv1.RegisterAdmissionKeyRequest{ - Actor: &authorityv1.WorkloadCredential{Credential: documentsCredential}, PublicKey: public, - }) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) - require.ErrorContains(t, err, "the caller is documents/documents, not the hop (authority/hop)") }) // Beyond the suite: the session a page reads is the live one. A fixture @@ -759,319 +761,137 @@ func runHostInterfaceFixture(t *testing.T) { require.Equal(t, codes.Unauthenticated, status.Code(err), "an ended session: %v", err) }) + // Beyond the suite: a fresh reviewed approval supersedes an unfinished + // attempt; the superseded operation can neither resume nor acknowledge. t.Run("fresh approval supersedes pending attempt", func(t *testing.T) { - baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - composition := compositionv1connect.NewCompositionServiceClient(http.DefaultClient, baseURL) - m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "conformance-pending-approval", Alias: "conformance-pending-approval", Kind: solutionhost.KindSolution, Generation: 1, - Host: solutionhost.HostTarget{Coordinate: "conformance-host", Domain: "acme"}, Release: solutionhost.Release{Publisher: "conformance", Name: "conformance-pending-approval", Version: "0.1.0"}, - Workloads: []solutionhost.Workload{{Service: "conformance-pending-approval", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "conformance-pending-approval", ResourceKinds: []string{"example-records"}}} - raw, err := solutionhost.SignedPayload(m) - require.NoError(t, err) - _, err = composition.ApplyManifest(ctx, connect.NewRequest(&compositionv1.ApplyManifestRequest{ - Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, Credential: "delivery:" + m.Alias, - })) - require.NoError(t, err) - sessions := sessionv1connect.NewSessionServiceClient(http.DefaultClient, baseURL) - bearer := func(fixture string) string { - t.Helper() - resp, err := sessions.SignIn(ctx, connect.NewRequest(&sessionv1.SignInRequest{FixtureToken: fixture})) + const target = "conformance-pending-approval" + page := wireScope(target+".page", "read") + deliverSolution(t, ctx, conn, signer, conformanceSolution{alias: target, generation: 1, ceilings: []*authorityv1.WorkScopeV1{page}, + roles: map[string][]*authorityv1.WorkScopeV1{"installer": {page}, "viewer": {page}}}) + admin := signIn("fixture-alex") + client := installationv1.NewInstallationServiceClient(conn) + review := func() *installationv1.ConsentApproval { + a, err := client.Review(admin, &installationv1.ReviewRequest{OrganizationId: fixtureOrganization, Target: target}) require.NoError(t, err) - return resp.Msg.GetBearer() + a.OperationId = uuid.NewString() + return a } - admin := bearer("fixture-alex") - client := installationv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - saas := accountsv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - catalogue := connect.NewRequest(&gen.ListAvailableSolutionsRequest{OrgId: fixtureOrganization}) - catalogue.Header().Set("Authorization", "Bearer "+admin) - offers, err := saas.ListAvailableSolutions(ctx, catalogue) - require.NoError(t, err) - var target string - for _, offer := range offers.Msg.GetSolutions() { - if offer.GetRouteAlias() == m.Alias { - target = offer.GetTargetId() + release := make(chan struct{}) + started := make(chan struct{}, 4) + svc.SetAuthorityInstallStep(func(ctx context.Context, _ domain.Installation) error { + started <- struct{}{} + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() } + }) + t.Cleanup(func() { svc.SetAuthorityInstallStep(nil) }) + install := func(approval *installationv1.ConsentApproval) chan error { + done := make(chan error, 1) + go func() { + _, err := client.Install(admin, &installationv1.InstallRequest{OrganizationId: fixtureOrganization, Target: target, Role: "viewer", Approval: approval}) + done <- err + }() + return done } - require.NotEmpty(t, target) - approval := &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, OperationId: uuid.NewString()} - install := func(token string) (*connect.Response[installationv1.Installation], error) { - req := connect.NewRequest(&installationv1.InstallRequest{OrganizationId: fixtureOrganization, Target: m.Alias, Role: "Collection reader", Approval: approval}) - req.Header().Set("Authorization", "Bearer "+token) - return client.Install(ctx, req) - } - first, err := install(admin) - require.NoError(t, err) - require.Equal(t, installationv1.Installation_STATUS_PENDING, first.Msg.GetStatus()) - original := approval - job, err := svc.ClaimInstallationStep(ctx, "superseded-approval") - require.NoError(t, err) - require.NotNil(t, job) - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: first.Msg.GetRevision(), OperationId: uuid.NewString()} - fresh, err := install(admin) - require.NoError(t, err, "fresh reviewed consent supersedes an unfinished attempt") - require.Equal(t, first.Msg.GetId(), fresh.Msg.GetId()) - require.Greater(t, fresh.Msg.GetRevision(), first.Msg.GetRevision()) - retry, err := install(admin) - require.NoError(t, err) - require.Equal(t, fresh.Msg, retry.Msg) - approval = original - _, err = install(admin) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_REVISION_CHANGED") - lease := &jobsv1.JobLeaseReference{JobId: job.GetId(), WorkerId: job.GetLease().GetOwner(), LeaseToken: job.GetLease().GetToken()} - require.Error(t, svc.FinishInstallationStep(ctx, lease, job.GetAttemptCount(), 204, ""), "superseded attempt cannot acknowledge") - require.NoError(t, acknowledgeInstallSteps(ctx, svc)) + first := review() + firstDone := install(first) + <-started + fresh := review() + require.Greater(t, fresh.GetExpectedRevision(), uint64(0), "the review names the pending attempt's revision") + freshDone := install(fresh) + <-started + close(release) + require.NoError(t, <-freshDone, "the fresh approval acknowledges") + err := <-firstDone + requireCode(t, err, codes.FailedPrecondition, "INSTALLATION_APPROVAL") + _, err = client.Install(admin, &installationv1.InstallRequest{OrganizationId: fixtureOrganization, Target: target, Role: "viewer", Approval: first}) + requireCode(t, err, codes.FailedPrecondition, "INSTALLATION_APPROVAL_REVISION_CHANGED") + replayed, err := client.Install(admin, &installationv1.InstallRequest{OrganizationId: fixtureOrganization, Target: target, Role: "viewer", Approval: fresh}) + require.NoError(t, err, "the unchanged fresh request replays") + require.Equal(t, installationv1.Installation_STATUS_ACTIVE, replayed.GetStatus()) }) - t.Run("admin re-install through Connect is explicit consent", func(t *testing.T) { - baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - composition := compositionv1connect.NewCompositionServiceClient(http.DefaultClient, baseURL) - m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "conformance-reconsent", Alias: "conformance-reconsent", Kind: solutionhost.KindModule, Generation: 1, - Host: solutionhost.HostTarget{Coordinate: "conformance-host", Domain: "acme"}, Release: solutionhost.Release{Publisher: "conformance", Name: "conformance-reconsent", Version: "0.1.0"}, - Workloads: []solutionhost.Workload{{Service: "conformance-reconsent", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "conformance-reconsent", ResourceKinds: []string{"example-records"}}} - raw, err := solutionhost.SignedPayload(m) - require.NoError(t, err) - _, err = composition.ApplyManifest(ctx, connect.NewRequest(&compositionv1.ApplyManifestRequest{ - Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, Credential: "delivery:" + m.Alias, + // Beyond the suite: what the core changes is in the audit record, in the + // transaction that changed it, naming the organization. + t.Run("every change of authority is audited", func(t *testing.T) { + var count int + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, `SELECT count(*) FROM public.audit_events WHERE event_type LIKE 'saas.authority.%' AND payload->>'organization' = $1`, fixtureOrganization).Scan(&count) })) - require.NoError(t, err) - sessions := sessionv1connect.NewSessionServiceClient(http.DefaultClient, baseURL) - bearer := func(fixture string) string { - t.Helper() - resp, err := sessions.SignIn(ctx, connect.NewRequest(&sessionv1.SignInRequest{FixtureToken: fixture})) - require.NoError(t, err) - return resp.Msg.GetBearer() + require.Greater(t, count, 0) + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, `SELECT count(*) FROM public.audit_events WHERE event_type = 'saas.authority.role_revoked'`).Scan(&count) + })) + require.Greater(t, count, 0, "the suite's revocations are audited") + }) + + // Last, because the envelope it writes becomes every module's ceiling: a + // real envelope write that narrows a module's binding (the write + // PutModuleEnvelope makes, without its platform administrator check) is + // refused at once until the registry is derived again, then issues only + // what the narrowed ceiling allows, and a capability issued before it + // fails its next check. + t.Run("an envelope narrowing a binding ends what was issued through it", func(t *testing.T) { + client := authorityv1.NewAuthorityServiceClient(conn) + const module = "conformance-envelope" + deliverModule(t, ctx, conn, signer, conformanceModule{alias: module, ceilings: []*authorityv1.WorkScopeV1{wireScope("conformance-envelope.task", "read")}, + bindings: []solutionhost.AuthorityBinding{{ID: "model", Audience: "model", Operations: map[string][]string{"headless": {"modelservice.profiles:read", "modelservice.datasets:read"}}}}}) + workloadOf := credential(module) + issue := func() (*authorityv1.IssueResponse, error) { + return client.Issue(ctx, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: workloadOf}}, + OperationBindingId: "model", Audience: "model", OrganizationId: fixtureOrganization}) } - admin, member := bearer("fixture-alex"), bearer("fixture-remi") - client := installationv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - saas := accountsv1connect.NewInstallationServiceClient(http.DefaultClient, baseURL) - catalogue := connect.NewRequest(&gen.ListAvailableSolutionsRequest{OrgId: fixtureOrganization}) - catalogue.Header().Set("Authorization", "Bearer "+admin) - offers, err := saas.ListAvailableSolutions(ctx, catalogue) - require.NoError(t, err) - var target string - for _, offer := range offers.Msg.GetSolutions() { - if offer.GetRouteAlias() == m.Alias { - target = offer.GetTargetId() + kinds := func(r *authorityv1.IssueResponse) []string { + var out []string + for _, sc := range r.GetContext().GetAuthorityScopes() { + out = append(out, sc.GetResourceKind()) } + return out } - require.NotEmpty(t, target) - approval := &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, OperationId: uuid.NewString()} - install := func(token string) (*connect.Response[installationv1.Installation], error) { - req := connect.NewRequest(&installationv1.InstallRequest{OrganizationId: fixtureOrganization, Target: m.Alias, Role: "Collection reader", Approval: approval}) - req.Header().Set("Authorization", "Bearer "+token) - return client.Install(ctx, req) - } - first, err := install(admin) + require.NoError(t, consentModule(ctx, svc, store, fixtureOrganization, conformanceAlex, module)) + before, err := issue() require.NoError(t, err) - retry, err := install(admin) - require.NoError(t, err, "initial retry is idempotent") - require.Equal(t, first.Msg, retry.Msg) - originalApproval := approval - approval = nil - _, err = install(admin) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_REQUIRED") - approval = proto.Clone(originalApproval).(*installationv1.ConsentApproval) - approval.ExpectedRevision = first.Msg.GetRevision() - _, err = install(admin) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_OPERATION_CONFLICT") - approval = originalApproval - readRevision := func() uint64 { - req := connect.NewRequest(&installationv1.ListRequest{OrganizationId: fixtureOrganization}) - req.Header().Set("Authorization", "Bearer "+admin) - rows, err := client.List(ctx, req) - require.NoError(t, err) - for _, row := range rows.Msg.GetInstallations() { - if row.GetId() == first.Msg.GetId() { - return row.GetRevision() - } - } - t.Fatal("installation missing from listing") - return 0 - } - svc.SetModuleAuthorityReads(store, nil) - t.Cleanup(func() { - svc.SetModuleAuthorityReads(withdrawnModuleInstallations{ComposedModuleInstallations: composed, withdrawn: "conformance-idle"}, nil) - }) - caller := business.ModuleCaller{PrincipalID: business.ModulePrincipalID(m.Alias), BoundOrg: fixtureOrganization} - _, err = svc.AuthorizeModuleCapability(ctx, caller) - require.NoError(t, err, "creation consents") - require.NoError(t, store.WithOrgTx(ctx, fixtureOrganization, func(ctx context.Context) error { - _, err := storetx.Tx(ctx).Exec(ctx, `UPDATE public.installations SET scope='{"capabilities":true,"follows":true}'::jsonb WHERE id=$1`, first.Msg.GetId()) + require.Contains(t, kinds(before), "modelservice.datasets") + + var latest uint64 + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + latest, err = store.LatestModuleEnvelopeRevision(ctx) return err })) - _, err = svc.AuthorizeModuleCapability(ctx, caller) - require.Equal(t, codes.FailedPrecondition, status.Code(err)) - _, err = install(member) - require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) - _, err = svc.AuthorizeModuleCapability(ctx, caller) - require.Equal(t, codes.FailedPrecondition, status.Code(err), "member re-install does not consent") - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: readRevision(), OperationId: uuid.NewString()} - again, err := install(admin) - require.NoError(t, err, "the named remedy returns success on the wire") - require.Equal(t, first.Msg.GetId(), again.Msg.GetId()) - _, err = svc.AuthorizeModuleCapability(ctx, caller) - require.NoError(t, err, "admin re-install records usable consent") - retry, err = install(admin) - require.NoError(t, err, "re-consent retry is idempotent") - require.Equal(t, again.Msg, retry.Msg) - // Preserve the exact successful request and still-valid bearer across - // a wider signed delivery: this is the reviewer's replay schedule. - m.Generation++ - m.Authority.ResourceKinds = append(m.Authority.ResourceKinds, "example-sensitive") - raw, err = solutionhost.SignedPayload(m) - require.NoError(t, err) - _, err = composition.ApplyManifest(ctx, connect.NewRequest(&compositionv1.ApplyManifestRequest{Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, Credential: "delivery:" + m.Alias})) - require.NoError(t, err) - _, err = install(admin) - require.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_GENERATION_CHANGED") - held, err := svc.AuthorizeModuleCapability(ctx, caller) - require.NoError(t, err) - require.Equal(t, []string{"example-records"}, held.Grant.Resources) - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: readRevision(), OperationId: uuid.NewString()} - _, err = install(admin) - require.NoError(t, err, "fresh review explicitly approves generation two") - held, err = svc.AuthorizeModuleCapability(ctx, caller) - require.NoError(t, err) - require.ElementsMatch(t, m.Authority.ResourceKinds, held.Grant.Resources) - // Exercise the other public remedy using its generated Connect client. - details, err := svc.ReadHostInstallation(ctx, fixtureOrganization, first.Msg.GetId()) + doc := []byte(fmt.Sprintf(`{"revision":%d,"principals":{%q:{"grant":{"tenant":%q,"resources":["conformance-envelope.task"],"operation_audiences":{"model":{"audience":"model","headless_scopes":[{"resource_kind":"modelservice.profiles","actions":["read"]}]}}}}}}`, + latest+1, module, fixtureOrganization)) + _, err = business.ParseModuleEnvelope(doc) require.NoError(t, err) - saasBody := &gen.InstallSolutionRequest{OrgId: fixtureOrganization, TargetId: target, AgentIdentifier: "conformance/conformance-reconsent:0.1.0", RoleId: details.RoleID, Approval: &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: readRevision(), OperationId: uuid.NewString()}} - installSaaS := func() error { - req := connect.NewRequest(saasBody) - req.Header().Set("Authorization", "Bearer "+admin) - _, err := saas.InstallSolution(ctx, req) - return err - } - require.NoError(t, installSaaS()) - require.NoError(t, installSaaS(), "same-generation retry is idempotent") - // Narrow through the real lifecycle owner, then retry both saved bodies. - narrowed := business.FullInstallationScope(held.Grant) - narrowed.Resources = []string{"example-records"} - scopeJSON, err := json.Marshal(narrowed) - require.NoError(t, err) - narrowReq := connect.NewRequest(&gen.NarrowInstallationRequest{OrgId: fixtureOrganization, InstallationId: first.Msg.GetId(), Scope: string(scopeJSON)}) - narrowReq.Header().Set("Authorization", "Bearer "+admin) - _, err = saas.NarrowInstallation(ctx, narrowReq) - require.NoError(t, err) - _, err = install(admin) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_REVISION_CHANGED") - require.ErrorContains(t, installSaaS(), "INSTALLATION_APPROVAL_REVISION_CHANGED") - held, err = svc.AuthorizeModuleCapability(ctx, caller) - require.NoError(t, err) - require.Equal(t, []string{"example-records"}, held.Grant.Resources) - m.Generation++ - raw, err = solutionhost.SignedPayload(m) - require.NoError(t, err) - _, err = composition.ApplyManifest(ctx, connect.NewRequest(&compositionv1.ApplyManifestRequest{Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, Credential: "delivery:" + m.Alias})) - require.NoError(t, err) - require.ErrorContains(t, installSaaS(), "INSTALLATION_APPROVAL_GENERATION_CHANGED") - - t.Run("list crosses a page of revoked history", func(t *testing.T) { - require.NoError(t, store.WithOrgTx(ctx, fixtureOrganization, func(ctx context.Context) error { - _, err := storetx.Tx(ctx).Exec(ctx, `INSERT INTO public.installations SELECT (jsonb_populate_record(NULL::public.installations, to_jsonb(i) || jsonb_build_object('id', '00000000-0000-0000-0000-' || lpad(n::text, 12, '0'), 'status', 'revoked', 'revoked_at', now(), 'consent_receipt', '{}'::jsonb))).* FROM public.installations i CROSS JOIN generate_series(1,201) n WHERE i.id=$1`, first.Msg.GetId()) - return err - })) - req := connect.NewRequest(&installationv1.ListRequest{OrganizationId: fixtureOrganization}) - req.Header().Set("Authorization", "Bearer "+admin) - listed, err := client.List(ctx, req) - require.NoError(t, err) - found := false - for _, row := range listed.Msg.GetInstallations() { - if row.GetId() == first.Msg.GetId() { - found = true - } - } - require.True(t, found, "host List lost the active installation behind revoked history") - }) + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + return store.AppendModuleEnvelopeRecord(ctx, &business.ModuleEnvelopeRecord{Revision: latest + 1, Document: doc, WrittenBy: conformanceAlex}, latest) + })) - t.Run("older receipt cannot recreate authority after revoke", func(t *testing.T) { - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: readRevision(), OperationId: uuid.NewString()} - a := approval - acceptedA, err := install(admin) - require.NoError(t, err) - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: acceptedA.Msg.GetRevision(), OperationId: uuid.NewString()} - acceptedB, err := install(admin) - require.NoError(t, err) - require.Greater(t, acceptedB.Msg.GetRevision(), acceptedA.Msg.GetRevision(), "every fresh receipt advances revision, even unchanged consent") - uninstall := connect.NewRequest(&gen.UninstallSolutionRequest{OrgId: fixtureOrganization, InstallationId: first.Msg.GetId()}) - uninstall.Header().Set("Authorization", "Bearer "+admin) - _, err = saas.UninstallSolution(ctx, uninstall) - require.NoError(t, err) - approval = a - _, err = install(admin) - require.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_REVISION_CHANGED") - revokedRevision := readRevision() - require.Greater(t, revokedRevision, acceptedB.Msg.GetRevision()) - approval = &installationv1.ConsentApproval{TargetId: target, Generation: m.Generation, ExpectedRevision: revokedRevision, OperationId: uuid.NewString()} - reinstalled, err := install(admin) - require.NoError(t, err, "fresh approval of listed revoked revision reinstalls") - require.NotEqual(t, first.Msg.GetId(), reinstalled.Msg.GetId()) - require.Greater(t, reinstalled.Msg.GetRevision(), revokedRevision) - retried, err := install(admin) - require.NoError(t, err) - require.Equal(t, reinstalled.Msg, retried.Msg) - approval = a - _, err = install(admin) - require.ErrorContains(t, err, "INSTALLATION_APPROVAL_REVISION_CHANGED") - first = reinstalled - }) + _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: before.GetToken(), Audience: "model"}) + require.Error(t, err, "a capability issued under the replaced ceiling fails its next check") + _, err = issue() + requireCode(t, err, codes.Unavailable, "is committed") - t.Run("list returns more than two hundred active installations", func(t *testing.T) { - // Seed valid independent targets; the test exercises the production - // listing through its generated Connect client, not 201 deliveries. - var targetIDs, bindingIDs []string - require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { - _, err := storetx.Tx(ctx).Exec(ctx, `INSERT INTO public.solution_host_bindings(binding_id,host_coordinate) - SELECT 'pagination-' || n, 'conformance-host' FROM generate_series(1,201) n`) - if err != nil { - return err - } - _, err = storetx.Tx(ctx).Exec(ctx, `INSERT INTO public.solution_targets(binding_id,solution_id,opened_generation) - SELECT 'pagination-' || n, 'pagination-' || n, 1 FROM generate_series(1,201) n`) - if err != nil { - return err - } - rows, err := storetx.Tx(ctx).Query(ctx, `SELECT id::text, binding_id FROM public.solution_targets WHERE binding_id LIKE 'pagination-%'`) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var id, binding string - if err := rows.Scan(&id, &binding); err != nil { - return err - } - targetIDs, bindingIDs = append(targetIDs, id), append(bindingIDs, binding) - } - return rows.Err() - })) - require.NoError(t, store.WithOrgTx(ctx, fixtureOrganization, func(ctx context.Context) error { - _, err := storetx.Tx(ctx).Exec(ctx, `INSERT INTO public.installations - SELECT (jsonb_populate_record(NULL::public.installations, to_jsonb(i) || jsonb_build_object( - 'id',gen_random_uuid(), 'target_id',t.id, 'registry_binding_id',t.binding_id, 'registry_principal_id',NULL, 'consent_receipt','{}'::jsonb))).* - FROM public.installations i CROSS JOIN unnest($2::uuid[], $3::text[]) AS t(id,binding_id) WHERE i.id=$1`, first.Msg.GetId(), targetIDs, bindingIDs) - return err - })) - req := connect.NewRequest(&installationv1.ListRequest{OrganizationId: fixtureOrganization}) - req.Header().Set("Authorization", "Bearer "+admin) - listed, err := client.List(ctx, req) - require.NoError(t, err) - active := 0 - seen := map[string]bool{} - for _, row := range listed.Msg.GetInstallations() { - require.False(t, seen[row.GetId()], "listing must not repeat an installation") - seen[row.GetId()] = true - if row.GetStatus() == installationv1.Installation_STATUS_ACTIVE { - active++ - } - } - require.GreaterOrEqual(t, active, 202) + pass, err := business.NewSolutionHostBindingReconciler(svc, business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(svc), Verifier: verifier, Coordinate: "conformance-host", Envelopes: svc, }) - + require.NoError(t, err) + // The envelope names only this module, so the pass reports every other + // composed module as omitted from the registry; it still installs the + // registry this module's narrowed grant is read from. + if err := pass.RunOnce(ctx); err != nil { + require.ErrorContains(t, err, "omitted from the registry") + require.NotContains(t, err.Error(), module) + } + after, err := issue() + require.NoError(t, err) + require.Equal(t, []string{"modelservice.profiles"}, kinds(after), "the narrowed ceiling issues only what it allows") + _, err = client.Check(ctx, &authorityv1.CheckRequest{Token: before.GetToken(), Audience: "model"}) + require.Error(t, err, "and the capability issued before stays refused") }) - } // serveConnect holds its :0 listener until cleanup and reads the assigned port diff --git a/module/services/accounts/code/pkg/adapters/host_interface_delegation.go b/module/services/accounts/code/pkg/adapters/host_interface_delegation.go deleted file mode 100644 index f29160085..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_delegation.go +++ /dev/null @@ -1,370 +0,0 @@ -package adapters - -// A person's delegation to a module behind the host interface: Delegate, -// GetDelegation, RevokeDelegation, and Issue from a delegation. -// -// The delegation is a standing ceiling, never a grant of its own. Delegate -// holds what it names to what the person holds now, at the module and at each -// audience its bindings reach. An issue from it re-reads the delegation and the -// grantor's current grants through the binding the module exercises, and -// issues for the grantor, with the module as the one acting hop carrying the -// delegation's id: the same hop a module's child derives (hopFrom), from the -// grantor's standing instead of a parent capability. Every later hop of that -// chain is cut to the delegation again (chainDelegations), so no hop below it -// gets what the person did not delegate. Every hop of that chain is sealed to -// the delegation (business.DelegatedOperationBindingID), which every verifier -// reads live, so revoking it fails each of those capabilities at its next -// check and touches nothing else its grantor holds. A host that cannot witness -// a revocation records no delegation. - -import ( - "cmp" - "context" - "errors" - "slices" - "time" - "unicode/utf8" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" - - accountsauth "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// Delegate records the signed-in person's grant to a module, within what they -// hold now. -func (s *HostAuthorityServer) Delegate(ctx context.Context, req *authorityv1.DelegateRequest) (*authorityv1.Delegation, error) { - identity, err := delegatingPerson(ctx, "delegate") - if err != nil { - return nil, err - } - if err := refuseOwnerSelected(req.GetScopes()); err != nil { - return nil, err - } - module := req.GetModule() - if service == nil || !service.IsDeclaredModulePrincipal(business.ModulePrincipalID(module)) { - return nil, status.Errorf(codes.NotFound, "module %q is not composed", module) - } - // Fail closed: a delegation this host could never end is never recorded. - if !service.ModuleDelegationsRevocable() { - return nil, delegationStatus(business.ErrModuleDelegationUnrevocable) - } - if len(req.GetScopes()) == 0 { - return nil, status.Error(codes.InvalidArgument, "a delegation names what it grants") - } - if utf8.RuneCountInString(req.GetPurpose()) > business.ModuleDelegationPurposeLimit { - return nil, delegationStatus(business.ErrModuleDelegationPurposeTooLong) - } - org, person := identity.OrgID.String(), identity.EffectiveSubject.String() - if err := requireOrgMember(ctx, person, org); err != nil { - return nil, err - } - // A delegation is used only through the module's installation in the - // grantor's organisation, so one to a module installed nowhere there is a - // row no issue could ever spend: refused, by name, before it is recorded. - if _, err := service.AuthorizeModuleCapability(ctx, business.ModuleCaller{PrincipalID: business.ModulePrincipalID(module), BoundOrg: org}); err != nil { - return nil, moduleInstallationRefusal(ctx, "host.authority.v1.AuthorityService/Delegate", err, - status.Errorf(codes.FailedPrecondition, "module %q is not installed in organization %s, so a delegation to it could never be used", module, org)) - } - _, scopes, err := workContextScopes(scopesToHost(req.GetScopes()), mintContentReads(module)) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - audiences := delegationAudiences(module) - for _, scope := range scopes { - if len(scope.GetActions()) == 0 { - return nil, status.Errorf(codes.InvalidArgument, "a delegated scope names its actions: %s", scope.GetResourceKind()) - } - for _, action := range scope.GetActions() { - one := &gen.WorkContextScope{ResourceKind: scope.GetResourceKind(), Actions: []string{action}, ResourceIds: scope.GetResourceIds()} - held, err := s.heldAtAny(ctx, org, person, audiences, one) - if err != nil { - return nil, err - } - if !held { - return nil, status.Errorf(codes.PermissionDenied, "%s delegates what they do not hold: %s, at none of %v", person, scopeString(&basev0.WorkScopeV1{ - ResourceKind: one.GetResourceKind(), Actions: one.GetActions(), ResourceIds: one.GetResourceIds(), - }), audiences) - } - } - } - d := &business.ModuleDelegation{OrgID: org, PrincipalID: person, ModulePrefix: module, Scopes: operationScopes(scopes), Purpose: req.GetPurpose()} - if err := service.RecordModuleDelegation(ctx, d); err != nil { - return nil, delegationStatus(err) - } - return delegationToWire(d), nil -} - -// GetDelegation reads one delegation for the module it is granted to (by its -// workload credential) or for its grantor (by the bearer). Anyone else is told -// it does not exist. -func (s *HostAuthorityServer) GetDelegation(ctx context.Context, req *authorityv1.GetDelegationRequest) (*authorityv1.Delegation, error) { - if service == nil { - return nil, status.Error(codes.Unavailable, "the authority is not wired") - } - id := req.GetDelegationId() - if credential := req.GetActor().GetCredential(); credential != "" { - prefix, err := service.ModulePrefixOfWorkload(ctx, credential) - if err != nil { - return nil, credentialRefusal(err, "actor") - } - d, err := service.ModuleDelegation(ctx, "", id) - if err != nil { - return nil, delegationStatus(err) - } - if d == nil || d.ModulePrefix != prefix { - return nil, status.Errorf(codes.NotFound, "no delegation %q is granted to %s", id, prefix) - } - return delegationToWire(d), nil - } - identity, ok := accountsauth.VerifiedRequestIdentity(ctx) - if !ok { - return nil, status.Error(codes.Unauthenticated, "a delegation is read by its module, with its workload credential, or by its grantor, signed in; the call carries neither") - } - d, err := service.ModuleDelegation(ctx, identity.OrgID.String(), id) - if err != nil { - return nil, delegationStatus(err) - } - if d == nil || d.PrincipalID != identity.EffectiveSubject.String() { - return nil, status.Errorf(codes.NotFound, "no delegation %q of yours", id) - } - return delegationToWire(d), nil -} - -// RevokeDelegation ends a delegation, for its grantor or an administrator of -// its organisation; the next issue from it, and every capability issued under -// it, is refused. -func (s *HostAuthorityServer) RevokeDelegation(ctx context.Context, req *authorityv1.RevokeDelegationRequest) (*authorityv1.RevokeDelegationResponse, error) { - identity, err := delegatingPerson(ctx, "revoke a delegation") - if err != nil { - return nil, err - } - if service == nil { - return nil, status.Error(codes.Unavailable, "the authority is not wired") - } - org, person := identity.OrgID.String(), identity.EffectiveSubject.String() - role, err := lookupMembership(ctx, org, person) - if err != nil { - return nil, membershipLookupStatus("cannot verify membership", err) - } - if role == "" { - return nil, status.Error(codes.PermissionDenied, "not a member of this organization") - } - d, err := service.RevokeModuleDelegation(ctx, org, person, req.GetDelegationId(), - role == gen.OrgRole_ORG_ROLE_ADMIN.String() || role == gen.OrgRole_ORG_ROLE_OWNER.String()) - if err != nil { - return nil, delegationStatus(err) - } - return &authorityv1.RevokeDelegationResponse{Delegation: delegationToWire(d)}, nil -} - -// delegatingPerson is the signed-in person a delegation is made or revoked by, -// in their own right: an interactive session, never a credential confined to -// one consumer (it acts only within that consumer's ceiling, and a delegation -// would outlive and exceed it), never an identity acting on someone's behalf -// (X-Act) or viewed as by an administrator, and never an API key. The same -// credentials every other person mint holds back. -func delegatingPerson(ctx context.Context, what string) (accountsauth.RequestIdentity, error) { - identity, ok := accountsauth.VerifiedRequestIdentity(ctx) - if !ok { - return identity, status.Errorf(codes.Unauthenticated, "only the signed-in person may %s; the call carries no bearer", what) - } - if _, confined := accountsauth.VerifiedResourceConfinement(ctx); confined { - return identity, status.Errorf(codes.PermissionDenied, "a resource-confined credential cannot %s: it acts only within its consumer's ceiling, and a delegation is a standing grant beyond it", what) - } - if _, acting := accountsauth.VerifiedActorFromContext(ctx); acting || identity.Delegation != nil { - return identity, status.Errorf(codes.PermissionDenied, "an identity acting on someone's behalf (X-Act) cannot %s for them", what) - } - if identity.Impersonated() { - return identity, status.Errorf(codes.PermissionDenied, "an impersonated session cannot %s for the person it views as", what) - } - if kind := credentialKindFromContext(ctx); kind != credentialKindSession { - return identity, status.Errorf(codes.PermissionDenied, "only an interactive session may %s; this call presented %s", what, cmp.Or(kind, "no credential kind")) - } - return identity, nil -} - -// delegationHop is Issue from a delegation: the acting module proves itself, -// the delegation must be granted to it and stand, and the hop is the module's -// through the binding it names, for the grantor. Nothing is read from a live -// capability, so renewing is issuing again. -func (s *HostAuthorityServer) delegationHop(ctx context.Context, id string, req *authorityv1.IssueRequest) (*gen.IssuedWorkContext, error) { - if req.GetActor().GetCredential() == "" { - return nil, status.Error(codes.Unauthenticated, "an issue from a delegation names the module acting (actor); the call carries no actor credential") - } - prefix, err := service.ModulePrefixOfWorkload(ctx, req.GetActor().GetCredential()) - if err != nil { - return nil, credentialRefusal(err, "actor") - } - d, err := service.ModuleDelegation(ctx, "", id) - if err != nil { - return nil, delegationStatus(err) - } - if d == nil || d.ModulePrefix != prefix { - return nil, status.Errorf(codes.NotFound, "no delegation %q is granted to %s", id, prefix) - } - if !d.Active() { - return nil, status.Errorf(codes.PermissionDenied, "delegation %s is revoked", id) - } - if req.GetOperationBindingId() == "" { - return nil, status.Errorf(codes.PermissionDenied, "an issue from a delegation names the binding it exercises; %s named none", prefix) - } - if err := requireComposed(req.GetAudience()); err != nil { - return nil, err - } - if err := requireDeclaredHopBinding(prefix, req); err != nil { - return nil, err - } - if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { - return nil, err - } - // The grantor's standing, as the root the module's one hop derives from: - // a fresh task in the delegation's organisation, bounded only by the - // issuer's ceiling. - now := time.Now() - if s.workContexts.now != nil { - now = s.workContexts.now() - } - standing := &basev0.WorkContextV1{ - TenantId: d.OrgID, OrganizationId: proto.String(d.OrgID), TaskId: uuid.NewString(), - OwnerPrincipalId: d.PrincipalID, OwnerPrincipalKind: proto.String(business.PrincipalKindHuman), - ExpiresAtUnix: now.Add(workcontext.DefaultMaxTTL).Unix(), - } - issued, err := s.hopFrom(ctx, prefix, standing, req, d) - if err != nil { - return nil, err - } - // A revocation that committed while the hop was signed may have left it - // sealed to the revision before; refuse it here rather than hand out a - // capability its next check refuses. - if after, err := service.ModuleDelegation(ctx, "", id); err != nil { - return nil, delegationStatus(err) - } else if !after.Active() { - return nil, status.Errorf(codes.PermissionDenied, "delegation %s is revoked", id) - } - return issued, nil -} - -// chainDelegations is every stored delegation a hop of claims acts under, and -// issuing, each confirmed for the chain: active, its grantor the owner, in the -// owner's organisation, and granted to the module on that hop. -func (s *HostAuthorityServer) chainDelegations(ctx context.Context, claims *basev0.WorkContextV1, issuing *business.ModuleDelegation) ([]*business.ModuleDelegation, error) { - hops := map[string]string{} // delegation id -> the module acting under it, by name - ids := make([]string, 0, len(claims.GetActorChain())) - for _, hop := range claims.GetActorChain() { - if id := hop.GetDelegationId(); id != "" { - hops[id] = hop.GetPrincipalId() - ids = append(ids, id) - } - } - var stored []*business.ModuleDelegation - if len(ids) > 0 { - var err error - if stored, err = service.ModuleDelegations(ctx, claims.GetTenantId(), ids); err != nil { - return nil, delegationStatus(err) - } - } - for _, d := range stored { - switch { - case !d.Active(): - return nil, status.Errorf(codes.PermissionDenied, "delegation %s is revoked", d.ID) - case d.PrincipalID != claims.GetOwnerPrincipalId() || d.OrgID != claims.GetTenantId() || - d.ModulePrefix != hops[d.ID]: - return nil, status.Errorf(codes.PermissionDenied, "delegation %s does not authorize this actor chain", d.ID) - } - } - if issuing != nil { - stored = append(stored, issuing) - } - return stored, nil -} - -// delegationAudiences is where a person may hold what they delegate to -// module: the module itself and every audience its bindings reach. -func delegationAudiences(module string) []string { - audiences := []string{module} - for _, binding := range service.ModulePrincipals()[business.ModulePrincipalID(module)].OperationAudiences { - if !slices.Contains(audiences, binding.Audience) { - audiences = append(audiences, binding.Audience) - } - } - slices.Sort(audiences[1:]) - return audiences -} - -// heldAtAny reports whether the person holds scope at one of audiences, by -// the check every mint makes. -func (s *HostAuthorityServer) heldAtAny(ctx context.Context, org, person string, audiences []string, scope *gen.WorkContextScope) (bool, error) { - for _, audience := range audiences { - held, err := s.workContexts.personHolds(ctx, org, person, audience, scope) - if err != nil || held { - return held, err - } - } - return false, nil -} - -// intersectScopes is what both scope sets allow, kind by kind. -func intersectScopes(a, b []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - var out []*basev0.WorkScopeV1 - for _, x := range a { - for _, y := range b { - if x.GetResourceKind() != y.GetResourceKind() { - continue - } - if both := intersectScope(x, y); both != nil { - out = append(out, both) - } - } - } - return out -} - -func delegatedScopes(d *business.ModuleDelegation) []*basev0.WorkScopeV1 { - out := make([]*basev0.WorkScopeV1, 0, len(d.Scopes)) - for _, scope := range d.Scopes { - out = append(out, &basev0.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: slices.Clone(scope.Actions), ResourceIds: slices.Clone(scope.ResourceIDs)}) - } - return out -} - -func operationScopes(scopes []*basev0.WorkScopeV1) []business.ModuleOperationScope { - out := make([]business.ModuleOperationScope, 0, len(scopes)) - for _, scope := range scopes { - out = append(out, business.ModuleOperationScope{ResourceKind: scope.GetResourceKind(), Actions: slices.Clone(scope.GetActions()), ResourceIDs: slices.Clone(scope.GetResourceIds())}) - } - return out -} - -func delegationToWire(d *business.ModuleDelegation) *authorityv1.Delegation { - out := &authorityv1.Delegation{ - DelegationId: d.ID, OrganizationId: d.OrgID, Grantor: d.PrincipalID, Module: d.ModulePrefix, - Purpose: d.Purpose, Active: d.Active(), - } - for _, scope := range d.Scopes { - out.Scopes = append(out.Scopes, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: slices.Clone(scope.Actions), ResourceIds: slices.Clone(scope.ResourceIDs)}) - } - return out -} - -// delegationStatus names a delegation store refusal on the wire. -func delegationStatus(err error) error { - switch { - case errors.Is(err, business.ErrModuleDelegationNotFound): - return status.Error(codes.NotFound, "no such delegation of yours") - case errors.Is(err, business.ErrModuleDelegationInvalid), errors.Is(err, business.ErrModuleDelegationPurposeTooLong): - return status.Error(codes.InvalidArgument, err.Error()) - case errors.Is(err, business.ErrModuleDelegationUnrevocable): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, business.ErrPolicyLogUnreachable): - return status.Errorf(codes.Unavailable, "the revocation cannot be witnessed, so it is refused: %v", err) - } - return interfaceStatus(err) -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_delegation_test.go b/module/services/accounts/code/pkg/adapters/host_interface_delegation_test.go deleted file mode 100644 index 1c2258806..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_delegation_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package adapters - -import ( - "context" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/google/uuid" - "google.golang.org/grpc/codes" - - accountsauth "accounts/pkg/auth" - authorityv1 "accounts/pkg/gen/host/authority/v1" -) - -// What a delegation leaves of what the owner holds is the meet, kind by kind: -// never an action or a resource either side lacks, and a kind only one names -// is gone. -func TestADelegationCutsWhatTheOwnerHoldsKindByKind(t *testing.T) { - held := []*basev0.WorkScopeV1{ - hopScope("example-records", []string{"invoke", "read"}), - hopScope("tool.items", []string{"read"}), - } - delegated := []*basev0.WorkScopeV1{ - hopScope("example-records", []string{"read", "write"}, "a"), - hopScope("modelservice.profiles", []string{"invoke"}), - } - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"}, "a")}, intersectScopes(held, delegated)) - if got := intersectScopes(held, []*basev0.WorkScopeV1{hopScope("example-records", []string{"write"})}); len(got) != 0 { - t.Fatalf("a delegation of what the owner does not hold leaves %v", got) - } -} - -// A delegation is made and revoked by a person in their own right: the -// credentials every other person mint holds back are refused by name before -// anything is read or recorded. -func TestADelegationRefusesACredentialThatIsNotThePersonsOwn(t *testing.T) { - subject := uuid.New() - person := accountsauth.RequestIdentity{RealActor: subject, EffectiveSubject: subject, OrgID: uuid.New(), SessionID: uuid.New()} - signedIn := func() context.Context { - return withCredentialKind(accountsauth.WithVerifiedRequestIdentity(context.Background(), person), credentialKindSession) - } - acting := person - acting.Delegation = &accountsauth.Actor{Subject: "a-service"} - for name, tc := range map[string]struct { - ctx context.Context - reason string - }{ - "a resource-confined credential": {accountsauth.WithVerifiedResourceConfinement(signedIn(), "https://acme.test/solutions/pages/mcp"), "resource-confined"}, - "an identity acting on someone's behalf": {accountsauth.WithVerifiedActor(signedIn(), &accountsauth.Actor{Subject: "a-service"}), "X-Act"}, - "a delegated identity": {withCredentialKind(accountsauth.WithVerifiedRequestIdentity(context.Background(), acting), credentialKindSession), "X-Act"}, - "an API key": {withCredentialKind(accountsauth.WithVerifiedRequestIdentity(context.Background(), person), credentialKindAPIKey), "interactive session"}, - "no bearer": {context.Background(), "carries no bearer"}, - } { - t.Run(name, func(t *testing.T) { - code := codes.PermissionDenied - if tc.reason == "carries no bearer" { - code = codes.Unauthenticated - } - _, err := (&HostAuthorityServer{}).Delegate(tc.ctx, &authorityv1.DelegateRequest{Module: "documents", - Scopes: []*authorityv1.WorkScopeV1{{ResourceKind: "example-records", Actions: []string{"read"}}}}) - requireRefused(t, err, code, tc.reason) - _, err = (&HostAuthorityServer{}).RevokeDelegation(tc.ctx, &authorityv1.RevokeDelegationRequest{DelegationId: uuid.NewString()}) - requireRefused(t, err, code, tc.reason) - }) - } -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_holdings.go b/module/services/accounts/code/pkg/adapters/host_interface_holdings.go deleted file mode 100644 index 72d7782a9..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_holdings.go +++ /dev/null @@ -1,220 +0,0 @@ -package adapters - -import ( - "context" - "slices" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/business" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// personHoldings is what a person's empty ask means at an audience: everything -// they hold there (HOST_INTERFACE.md gap 11). -// -// Nothing here decides authority. The candidates are every (kind, action) this -// host can name at the audience: the kinds the audience module declares as its -// content, each with the actions the composed permission vocabulary, the -// organisation's roles and the composition's bindings to the audience name on -// it. Each candidate is kept only if the person holds it by the check every mint -// makes (personHolds); one the person holds only on named resources is kept on -// exactly those resources, as their resource-limited grants list them. StartTask -// then decides the ask again as it decides any other. A person who holds -// nothing there is refused by name. -func (s *WorkContextAuthorityServer) personHoldings(ctx context.Context, orgID, audience string) ([]*gen.WorkContextScope, error) { - ownerID, err := s.authorizeOwner(ctx, orgID) - if err != nil { - return nil, err - } - kinds, err := service.ModuleContentResources(audience) - if err != nil { - return nil, status.Errorf(codes.NotFound, "audience %q declares no content: %v", audience, err) - } - roles, err := service.ListRoles(ctx, &gen.ListRolesRequest{OrgId: orgID}) - if err != nil { - return nil, status.Error(codes.Unavailable, err.Error()) - } - var held []*gen.WorkContextScope - for _, kind := range kinds { - var actions []string - for _, action := range candidateActions(kind, audience, roles.GetRoles()) { - holds, err := s.personHolds(ctx, orgID, ownerID, audience, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}}) - if err != nil { - return nil, err - } - if holds { - actions = append(actions, action) - } else if _, ok := s.authority.(scopedResourceReader); ok { - _, ids, err := s.ownerResourceGrants(ctx, orgID, ownerID, kind, action) - if err != nil { - return nil, err - } - if len(ids) > 0 { - held = append(held, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids}) - } - } - } - if len(actions) > 0 { - held = append(held, &gen.WorkContextScope{ResourceKind: kind, Actions: actions}) - } - } - if len(held) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "the owner holds nothing at this audience: %s", audience) - } - return held, nil -} - -// candidateActions is every action this host names on kind at audience, sorted -// and unique. A wildcard names no action: it is what a role grants, never what -// a capability carries. -func candidateActions(kind, audience string, roles []*gen.Role) []string { - actions := business.VocabularyActions(kind) - for _, role := range roles { - for _, p := range role.GetPermissions() { - if p.GetResource() == kind && p.GetAction() != "*" { - actions = append(actions, p.GetAction()) - } - } - } - for _, grant := range service.ModulePrincipals() { - for _, binding := range grant.OperationAudiences { - if binding.Audience != audience { - continue - } - for _, scopes := range [][]business.ModuleOperationScope{binding.InvokeScopes, binding.HeadlessScopes, binding.LookupScopes} { - for _, scope := range scopes { - if scope.ResourceKind == kind { - actions = append(actions, scope.Actions...) - } - } - } - } - } - slices.Sort(actions) - return slices.Compact(actions) -} - -// personHolds reports whether the person holds scope at audience, by the check -// every mint makes: the scope's permissions resolved against their current -// authority, with the audience's own content admitted through the content-read -// branch. Refused is false; a check that could not answer is the error. -func (s *WorkContextAuthorityServer) personHolds(ctx context.Context, orgID, ownerID, audience string, scope *gen.WorkContextScope) (bool, error) { - permissions, _, err := workContextScopes([]*gen.WorkContextScope{scope}, mintContentReads(audience)) - if err != nil { - return false, status.Error(codes.Internal, err.Error()) - } - _, err = s.resolveAuthority(ctx, orgID, ownerID, "", permissions) - switch status.Code(err) { - case codes.OK: - return true, nil - case codes.PermissionDenied: - return false, nil - } - return false, err -} - -// ownerHoldsThrough is what the owner holds at audience now, within through -// (a binding's declared scopes): each of its actions kept only if the owner -// holds it there by personHolds, the check every mint makes. The caller has -// installed the owner's identity on ctx. -func (s *WorkContextAuthorityServer) ownerHoldsThrough(ctx context.Context, orgID, ownerID, audience string, through []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { - var held []*basev0.WorkScopeV1 - for _, scope := range through { - var actions []string - for _, action := range scope.GetActions() { - holds, err := s.personHolds(ctx, orgID, ownerID, audience, &gen.WorkContextScope{ - ResourceKind: scope.GetResourceKind(), Actions: []string{action}, ResourceIds: scope.GetResourceIds(), - }) - if err != nil { - return nil, err - } - if holds { - actions = append(actions, action) - } - } - if len(actions) > 0 { - held = append(held, &basev0.WorkScopeV1{ResourceKind: scope.GetResourceKind(), Actions: actions, ResourceIds: scope.GetResourceIds()}) - } - } - return held, nil -} - -// personHeldOfAsk is an ask met with what the person holds at the audience, by -// the check every mint makes, action by action: a kind-wide action they hold -// is kept; one they hold only on named resources is kept on those; an ask -// naming resources keeps the ones they hold and drops the rest. An asked -// action they hold on nothing it names is beyond them, and the ask is refused -// naming it, as the interface refuses an ask beyond what the owner holds: the -// intersection narrows resources, never actions, and never comes back empty. -func (s *WorkContextAuthorityServer) personHeldOfAsk(ctx context.Context, orgID, audience string, asked []*gen.WorkContextScope) ([]*gen.WorkContextScope, error) { - ownerID, err := s.authorizeOwner(ctx, orgID) - if err != nil { - return nil, err - } - _, scopedGrants := s.authority.(scopedResourceReader) - var out []*gen.WorkContextScope - keep := func(kind, action string, ids []string) { - for _, kept := range out { - if kept.GetResourceKind() == kind && slices.Equal(kept.GetResourceIds(), ids) { - if !slices.Contains(kept.Actions, action) { - kept.Actions = append(kept.Actions, action) - } - return - } - } - out = append(out, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids}) - } - for _, scope := range asked { - kind := scope.GetResourceKind() - for _, action := range scope.GetActions() { - if len(scope.GetResourceIds()) == 0 { - holds, err := s.personHolds(ctx, orgID, ownerID, audience, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}}) - if err != nil { - return nil, err - } - if holds { - keep(kind, action, nil) - continue - } - var ids []string - if scopedGrants { - if _, ids, err = s.ownerResourceGrants(ctx, orgID, ownerID, kind, action); err != nil { - return nil, err - } - } - if len(ids) == 0 { - return nil, beyondTheOwner(audience, kind, action) - } - keep(kind, action, ids) - continue - } - var ids []string - for _, id := range scope.GetResourceIds() { - holds, err := s.personHolds(ctx, orgID, ownerID, audience, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}, ResourceIds: []string{id}}) - if err != nil { - return nil, err - } - if holds && !slices.Contains(ids, id) { - ids = append(ids, id) - } - } - if len(ids) == 0 { - return nil, beyondTheOwner(audience, kind, action) - } - keep(kind, action, ids) - } - } - for _, scope := range out { - slices.Sort(scope.Actions) - slices.Sort(scope.ResourceIds) - } - return out, nil -} - -// beyondTheOwner names an asked action the person holds on nothing it names. -func beyondTheOwner(audience, kind, action string) error { - return status.Errorf(codes.PermissionDenied, "asked beyond what the owner holds at %s: %s:%s is held on none of what the ask names", audience, kind, action) -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_hop.go b/module/services/accounts/code/pkg/adapters/host_interface_hop.go deleted file mode 100644 index 1fa022718..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_hop.go +++ /dev/null @@ -1,437 +0,0 @@ -package adapters - -// A module's hop through a binding it declares, behind the host interface -// (HOST_INTERFACE.md gap 14): a module calling a module on its owner's behalf. -// -// The owner's decision of 2026-10-25 (child issuance A): the child is built -// from the owner's CURRENT grants at the binding's audience, narrowed to what -// the binding declares, with the parent's actor chain replayed and the acting -// module as the last hop. A module holds nothing of its own; it inherits who it -// acts for, which is what lets a chain of modules cross audiences. -// -// Where the binding is held: where the host already holds every module's -// delivered contract, the module principal registry, as installed in this -// organisation (Service.ModuleOperationAudience, the binding the delegated -// exchange redeems). The child is sealed to the installation that binding is -// granted within (the live answer for //), so -// core's rule that a sealed binding belongs to the capability's installation -// holds by construction rather than by the owner's seal. -// -// One rule on top of A: a parent that was narrowed (an action dropped, the -// resources named) stays narrowed. No hop below it gets back what a hop above -// it gave up, so the child carries every narrowing in its own authority scopes -// for the next hop to be held to. -// -// The owner is a person or an installation's agent. For an agent the "current -// grants" are its installation's standing grants, read live -// (installationHeldThrough), and the child stays the agent's. - -import ( - "cmp" - "context" - "errors" - "fmt" - "slices" - "strings" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/wool" - "github.com/codefly-dev/core/workcontext" - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - accountsauth "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// bindingHop issues the child of a verified parent addressed to the module -// prefix names, through that module's binding req names, to req's audience -// (requireDeclaredHopBinding has held both to the declaration). -func (s *HostAuthorityServer) bindingHop(ctx context.Context, prefix string, parent *workcontext.Verified, req *authorityv1.IssueRequest) (*gen.IssuedWorkContext, error) { - claims := parent.Context() - if kind := claims.GetOwnerPrincipalKind(); kind != business.PrincipalKindHuman && kind != business.PrincipalKindAgent { - return nil, status.Errorf(codes.PermissionDenied, "a hop through a binding inherits its owner's current grants, which this host reads for a person or an installation's agent; this context is owned by a %s", kind) - } - return s.hopFrom(ctx, prefix, claims, req, nil) -} - -// hopFrom issues the hop of the module prefix names through its binding, for -// the person who owns claims: a verified parent's, or, when issuing names the -// delegation the hop acts under, the grantor's standing that delegation was -// read from (delegationHop). Either way the owner's current grants through the -// binding decide it, cut to every stored delegation the chain acts under. -func (s *HostAuthorityServer) hopFrom(ctx context.Context, prefix string, claims *basev0.WorkContextV1, req *authorityv1.IssueRequest, issuing *business.ModuleDelegation) (*gen.IssuedWorkContext, error) { - tenant, owner := claims.GetTenantId(), claims.GetOwnerPrincipalId() - registered := business.ModulePrincipalID(prefix) - bindingID := req.GetOperationBindingId() - // The binding as installed in this organisation: an installation narrows - // its declaration, and a headless-only binding does nothing as a person. - installed, err := service.ModuleOperationAudience(ctx, business.ModuleCaller{PrincipalID: registered, BoundOrg: tenant}, tenant, prefix, bindingID) - if err != nil { - return nil, interfaceStatus(err) - } - through, err := hopOperationScopes(installed, bindingID, req.GetOperation(), req.GetAudience()) - if err != nil { - return nil, err - } - // Rule A for either owner: a person's current grants, or the standing - // grants of the installation whose agent owns the parent. A delegation's - // hop is a person's, from the grantor's standing. - var held []*basev0.WorkScopeV1 - if claims.GetOwnerPrincipalKind() == business.PrincipalKindAgent { - held, err = s.workContexts.installationHeldThrough(ctx, claims, req.GetAudience(), through) - } else { - held, err = s.personHeldThrough(ctx, claims, prefix, req.GetAudience(), through, issuing) - } - if err != nil { - return nil, err - } - if len(held) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "%s holds nothing at %s through operation binding %q", owner, req.GetAudience(), bindingID) - } - // A delegation is a ceiling for as long as the chain acts under it: every - // stored one a hop names, and the one this hop is issued from. - delegations, err := s.chainDelegations(ctx, claims, issuing) - if err != nil { - return nil, err - } - for _, d := range delegations { - if held = intersectScopes(held, delegatedScopes(d)); len(held) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "delegation %s grants nothing %s still holds at %s through operation binding %q", d.ID, owner, req.GetAudience(), bindingID) - } - } - narrowed := parentNarrowings(claims) - ceiling := narrowed.keep(held) - if len(ceiling) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "the parent was narrowed away from everything %s holds at %s through operation binding %q", owner, req.GetAudience(), bindingID) - } - scopes := ceiling - if len(req.GetScopes()) > 0 { - _, asked, err := workContextScopes(scopesToHost(req.GetScopes()), mintContentReads(req.GetAudience())) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - for _, scope := range asked { - if !workcontext.ScopeContained(scope, held) { - if len(delegations) > 0 { - return nil, status.Errorf(codes.PermissionDenied, "a hop widens what its owner holds and delegated through operation binding %q: %s", bindingID, scopeString(scope)) - } - return nil, status.Errorf(codes.PermissionDenied, "a child widens what its owner holds through operation binding %q: %s", bindingID, scopeString(scope)) - } - if !workcontext.ScopeContained(scope, ceiling) { - return nil, status.Errorf(codes.PermissionDenied, "a child widens what its parent narrowed: %s", scopeString(scope)) - } - } - scopes = asked - } - ttl, err := issueTTL(req) - if err != nil { - return nil, err - } - // What the child carries beside its grant, so the next hop is held to it: - // every narrowing of a kind it does not name, and a denial of each kind the - // owner holds through the binding that the child was not granted, so that - // kind stays dropped down the chain (rule A: a narrowing is never recovered). - carried := narrowed.deny(held, scopes).outside(scopes) - // A hop issued from a delegation carries its id; every hop of a chain that - // acts under one (a chain holds at most one: a delegation's issue starts - // it) is sealed to it, so its revocation ends exactly these capabilities. - hop := delegatedHop{} - if issuing != nil { - hop.ID = issuing.ID - } - for _, d := range delegations { - hop.Under = d.ID - } - return s.workContexts.mintBindingHop(ctx, claims, prefix, bindingID, hopOperation(req.GetOperation()), req.GetAudience(), scopes, carried, workContextTTL(ttl), hop) -} - -// delegatedHop is how a hop relates to a stored delegation: ID, the id the -// hop carries (the delegation's own when issued from it), and Under, the -// delegation its binding is sealed to. Both empty for a chain under none. -type delegatedHop struct { - ID, Under string -} - -// personHeldThrough is what a person who owns parent holds at audience within -// through: a solution's roles decide it where they apply, the organisation's -// roles otherwise, once the parent is shown to still stand. -func (s *HostAuthorityServer) personHeldThrough(ctx context.Context, parent *basev0.WorkContextV1, prefix, audience string, through []*basev0.WorkScopeV1, issuing *business.ModuleDelegation) ([]*basev0.WorkScopeV1, error) { - tenant, owner := parent.GetTenantId(), parent.GetOwnerPrincipalId() - // The owner's authority is re-read as the owner: the parent token is what - // proves the owner here, or the delegation its grantor recorded. - ctx = accountsauth.WithVerifiedDatabaseIdentity(ctx, owner, tenant) - if issuing != nil { - // No parent stands behind a delegation's hop: its grantor must still - // be a member, and the delegation is re-read by the caller. - if err := requireOrgMember(ctx, owner, tenant); err != nil { - return nil, status.Errorf(codes.PermissionDenied, "delegation %s: its grantor %s is no longer a member of this organization", issuing.ID, owner) - } - } else { - decided, err := s.solutionParentStands(ctx, parent) - if err != nil { - return nil, err - } - if !decided { - if err := s.workContexts.requireParentStands(ctx, parent); err != nil { - return nil, err - } - } - } - held, decided, err := s.solutionHoldsThrough(ctx, parent, prefix, audience, through) - if err != nil || decided { - return held, err - } - return s.workContexts.ownerHoldsThrough(ctx, tenant, owner, audience, through) -} - -// requireDeclaredHopBinding refuses, before the parent is verified (and so -// before a single-use parent is spent), a binding the acting module does not -// declare and an audience that is not the binding's. -func requireDeclaredHopBinding(prefix string, req *authorityv1.IssueRequest) error { - bindingID := req.GetOperationBindingId() - declared, ok := service.ModulePrincipals()[business.ModulePrincipalID(prefix)].OperationAudiences[bindingID] - if !ok { - return status.Errorf(codes.NotFound, "operation binding %q is not declared by %s", bindingID, prefix) - } - if declared.Audience != req.GetAudience() { - return status.Errorf(codes.PermissionDenied, "operation binding %q is addressed to %s, not %s", bindingID, declared.Audience, req.GetAudience()) - } - return nil -} - -// hopOperationScopes is what the installed binding may do for one operation -// of a hop: invoke (the default) or lookup. Headless is a workload's own. -func hopOperationScopes(installed business.ModuleOperationAudience, bindingID, operation, audience string) ([]*basev0.WorkScopeV1, error) { - lookup := false - switch operation { - case "", "invoke": - case "lookup": - if len(installed.LookupScopes) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "operation binding %q declares no lookup", bindingID) - } - lookup = true - case "headless": - return nil, status.Errorf(codes.InvalidArgument, "a hop acts for its owner; headless is a workload's own operation on binding %q", bindingID) - default: - return nil, status.Errorf(codes.InvalidArgument, "operation %q is none of invoke, headless and lookup", operation) - } - _, scopes, err := workContextScopes(installed.WireScopes(lookup), mintContentReads(audience)) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - return scopes, nil -} - -// storedPrincipal is the principal as storage holds it: a module a capability -// names by its name is stored under its registry id (the identity columns and -// grant subjects are ids), and every other principal is itself. -func storedPrincipal(principal string) string { - if service != nil { - if id, isModule := service.ModuleRegistryID(principal); isModule { - return id - } - } - return principal -} - -// requireModuleChainStands re-reads every module hop of a capability against -// the binding it hopped through (business.ConfirmModuleChain): core re-reads -// only the binding the capability seals, the last hop's, so a binding narrowed -// or withdrawn upstream would otherwise go unnoticed until the child expired. -// -// Each hop is held to the exact scope set it was issued under (invoke, lookup -// or headless), and the original granted kinds recorded beside its binding. -// Missing journal provenance refuses the chain. -func (s *WorkContextAuthorityServer) requireModuleChainStands(ctx context.Context, wc *basev0.WorkContextV1) error { - chain := wc.GetActorChain() - // A chain of hops through bindings seals its last hop's module binding; a - // context sealed to a source delegation or an installed read is confirmed - // by its own checker. - if len(chain) == 0 || !business.NamesModuleOperationBinding(wc.GetOperationBinding().GetBindingId()) { - return nil - } - if service == nil { - return status.Error(codes.Unavailable, "no module authority is wired, so a module chain cannot be re-read") - } - recorded := map[string]business.ActorChainHopBinding{} - if reader, ok := s.journal.(business.ActorChainHopBindingReader); ok && reader != nil { - var err error - if recorded, err = reader.ActorChainHopBindings(ctx, wc.GetTenantId(), journalKeys(wc.GetTaskId(), chain)); err != nil { - wool.Get(ctx).In("requireModuleChainStands").Warn("the bindings this chain went through could not be read", - wool.Field("org", wc.GetTenantId()), wool.ErrField(err)) - return status.Error(codes.Unavailable, "cannot read the bindings this chain went through") - } - } - hops := make([]business.ModuleChainHop, 0, len(chain)) - for _, hop := range chain { - // The per-issue row of a delegation's hop is the one that issue wrote; - // the hop's own id is every other hop's. - through, issued := recorded[delegatedHopRowID(hop.GetDelegationId(), wc.GetTaskId())] - if !issued { - through = recorded[hop.GetDelegationId()] - } - hops = append(hops, business.ModuleChainHop{ - Principal: hop.GetPrincipalId(), Scopes: operationScopesFromCore(hop.GetGrantedScopes()), - Through: through, - }) - } - if err := service.ConfirmModuleChain(ctx, wc.GetTenantId(), wc.GetAudience(), hops); err != nil { - if errors.Is(err, business.ErrModuleHopStale) { - return status.Error(codes.PermissionDenied, "a binding a hop of this chain went through no longer grants what it carries") - } - return interfaceStatus(err) - } - return nil -} - -// requireParentStands refuses a parent whose authority moved since it was -// issued: the owner's revision for an owner-only parent, as every derivation -// checks, and the journal's revocation of any hop for a chained one (a module -// actor holds no row-backed authority to re-resolve; its binding is re-read -// live by core on the hop that names it). -func (s *WorkContextAuthorityServer) requireParentStands(ctx context.Context, parent *basev0.WorkContextV1) error { - if len(parent.GetActorChain()) == 0 { - _, err := s.requireCurrentAuthority(ctx, parent.GetTenantId(), parent) - return err - } - return s.requireChainNotRevoked(ctx, parent.GetTenantId(), parent.GetTaskId(), parent.GetActorChain()) -} - -// mintBindingHop mints the child: the owner's session sealed to the -// installation the binding is granted within, carrying scopes and the -// narrowings of every other kind; the parent's actors replayed; then the -// acting module's hop exercising the binding, granted scopes, carrying and -// sealed under the stored delegation it acts under, if any (a fresh id -// otherwise). It never outlives its parent and descends from the parent's -// session. -// -// The acting module is named by its NAME, as the interface states an actor -// and as the binding it exercises is granted (// -// is answered as granted to ): core refuses a hop whose actor is not -// the binding's, so the name is what makes the hop verify and what keeps one -// module from exercising another's binding. -func (s *WorkContextAuthorityServer) mintBindingHop( - ctx context.Context, parent *basev0.WorkContextV1, prefix, bindingID, operation, audience string, - scopes, carried []*basev0.WorkScopeV1, ttl time.Duration, delegated delegatedHop, -) (*gen.IssuedWorkContext, error) { - // The structural audience rule every mint runs: none empty, and never the - // module capability surface's own. - if err := requireMintableAudience(audience); err != nil { - return nil, err - } - tenant := parent.GetTenantId() - actor := prefix - sealed := business.ModuleOperationBindingID(tenant, prefix, bindingID) - if delegated.Under != "" { - sealed = business.DelegatedOperationBindingID(sealed, delegated.Under) - } - binding, err := s.seals.WorkContextOperationBinding(ctx, tenant, sealed) - switch { - case errors.Is(err, business.ErrNoOperationBinding): - return nil, status.Errorf(codes.PermissionDenied, "operation binding %q is not installed for %s in this organisation", bindingID, actor) - case err != nil: - return nil, mapWorkContextError(ctx, err) - case binding.PrincipalID != actor: - // The binding the hop seals is granted to the module acting, by name, - // or the hop is not signed at all: core would refuse it, and a host - // that signed it would be one disagreement away from accepting it. - return nil, status.Errorf(codes.PermissionDenied, "operation binding %q is granted to %s, not %s", bindingID, binding.PrincipalID, actor) - } - now := time.Now() - if s.now != nil { - now = s.now() - } - remaining := time.Unix(parent.GetExpiresAtUnix(), 0).Sub(now) - if ttl == 0 { - ttl = workContextDefaultTTL - } - ttl = min(ttl, remaining) - if ttl < time.Second { - return nil, status.Error(codes.PermissionDenied, "the parent expires before a child can be issued") - } - authority := append(cloneWorkScopes(scopes), carried...) - hops := make([]delegationHop, 0, len(parent.GetActorChain())+1) - for _, hop := range parent.GetActorChain() { - hops = append(hops, delegationHop{ - PrincipalID: hop.GetPrincipalId(), PrincipalKind: hop.GetPrincipalKind(), AgentID: hop.GetAgentId(), - DelegationID: hop.GetDelegationId(), GrantedScopes: cloneWorkScopes(authority), - }) - } - hops = append(hops, delegationHop{ - PrincipalID: actor, PrincipalKind: business.PrincipalKindService, DelegationID: cmp.Or(delegated.ID, uuid.NewString()), - GrantedScopes: cloneWorkScopes(scopes), OperationBindingID: sealed, - }) - token, signed, err := s.mint(ctx, mintRequest{ - Start: workcontext.StartInput{ - TenantID: tenant, OwnerPrincipalID: parent.GetOwnerPrincipalId(), OwnerPrincipalKind: parent.GetOwnerPrincipalKind(), - OwnerAgentID: parent.GetOwnerAgentId(), OrganizationID: parent.GetOrganizationId(), TaskID: parent.GetTaskId(), - Audience: audience, AuthorityScopes: authority, AttributionTeamIDs: append([]string(nil), parent.GetAttributionTeamIds()...), - WorkspaceID: parent.GetWorkspaceId(), ProjectID: parent.GetProjectId(), InstallationID: binding.InstallationID, - }, - Hops: hops, DerivedFrom: parent.GetSessionId(), Replay: workcontext.ReplayIdempotent, TTL: ttl, - }) - if err != nil { - return nil, mapWorkContextError(ctx, err) - } - if err := s.journalHopThrough(ctx, signed, business.ActorChainHopBinding{BindingID: sealed, Operation: operation}, delegated.Under); err != nil { - return nil, err - } - return issuedWorkContext(token, signed), nil -} - -// hopOperation is the scope set a hop is issued under: invoke unless it asked -// for lookup (hopOperationScopes has refused every other operation). -func hopOperation(asked string) string { - if asked == "" { - return "invoke" - } - return asked -} - -// intersectScope is what both scopes of one kind allow: the actions in both, -// and the resources in both, where naming none means any. Nothing in common is -// nil, never a scope over all of the kind. -func intersectScope(a, b *basev0.WorkScopeV1) *basev0.WorkScopeV1 { - if a == nil || b == nil { - return nil - } - var actions []string - for _, action := range a.GetActions() { - if slices.Contains(b.GetActions(), action) { - actions = append(actions, action) - } - } - if len(actions) == 0 { - return nil - } - ids := a.GetResourceIds() - switch { - case len(ids) == 0: - ids = b.GetResourceIds() - case len(b.GetResourceIds()) > 0: - var both []string - for _, id := range ids { - if slices.Contains(b.GetResourceIds(), id) { - both = append(both, id) - } - } - if len(both) == 0 { - return nil - } - ids = both - } - return &basev0.WorkScopeV1{ResourceKind: a.GetResourceKind(), Actions: actions, ResourceIds: slices.Clone(ids)} -} - -func scopeString(scope *basev0.WorkScopeV1) string { - out := fmt.Sprintf("%s:%s", scope.GetResourceKind(), strings.Join(scope.GetActions(), ",")) - if ids := scope.GetResourceIds(); len(ids) > 0 { - out += "[" + strings.Join(ids, ",") + "]" - } - return out -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_hop_test.go b/module/services/accounts/code/pkg/adapters/host_interface_hop_test.go deleted file mode 100644 index 6086974e4..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_hop_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package adapters - -import ( - "context" - "errors" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" -) - -// hopPrincipals composes two modules that call each other on a person's -// behalf: tools reads documents' records through "records", and documents -// reads tools's items through "items". -const hopPrincipals = `{` + - `"tools":{"workload":{"service_account":"tool","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `",` + - `"resources":["tool.items"],"operation_audiences":{"records":{"audience":"documents",` + - `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke","read"]}],` + - `"lookup_scopes":[{"resource_kind":"example-records","actions":["read"]}]}}},` + - `"documents":{"workload":{"service_account":"documents","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `",` + - `"resources":["example-records"],"operation_audiences":{"items":{"audience":"tools",` + - `"invoke_scopes":[{"resource_kind":"tool.items","actions":["read"]}]}}}}` - -const hopOwner = "33333333-3333-4333-8333-333333333333" - -// holdingAuthority holds the owner to exactly the kind:action pairs it names. -type holdingAuthority struct { - workContextAuthorityFake - held map[string]bool - // standing is what an installation's standing grants admit. - standing map[string]bool -} - -func (f *holdingAuthority) ResolveWorkContextAuthority(ctx context.Context, org, owner, actor string, permissions []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { - for _, p := range permissions { - if !f.held[p.ResourceKind+":"+p.Action] { - return nil, business.NewStoreError(errors.New(p.ResourceKind+":"+p.Action+" is not held"), business.ErrTypePermission) - } - } - return f.workContextAuthorityFake.ResolveWorkContextAuthority(ctx, org, owner, actor, permissions) -} - -// ResolveInstallationAuthority holds an installation's agent to exactly the -// kind:action pairs standing names, as its standing grants would. -func (f *holdingAuthority) ResolveInstallationAuthority(ctx context.Context, org, installation string, permissions []business.WorkContextPermission) (*business.InstallationAuthorityFacts, error) { - for _, p := range permissions { - if !f.standing[p.ResourceKind+":"+p.Action] { - return nil, business.NewStoreError(errors.New("agent standing grant does not authorize "+p.ResourceKind+":"+p.Action), business.ErrTypePermission) - } - } - return f.workContextAuthorityFake.ResolveInstallationAuthority(ctx, org, installation, permissions) -} - -type hopFixture struct { - server *HostAuthorityServer - issuer *WorkContextAuthorityServer - tool string // tools's workload credential - docs string // documents' workload credential - holding *holdingAuthority -} - -// newHopFixture composes hopPrincipals with an owner holding held. -func newHopFixture(t *testing.T, held ...string) hopFixture { - t.Helper() - installModuleWorkContextService(t, hopPrincipals) - holding := &holdingAuthority{workContextAuthorityFake: workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, held: map[string]bool{}} - for _, h := range held { - holding.held[h] = true - } - issuer := newCoreTestServer(t, holding, moduleSealAnswers(12)) - issuer.journal = &bindingJournal{hops: map[string]business.ActorChainHopInput{}} - return hopFixture{server: NewHostAuthorityServer(issuer, nil), issuer: issuer, tool: moduleToken(t, "tools"), docs: moduleToken(t, "documents"), holding: holding} -} - -// parent is the owner's capability addressed to audience, carrying scopes. -func (f hopFixture) parent(t *testing.T, audience string, scopes ...*basev0.WorkScopeV1) (string, *basev0.WorkContextV1) { - t.Helper() - return mintTestCapability(t, f.issuer, testCapability{Audience: audience, Tenant: moduleWorkContextTenant, Owner: hopOwner, Scopes: scopes}) -} - -func (f hopFixture) hop(parent, credential, binding, audience string, asked ...*authorityv1.WorkScopeV1) (*authorityv1.IssueResponse, error) { - return f.server.Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, Actor: &authorityv1.WorkloadCredential{Credential: credential}, - OperationBindingId: binding, Audience: audience, Scopes: asked, - }) -} - -func hopScope(kind string, actions []string, ids ...string) *basev0.WorkScopeV1 { - return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions, ResourceIds: ids} -} - -// claims is the issued capability as core reads it. -func claims(t *testing.T, out *authorityv1.IssueResponse) *basev0.WorkContextV1 { - t.Helper() - decoded, err := workcontext.Decode(out.GetToken()) - require.NoError(t, err) - return decoded.Context() -} - -func ask(kind string, actions []string, ids ...string) *authorityv1.WorkScopeV1 { - return &authorityv1.WorkScopeV1{ResourceKind: kind, Actions: actions, ResourceIds: ids} -} - -func requireRefused(t *testing.T, err error, code codes.Code, reason string) { - t.Helper() - require.Equal(t, code, status.Code(err), "%v", err) - require.Contains(t, status.Convert(err).Message(), reason) -} - -// A: the child is the owner's current grants at the binding's audience within -// the binding — here read, since the owner does not hold invoke — although the -// parent never carried the audience's kind. It is owned by the owner, actored -// by the module, sealed to the installation the binding is granted within, -// names that binding, descends from the parent's session, and verifies there. -func TestAHopThroughABindingCarriesTheOwnersCurrentGrantsWithinIt(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, parentClaims := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, hopOwner, wc.GetOwnerPrincipalId()) - require.Equal(t, "documents", wc.GetAudience()) - require.Len(t, wc.GetActorChain(), 1) - require.Equal(t, "tools", wc.GetActorChain()[0].GetPrincipalId()) - require.Equal(t, moduleWorkContextTenant+"/tools/records", wc.GetOperationBinding().GetBindingId()) - require.Equal(t, parentClaims.GetSessionId(), wc.GetParentSessionId()) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}, wc.GetActorChain()[0].GetGrantedScopes()) - _, err = f.issuer.verifyFor(context.Background(), out.GetToken(), "documents") - require.NoError(t, err) - - // The revision check confirms the hop by the binding it seals. - require.NoError(t, service.ConfirmModuleHopBinding(context.Background(), moduleWorkContextTenant, - wc.GetActorChain()[0].GetPrincipalId(), wc.GetOperationBinding().GetBindingId(), operationScopesFromCore(wc.GetActorChain()[0].GetGrantedScopes()))) -} - -func TestAHopThroughABindingItsModuleDoesNotDeclareIsNotFound(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := mintTestCapability(t, f.issuer, testCapability{Audience: "tools", Tenant: moduleWorkContextTenant, Owner: hopOwner, - Replay: workcontext.ReplaySingleUse, Scopes: []*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"})}}) - _, err := f.hop(parent, f.tool, "undeclared", "documents") - requireRefused(t, err, codes.NotFound, `"undeclared" is not declared by tools`) - // documents declares "items", tools does not: a binding is its own module's. - _, err = f.hop(parent, f.tool, "items", "tools") - requireRefused(t, err, codes.NotFound, `"items" is not declared by tools`) - // Refused before the parent is verified, so a single-use parent is unspent. - _, err = f.issuer.verifyFor(context.Background(), parent, "tools") - require.NoError(t, err) -} - -func TestAHopAddressedElsewhereThanItsBindingIsRefused(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - _, err := f.hop(parent, f.tool, "records", "tools") - requireRefused(t, err, codes.PermissionDenied, `"records" is addressed to documents, not tools`) -} - -func TestAHopForAnOwnerHoldingNoneOfTheBindingIsRefused(t *testing.T) { - f := newHopFixture(t, "tool.items:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - _, err := f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "holds nothing at documents through operation binding \"records\"") -} - -// The binding bounds the ask, and so does what the owner holds now. -func TestAHopAskingBeyondWhatItsOwnerHoldsIsRefused(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - for _, actions := range [][]string{{"invoke"}, {"write"}} { - _, err := f.hop(parent, f.tool, "records", "documents", ask("example-records", actions)) - requireRefused(t, err, codes.PermissionDenied, "widens what its owner holds") - } -} - -// A parent narrowed to one resource and one action stays narrowed: the owner -// holds more now, and the binding grants more, but the child carries only -// what the parent kept, and asking for what it gave up is refused by name. -func TestANarrowedParentStaysNarrowed(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:invoke", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"}), hopScope("example-records", []string{"read"}, "c1")) - - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"}, "c1")}, claims(t, out).GetActorChain()[0].GetGrantedScopes()) - - for _, asked := range []*authorityv1.WorkScopeV1{ask("example-records", []string{"invoke"}), ask("example-records", []string{"read"}, "c2"), ask("example-records", []string{"read"})} { - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"}), hopScope("example-records", []string{"read"}, "c1")) - _, err := f.hop(parent, f.tool, "records", "documents", asked) - requireRefused(t, err, codes.PermissionDenied, "widens what its parent narrowed") - } -} - -// Down a chain too: the first hop's child never carried tool.items in what it -// may do at documents, yet documents' hop back to tools is held to the one -// item the owner's first token named, which the child carried for it. -func TestANarrowingHoldsDownTheChain(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"}, "t1")) - first, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - - second, err := f.hop(first.GetToken(), f.docs, "items", "tools") - require.NoError(t, err) - wc := claims(t, second) - require.Len(t, wc.GetActorChain(), 2) - require.Equal(t, hopOwner, wc.GetOwnerPrincipalId()) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"}, "t1")}, wc.GetActorChain()[1].GetGrantedScopes()) - _, err = f.issuer.verifyFor(context.Background(), second.GetToken(), "tools") - require.NoError(t, err) - // The module the chain passed through stands while it is installed; the - // last hop is confirmed by its own binding. - sealed := wc.GetOperationBinding().GetBindingId() - require.NoError(t, service.ConfirmModuleHopBinding(context.Background(), moduleWorkContextTenant, wc.GetActorChain()[0].GetPrincipalId(), sealed, operationScopesFromCore(wc.GetActorChain()[0].GetGrantedScopes()))) - require.NoError(t, service.ConfirmModuleHopBinding(context.Background(), moduleWorkContextTenant, wc.GetActorChain()[1].GetPrincipalId(), sealed, operationScopesFromCore(wc.GetActorChain()[1].GetGrantedScopes()))) - - _, err = f.hop(first.GetToken(), f.docs, "items", "tools", ask("tool.items", []string{"read"}, "t2")) - requireRefused(t, err, codes.PermissionDenied, "widens what its parent narrowed") -} - -// A module hops only on a token addressed to it, refused by name before the -// parent is verified, so another module's single-use parent is not spent. -func TestAHopOnATokenAddressedElsewhereIsRefusedUnspent(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := mintTestCapability(t, f.issuer, testCapability{Audience: "documents", Tenant: moduleWorkContextTenant, Owner: hopOwner, - Replay: workcontext.ReplaySingleUse, Scopes: []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}}) - _, err := f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "tools hops only on a token addressed to it") - _, err = f.issuer.verifyFor(context.Background(), parent, "documents") - require.NoError(t, err) -} - -const ( - hopAgent = "44444444-4444-4444-8444-444444444444" - hopAgentIdentifier = "acme.example/assistant:1.0.0" - hopAgentInstallation = "55555555-5555-4555-8555-555555555555" -) - -// installationOwned makes f's installation, whose agent is hopAgent, admit -// standing, and mints that agent's own capability at tools carrying scopes. -func (f hopFixture) installationOwned(t *testing.T, standing []string, scopes ...*basev0.WorkScopeV1) (string, *basev0.WorkContextV1) { - t.Helper() - f.holding.standing = map[string]bool{} - for _, s := range standing { - f.holding.standing[s] = true - } - f.holding.installationFcts = &business.InstallationAuthorityFacts{OwnerPrincipalID: hopOwner, OrganizationRevision: 12, - Actor: &business.Principal{ID: hopAgent, Kind: business.PrincipalKindAgent, AgentIdentifier: hopAgentIdentifier}} - f.holding.installations = map[string]business.InstallationIdentity{hopAgentInstallation: {OrgID: moduleWorkContextTenant, Status: "active"}} - f.holding.agents = map[string]string{hopAgent: hopAgentInstallation} - return mintTestCapability(t, f.issuer, testCapability{Audience: "tools", Tenant: moduleWorkContextTenant, Owner: hopAgent, - OwnerKind: business.PrincipalKindAgent, OwnerAgent: hopAgentIdentifier, Scopes: scopes}) -} - -// Rule A for an installation: a hop below a capability its agent owns carries -// what the installation's standing grants admit within the binding (read here, -// since they do not admit invoke), whatever any person holds. The child stays -// owned by the agent, is actored by the module and verifies; a second hop -// keeps the owner; an ask beyond the standing grants is refused, and so is -// every hop once the installation no longer stands. -func TestAHopFromAnInstallationOwnedParentInheritsTheInstallationsStandingGrants(t *testing.T) { - // The person-owned path would grant invoke: the agent's must not borrow it. - f := newHopFixture(t, "example-records:invoke", "example-records:read", "tool.items:read") - parent, parentClaims := f.installationOwned(t, []string{"tool.items:read", "example-records:read"}, hopScope("tool.items", []string{"read"})) - - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, hopAgent, wc.GetOwnerPrincipalId()) - require.Equal(t, business.PrincipalKindAgent, wc.GetOwnerPrincipalKind()) - require.Equal(t, hopAgentIdentifier, wc.GetOwnerAgentId()) - require.Equal(t, parentClaims.GetTaskId(), wc.GetTaskId()) - require.Equal(t, parentClaims.GetSessionId(), wc.GetParentSessionId()) - require.Len(t, wc.GetActorChain(), 1) - require.Equal(t, "tools", wc.GetActorChain()[0].GetPrincipalId()) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}, wc.GetActorChain()[0].GetGrantedScopes()) - _, err = f.server.Check(context.Background(), &authorityv1.CheckRequest{Token: out.GetToken(), Audience: "documents"}) - require.NoError(t, err) - - // Down the chain: documents hops back to tools; the owner is still the agent. - f.holding.standing["tool.items:read"] = true - back, err := f.hop(out.GetToken(), f.docs, "items", "tools") - require.NoError(t, err) - require.Equal(t, hopAgent, claims(t, back).GetOwnerPrincipalId()) - require.Len(t, claims(t, back).GetActorChain(), 2) - - _, err = f.hop(parent, f.tool, "records", "documents", ask("example-records", []string{"invoke"})) - requireRefused(t, err, codes.PermissionDenied, "widens") - - delete(f.holding.standing, "example-records:read") - _, err = f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "holds nothing") - - f.holding.agents = nil - _, err = f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "acts for no active installation") -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_installation.go b/module/services/accounts/code/pkg/adapters/host_interface_installation.go deleted file mode 100644 index 92fce271f..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_installation.go +++ /dev/null @@ -1,141 +0,0 @@ -package adapters - -import ( - "context" - "errors" - "slices" - - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// forInstallation is an installation acting for itself (the interface's -// InstallationRef). An installation id is not a credential: the actor must be a -// workload of the installed solution, and the installation it names must be -// that solution's own active installation in the organisation the work runs -// in. The caller is proven first, so one proving nothing learns nothing of -// what it asked, not even whether the ask is well formed; only then is the -// installation held to the solution's own, and the audience to the -// composition. Every refusal comes before the mint and names its reason; the -// authority is then the installation's own, through the host's headless mint, -// and the capability is owned by the installation's agent: no person is acting -// (host_interface_installation_owned.go). -func (s *HostAuthorityServer) forInstallation(ctx context.Context, installationID string, req *authorityv1.IssueRequest, scopes []*gen.WorkContextScope, ttl int32) (*gen.IssuedWorkContext, error) { - credential := req.GetActor().GetCredential() - if credential == "" { - return nil, status.Error(codes.Unauthenticated, "an installation acts for itself through its solution's workload, and the issue carries no actor credential") - } - prefix, err := service.ModulePrefixOfWorkload(ctx, credential) - if err != nil { - return nil, credentialRefusal(err, "actor") - } - if installationID == "" { - return nil, status.Error(codes.InvalidArgument, "an installation's issue names the installation acting") - } - // The installation is read for whose it is, its organization included: - // the request names the installation, never the organization it runs in. - installation, err := s.workContexts.authority.WorkContextInstallationIdentity(ctx, installationID) - var missing *business.StoreError - switch { - case errors.As(err, &missing) && missing.StoreErrorType == business.ErrTypeNotFound: - return nil, status.Error(codes.NotFound, err.Error()) - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - return nil, status.FromContextError(err).Err() - case err != nil: - return nil, status.Errorf(codes.Unavailable, "cannot read installation %s", installationID) - } - if installation.PrincipalID != business.ModulePrincipalID(prefix) { - return nil, status.Errorf(codes.PermissionDenied, "installation %s acts only through its installed solution's workload, and %s is not the installed solution", installationID, prefix) - } - org := installation.OrgID - if named := req.GetOrganizationId(); named != "" && named != org { - return nil, status.Errorf(codes.PermissionDenied, "installation %s belongs to organization %s, not %s", installationID, org, named) - } - // A solution the composition pins to one tenant acts there alone. - if _, err := installationOrganization(prefix, org); err != nil { - return nil, err - } - if installation.Status != "active" { - return nil, status.Errorf(codes.PermissionDenied, "installation %s is %s; only an active installation acts for itself", installationID, installation.Status) - } - if req.GetAudience() == "" { - return nil, status.Error(codes.InvalidArgument, "an issue names its audience") - } - if err := requireComposed(req.GetAudience()); err != nil { - return nil, err - } - mint := &gen.StartInstallationTaskRequest{ - OrgId: org, InstallationId: installationID, TaskId: uuid.NewString(), - Audience: req.GetAudience(), AuthorityScopes: scopes, TtlSeconds: ttl, - } - if len(scopes) == 0 { - // An empty ask is everything the installation holds at the audience. - if mint.AuthorityScopes, err = s.workContexts.installationHoldings(ctx, org, installationID, req.GetAudience()); err != nil { - return nil, err - } - } - if err := Validate(mint); err != nil { - return nil, err - } - return s.workContexts.startInstallationTask(ctx, mint, ownedByAgent) -} - -// installationOrganization is the organisation an installation's work runs in: -// the one the request names, held to the solution's own tenant when the -// composition pins one (a pinned principal is never issued outside its pin, -// whatever cross_tenant says: ModulePrincipalGrant.IssuedIn), or that tenant when the -// request names none. -func installationOrganization(prefix, named string) (string, error) { - grant := service.ModulePrincipals()[business.ModulePrincipalID(prefix)] - switch { - case named == "" && grant.Tenant == "": - return "", status.Errorf(codes.InvalidArgument, "%s is not pinned to one organization, so its installation's issue names the organization", prefix) - case named == "": - return grant.Tenant, nil - case !grant.IssuedIn(named): - return "", status.Errorf(codes.PermissionDenied, "%s acts in organization %s, not %s", prefix, grant.Tenant, named) - } - return named, nil -} - -// installationHoldings is what an installation's empty ask means: every -// (kind, action) the composition names at the audience that the installation's -// own authority admits, by the check its mint makes. Holding nothing is refused. -func (s *WorkContextAuthorityServer) installationHoldings(ctx context.Context, orgID, installationID, audience string) ([]*gen.WorkContextScope, error) { - kinds, err := service.ModuleContentResources(audience) - if err != nil { - return nil, status.Errorf(codes.PermissionDenied, "installation %s holds nothing at %s: %v", installationID, audience, err) - } - // A solution's roles name actions on its own kinds that no organisation - // role does; the installation's standing role is one of them, so each is a - // candidate, decided below like every other. - authority, err := service.DeliveredSolutionAuthority(ctx, audience) - if err != nil { - return nil, status.Errorf(codes.Unavailable, "cannot read %s's delivered roles: %v", audience, err) - } - var held []*gen.WorkContextScope - for _, kind := range kinds { - var actions []string - for _, action := range slices.Compact(slices.Sorted(slices.Values(append(candidateActions(kind, audience, nil), authority.RoleActions(kind)...)))) { - admitted, err := s.installationAdmits(ctx, orgID, installationID, audience, &gen.WorkContextScope{ResourceKind: kind, Actions: []string{action}}) - if err != nil { - return nil, err - } - if admitted { - actions = append(actions, action) - } - } - if len(actions) > 0 { - held = append(held, &gen.WorkContextScope{ResourceKind: kind, Actions: actions}) - } - } - if len(held) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "installation %s holds nothing at %s", installationID, audience) - } - return held, nil -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_installation_owned.go b/module/services/accounts/code/pkg/adapters/host_interface_installation_owned.go deleted file mode 100644 index 1bed7b9fd..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_installation_owned.go +++ /dev/null @@ -1,111 +0,0 @@ -package adapters - -// A capability an installation's agent owns (the coordinator's decision of -// 2026-10-25): an installation acting for itself is the owner of what it is -// issued, not its human owner of record with the agent acting, because no -// person is acting. Its authority is the installation's standing grants, read -// live: at the issue (forInstallation), at every Check, and at every hop below -// it, where rule A reads the installation's grants as it reads a person's. - -import ( - "context" - "errors" - "slices" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/business" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// requireInstallationStands re-verifies a capability an installation's agent -// owns against live state, beyond the seal and revision core has checked: the -// installation that agent acts for is active, its agent is live, an owner of -// record is still an organisation admin, and its standing grants still admit -// what the capability carries (the last hop's grant on a chained one, whose -// hops must not be revoked). At its own audience the agent's ceiling holds -// too. It answers the installation and what was resolved for it. -func (s *WorkContextAuthorityServer) requireInstallationStands(ctx context.Context, wc *basev0.WorkContextV1) (string, *business.InstallationAuthorityFacts, error) { - tenant, agent := wc.GetTenantId(), wc.GetOwnerPrincipalId() - if wc.GetOwnerPrincipalKind() != business.PrincipalKindAgent { - return "", nil, status.Errorf(codes.PermissionDenied, "this context is owned by a %s, not an installation's agent", wc.GetOwnerPrincipalKind()) - } - installation, err := s.authority.WorkContextAgentInstallation(ctx, tenant, agent) - var missing *business.StoreError - switch { - case errors.As(err, &missing) && missing.StoreErrorType == business.ErrTypeNotFound: - return "", nil, status.Errorf(codes.PermissionDenied, "the installation agent %s acts for no active installation: %v", agent, err) - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - return "", nil, status.FromContextError(err).Err() - case err != nil: - return "", nil, status.Errorf(codes.Unavailable, "cannot read the installation agent %s acts for", agent) - } - carried := effectiveScopes(wc) - facts, err := s.resolveInstallationAuthority(ctx, tenant, installation, permissionsFromCoreScopes(carried, recheckContentReads())) - if err != nil { - return "", nil, err - } - if facts.Actor == nil || facts.Actor.ID != agent { - return "", nil, status.Errorf(codes.PermissionDenied, "installation %s no longer acts through agent %s", installation, agent) - } - if chain := wc.GetActorChain(); len(chain) > 0 { - if err := s.requireChainNotRevoked(ctx, tenant, wc.GetTaskId(), chain); err != nil { - return "", nil, err - } - } else if err := enforceActorCeiling(ctx, facts.Actor, wc.GetAudience(), carried); err != nil { - return "", nil, err - } - return installation, facts, nil -} - -// installationHeldThrough is rule A for a parent an installation's agent -// owns: of what the binding may do (through), what that installation's -// standing grants admit at the audience now, action by action, within the -// kinds the agent may be delegated. The parent is re-verified first, so a -// hop stands on a parent that still stands. -func (s *WorkContextAuthorityServer) installationHeldThrough(ctx context.Context, parent *basev0.WorkContextV1, audience string, through []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { - installation, facts, err := s.requireInstallationStands(ctx, parent) - if err != nil { - return nil, err - } - var held []*basev0.WorkScopeV1 - for _, scope := range through { - if len(facts.Actor.AllowedScopes) > 0 && !slices.Contains(facts.Actor.AllowedScopes, scope.GetResourceKind()) { - continue - } - var actions []string - for _, action := range scope.GetActions() { - admitted, err := s.installationAdmits(ctx, parent.GetTenantId(), installation, audience, - &gen.WorkContextScope{ResourceKind: scope.GetResourceKind(), Actions: []string{action}, ResourceIds: scope.GetResourceIds()}) - if err != nil { - return nil, err - } - if admitted { - actions = append(actions, action) - } - } - if len(actions) > 0 { - held = append(held, &basev0.WorkScopeV1{ResourceKind: scope.GetResourceKind(), Actions: actions, ResourceIds: scope.GetResourceIds()}) - } - } - return held, nil -} - -// installationAdmits answers whether an installation's standing grants admit -// one scope at the audience, by the check its mint makes. -func (s *WorkContextAuthorityServer) installationAdmits(ctx context.Context, orgID, installationID, audience string, scope *gen.WorkContextScope) (bool, error) { - permissions, _, err := workContextScopes([]*gen.WorkContextScope{scope}, mintContentReads(audience)) - if err != nil { - return false, status.Error(codes.Internal, err.Error()) - } - _, err = s.resolveInstallationAuthority(ctx, orgID, installationID, permissions) - switch status.Code(err) { - case codes.OK: - return true, nil - case codes.PermissionDenied: - return false, nil - } - return false, err -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_installation_test.go b/module/services/accounts/code/pkg/adapters/host_interface_installation_test.go deleted file mode 100644 index aaf527076..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_installation_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package adapters - -import ( - "context" - "errors" - "testing" - - "github.com/codefly-dev/core/workcontext" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/auth" - "accounts/pkg/business" - "accounts/pkg/business/executiontest" - authorityv1 "accounts/pkg/gen/host/authority/v1" -) - -// installationIssueFixture is a host composing one solution, notes.test, pinned -// to the test organisation and installed there (the seal fake answers -// notesInstallation), beside the module tool.test. It returns the -// interface, the store answering whose each installation is, the solution's -// workload credential and the module's. -func installationIssueFixture(t *testing.T) (*HostAuthorityServer, *workContextAuthorityFake, string, string) { - t.Helper() - previous := service - svc, err := business.NewService(renewMembershipStore{}) - require.NoError(t, err) - service = svc - t.Cleanup(func() { service = previous }) - cluster := executiontest.New() - svc.SetExecutionBinding(cluster) - solution := business.ModuleWorkload{Namespace: "notes", ServiceAccount: "notes", Container: "app"} - module := business.ModuleWorkload{Namespace: "example", ServiceAccount: "tool", Container: "app"} - svc.SetModulePrincipals(business.ModulePrincipalRegistry{ - business.ModulePrincipalID("notes.test"): {Prefix: "notes.test", Tenant: renewOrgID, Resources: []string{"doc"}, Workloads: []business.ModuleWorkload{solution}}, - business.ModulePrincipalID("tool.test"): {Prefix: "tool.test", Tenant: renewOrgID, Workloads: []business.ModuleWorkload{module}}, - }) - declareTestAudiences(svc) - seals := newSealAnswersFake(7) - seals.installations["notes.test"] = notesInstallation - seals.installations["tool.test"] = toolInstallation - facts := healthyInstallationFacts() - facts.Actor.AllowedAudiences, facts.Actor.AllowedScopes = nil, nil - authority := &workContextAuthorityFake{installationFcts: facts, installations: map[string]business.InstallationIdentity{ - notesInstallation: {OrgID: renewOrgID, PrincipalID: business.ModulePrincipalID("notes.test"), Status: "active"}, - toolInstallation: {OrgID: renewOrgID, PrincipalID: business.ModulePrincipalID("tool.test"), Status: "active"}, - }, agents: map[string]string{installAgentID: notesInstallation}} - issuer := newCoreTestServer(t, authority, seals) - return NewHostAuthorityServer(issuer, nil), authority, - cluster.Run(executiontest.Workload{Namespace: solution.Namespace, ServiceAccount: solution.ServiceAccount, Container: solution.Container}), - cluster.Run(executiontest.Workload{Namespace: module.Namespace, ServiceAccount: module.ServiceAccount, Container: module.Container}) -} - -const ( - notesInstallation = "0199a000-0000-7000-8000-0000000000a1" - toolInstallation = "0199a000-0000-7000-8000-0000000000a2" -) - -func installationIssue(installation, credential, org string) *authorityv1.IssueRequest { - return &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Installation{Installation: &authorityv1.InstallationRef{InstallationId: installation}}, - Audience: "notes.test", OrganizationId: org, Actor: &authorityv1.WorkloadCredential{Credential: credential}, - Scopes: []*authorityv1.WorkScopeV1{{ResourceKind: "doc", Actions: []string{"read"}}}, - } -} - -// The installed solution's own workload is issued its own installation's -// capability, sealed to that installation and owned by its agent: no person is -// acting, so neither the owner of record nor anyone else is named, and nothing -// acts below the owner. It verifies where it is addressed. -func TestAnInstallationActsForItselfThroughItsSolutionsWorkload(t *testing.T) { - server, _, solution, _ := installationIssueFixture(t) - out, err := server.Issue(context.Background(), installationIssue(notesInstallation, solution, "")) - require.NoError(t, err) - decoded, err := workcontext.Decode(out.GetToken()) - require.NoError(t, err) - wc := decoded.Context() - require.Equal(t, notesInstallation, wc.GetSeal().GetInstallationId()) - require.Equal(t, renewOrgID, wc.GetTenantId()) - require.Equal(t, installAgentID, wc.GetOwnerPrincipalId()) - require.Equal(t, business.PrincipalKindAgent, wc.GetOwnerPrincipalKind()) - require.Equal(t, "acme.example/solution:1.0.0", wc.GetOwnerAgentId()) - require.Empty(t, wc.GetActorChain()) - require.Empty(t, wc.GetAttributionTeamIds(), "no person's teams are attributed") - require.Equal(t, installAgentID, out.GetContext().GetOwnerPrincipalId()) - require.Equal(t, "agent", out.GetContext().GetOwnerPrincipalKind()) - _, err = server.Check(context.Background(), &authorityv1.CheckRequest{Token: out.GetToken(), Audience: "notes.test"}) - require.NoError(t, err) -} - -// A Check re-reads the installation an agent-owned capability stands on: -// revoking the installation, withdrawing what its standing grant admits, or -// moving the installation to another agent refuses a capability whose seal and -// revision have not moved. -func TestACheckReverifiesAnInstallationOwnedCapability(t *testing.T) { - for name, move := range map[string]func(*workContextAuthorityFake){ - "the installation is revoked": func(f *workContextAuthorityFake) { - revoked := f.installations[notesInstallation] - revoked.Status = "revoked" - f.installations[notesInstallation] = revoked - }, - "the standing grant no longer admits it": func(f *workContextAuthorityFake) { - f.installationErr = business.NewStoreError(errors.New("agent standing grant does not authorize doc:read at requested scope"), business.ErrTypePermission) - }, - "the installation acts through another agent": func(f *workContextAuthorityFake) { - moved := *f.installationFcts - actor := *moved.Actor - actor.ID = "019fec91-2000-7000-8000-0000000000ff" - moved.Actor = &actor - f.installationFcts = &moved - }, - } { - t.Run(name, func(t *testing.T) { - server, store, solution, _ := installationIssueFixture(t) - out, err := server.Issue(context.Background(), installationIssue(notesInstallation, solution, "")) - require.NoError(t, err) - move(store) - _, err = server.Check(context.Background(), &authorityv1.CheckRequest{Token: out.GetToken(), Audience: "notes.test"}) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) - _, err = server.ListResources(context.Background(), &authorityv1.ListResourcesRequest{Token: out.GetToken(), Audience: "notes.test", ResourceKind: "doc", Action: "read"}) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) - }) - } -} - -// An installation's capability cannot be used to act as a person: a member -// signed in cannot renew it or exchange it as their own, because it is not -// theirs, and a hop below it stays owned by the agent -// (TestAHopFromAnInstallationOwnedParentInheritsTheInstallationsStandingGrants). -func TestAnInstallationOwnedCapabilityCannotActAsAPerson(t *testing.T) { - server, _, solution, _ := installationIssueFixture(t) - out, err := server.Issue(context.Background(), installationIssue(notesInstallation, solution, "")) - require.NoError(t, err) - member := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, auth.Assurance{}) - for name, audience := range map[string]string{"a renewal": "notes.test", "an exchange": "tool.test"} { - t.Run(name, func(t *testing.T) { - _, err := server.Issue(member, &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: out.GetToken()}, Audience: audience, - }) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) - require.Contains(t, status.Convert(err).Message(), "not valid for caller") - }) - } -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_issue_test.go b/module/services/accounts/code/pkg/adapters/host_interface_issue_test.go deleted file mode 100644 index 59901c7ff..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_issue_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package adapters - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - accountsauth "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" -) - -// requiringPolicy is a delivered method policy requiring one scope, held as -// the host's delivery check holds it; it records what the call was held to. -type requiringPolicy struct { - required *basev0.WorkScopeV1 - held []*basev0.WorkScopeV1 -} - -func (p *requiringPolicy) CheckDeliveredMethod(_ context.Context, audience, method string, held []*basev0.WorkScopeV1) error { - p.held = held - if !workcontext.ScopeContained(p.required, held) { - return fmt.Errorf("%s requires %s for %s, which the call does not hold", audience, scopeString(p.required), method) - } - return nil -} - -// A method is held to what the call's current actor was delegated, not to the -// owner's authority above it: an owner holding read and write who delegated -// read alone does not pass a method requiring write through that actor. -func TestCheckHoldsAMethodToWhatTheActorWasDelegated(t *testing.T) { - issuer, server, _ := hostChildFixture(t) - policy := &requiringPolicy{required: hopScope("evidence", []string{"write"})} - server.methods = policy - owned := hopScope("evidence", []string{"read", "write"}) - delegated, _ := mintTestCapability(t, issuer, testCapability{ - Audience: "documents", Tenant: renewOrgID, Owner: renewActorID, Scopes: []*basev0.WorkScopeV1{owned}, - Actors: []delegationHop{agentHop("actor-1", uuid.NewString(), hopScope("evidence", []string{"read"}))}, - }) - const method = "/example.v1.EvidenceService/Write" - - _, err := server.Check(context.Background(), &authorityv1.CheckRequest{Token: delegated, Audience: "documents", Method: method}) - requireRefused(t, err, codes.PermissionDenied, "requires evidence:write") - requireScopes(t, []*basev0.WorkScopeV1{hopScope("evidence", []string{"read"})}, policy.held) - - // The owner acting directly holds write, and the same method passes. - direct, _ := mintTestCapability(t, issuer, testCapability{ - Audience: "documents", Tenant: renewOrgID, Owner: renewActorID, Scopes: []*basev0.WorkScopeV1{owned}, - }) - _, err = server.Check(context.Background(), &authorityv1.CheckRequest{Token: direct, Audience: "documents", Method: method}) - require.NoError(t, err) -} - -// workloadPrincipals composes documents, whose workload issues through its -// "model" binding (headless: invoke and read on one profile; lookup: read), -// and the two audiences its bindings name. -const workloadPrincipals = `{"documents":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `","operation_audiences":{` + - `"model":{"audience":"modelservice",` + - `"invoke_scopes":[{"resource_kind":"modelservice.profiles","actions":["invoke","read"]}],` + - `"lookup_scopes":[{"resource_kind":"modelservice.profiles","actions":["read"]}],` + - `"headless_scopes":[{"resource_kind":"modelservice.profiles","actions":["invoke","read"],"resource_ids":["profile-digest"]}]},` + - `"interactive":{"audience":"reportservice",` + - `"invoke_scopes":[{"resource_kind":"reports","actions":["read","write"]}]}}},` + - `"modelservice":{"workload":{"service_account":"model","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `","resources":["modelservice.profiles"]},` + - `"reportservice":{"workload":{"service_account":"reports","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `","resources":["reports"]}}` - -type workloadFixture struct { - server *HostAuthorityServer - credential string -} - -func newWorkloadFixture(t *testing.T) workloadFixture { - t.Helper() - installModuleOperationContextService(t, workloadPrincipals) - installModuleWorkContextAuthority(t) - return workloadFixture{server: NewHostAuthorityServer(WorkContextSingleton(), ModuleCapabilitiesSingleton()), credential: moduleToken(t, "documents")} -} - -func (f workloadFixture) issue(req *authorityv1.IssueRequest) (*authorityv1.IssueResponse, error) { - req.As = &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: f.credential}} - return f.server.Issue(context.Background(), req) -} - -func lifetime(wc *basev0.WorkContextV1) time.Duration { - return time.Duration(wc.GetExpiresAtUnix()-wc.GetIssuedAtUnix()) * time.Second -} - -// A workload's issue is what it asked for, below what its binding grants it: -// a read-only lookup on one profile for five seconds is exactly that, not the -// binding's whole headless grant for a minute. -func TestAWorkloadIssueIsWhatItAskedForWithinItsBinding(t *testing.T) { - f := newWorkloadFixture(t) - - out, err := f.issue(&authorityv1.IssueRequest{ - OperationBindingId: "model", Operation: "lookup", TtlSeconds: 5, - Scopes: []*authorityv1.WorkScopeV1{ask("modelservice.profiles", []string{"read"}, "profile-digest")}, - }) - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, "modelservice", wc.GetAudience()) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("modelservice.profiles", []string{"read"}, "profile-digest")}, wc.GetAuthorityScopes()) - require.LessOrEqual(t, lifetime(wc), 5*time.Second) - _, err = WorkContextSingleton().verifyFor(context.Background(), out.GetToken(), "modelservice") - require.NoError(t, err) - - // An empty lookup is the binding's lookup within its headless grant. - out, err = f.issue(&authorityv1.IssueRequest{OperationBindingId: "model", Operation: "lookup"}) - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("modelservice.profiles", []string{"read"}, "profile-digest")}, claims(t, out).GetAuthorityScopes()) - - // An empty headless ask is the whole headless grant, at the mint's ceiling. - out, err = f.issue(&authorityv1.IssueRequest{OperationBindingId: "model", TtlSeconds: 3600}) - require.NoError(t, err) - wc = claims(t, out) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("modelservice.profiles", []string{"invoke", "read"}, "profile-digest")}, wc.GetAuthorityScopes()) - require.LessOrEqual(t, lifetime(wc), 60*time.Second) -} - -// Nothing outside the binding's grant is issued, and every refusal names why. -func TestAWorkloadIssueBeyondItsBindingIsRefusedByName(t *testing.T) { - f := newWorkloadFixture(t) - for name, c := range map[string]struct { - req *authorityv1.IssueRequest - code codes.Code - reason string - }{ - "an action the binding does not grant": {&authorityv1.IssueRequest{OperationBindingId: "model", - Scopes: []*authorityv1.WorkScopeV1{ask("modelservice.profiles", []string{"write"}, "profile-digest")}}, codes.PermissionDenied, "widens what operation binding \"model\" grants it for headless"}, - "the kind beyond the one profile granted": {&authorityv1.IssueRequest{OperationBindingId: "model", - Scopes: []*authorityv1.WorkScopeV1{ask("modelservice.profiles", []string{"read"})}}, codes.PermissionDenied, "widens"}, - "invoke under a lookup": {&authorityv1.IssueRequest{OperationBindingId: "model", Operation: "lookup", - Scopes: []*authorityv1.WorkScopeV1{ask("modelservice.profiles", []string{"invoke"}, "profile-digest")}}, codes.PermissionDenied, "for lookup"}, - "an audience the binding does not address": {&authorityv1.IssueRequest{OperationBindingId: "model", Audience: "reportservice"}, - codes.PermissionDenied, `"model" is addressed to modelservice, not reportservice`}, - "invoke, a person's operation": {&authorityv1.IssueRequest{OperationBindingId: "model", Operation: "invoke"}, - codes.InvalidArgument, "invoke is a person's operation"}, - "a lookup the binding does not declare": {&authorityv1.IssueRequest{OperationBindingId: "interactive", Operation: "lookup"}, - codes.PermissionDenied, `"interactive" declares no lookup`}, - "a negative lifetime": {&authorityv1.IssueRequest{OperationBindingId: "model", TtlSeconds: -1}, - codes.InvalidArgument, "not negative"}, - "scopes on its own context": {&authorityv1.IssueRequest{Audience: ModuleWorkContextAudience, - Scopes: []*authorityv1.WorkScopeV1{ask("modelservice.profiles", []string{"read"})}}, codes.InvalidArgument, "carries no scopes"}, - "an operation on its own context": {&authorityv1.IssueRequest{Audience: ModuleWorkContextAudience, Operation: "lookup"}, - codes.InvalidArgument, "names no operation"}, - "an audience that is not composed": {&authorityv1.IssueRequest{OperationBindingId: "model", Audience: "nowhere"}, - codes.NotFound, `audience "nowhere" is not composed`}, - } { - t.Run(name, func(t *testing.T) { - _, err := f.issue(c.req) - requireRefused(t, err, c.code, c.reason) - }) - } -} - -// A workload's own context is reachable: addressed to the module capability -// surface, which is never a composed module, with the lifetime it asked for. -func TestAWorkloadIssuesItsOwnContext(t *testing.T) { - f := newWorkloadFixture(t) - out, err := f.issue(&authorityv1.IssueRequest{Audience: ModuleWorkContextAudience, TtlSeconds: 30}) - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, ModuleWorkContextAudience, wc.GetAudience()) - require.LessOrEqual(t, lifetime(wc), 30*time.Second) - caller, err := WorkContextSingleton().VerifyModuleWorkContext(context.Background(), out.GetToken()) - require.NoError(t, err) - require.Equal(t, moduleWorkContextTenant, caller.BoundOrg) -} - -// A credential that proves nothing is refused naming why, and a caller proving -// nothing learns nothing of which modules are composed: a bearer-less person -// or exchange is Unauthenticated whatever the audience. -func TestAnIssueAuthenticatesBeforeItAnswersForTheComposition(t *testing.T) { - f := newWorkloadFixture(t) - f.credential = "not-a-token" - _, err := f.issue(&authorityv1.IssueRequest{OperationBindingId: "model"}) - requireRefused(t, err, codes.PermissionDenied, "workload credential refused: the presented token was reviewed and refused") - - for _, audience := range []string{"modelservice", "nowhere"} { - _, err = f.server.Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: audience, - }) - requireRefused(t, err, codes.Unauthenticated, "signed-in person") - _, err = f.server.Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: "p"}, Audience: audience, - }) - requireRefused(t, err, codes.Unauthenticated, "carries no bearer") - } -} - -// An issue from a delegation names the module acting before anything is read. -func TestAnIssueFromADelegationNamesItsActor(t *testing.T) { - _, err := (&HostAuthorityServer{}).Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Delegation{Delegation: &authorityv1.DelegationRef{DelegationId: "d"}}, Audience: "documents", - }) - requireRefused(t, err, codes.Unauthenticated, "names the module acting") -} - -// exchangeFixture is the owner of a capability addressed to tool.test, signed -// in, over the interface. -func exchangeFixture(t *testing.T) (*HostAuthorityServer, string, context.Context) { - t.Helper() - exchangeInstallationService(t) - issuer := newRenewTestServer(t) - parent := personCapability(t, issuer, "tool.test", testInstallation("tool.test")) - return NewHostAuthorityServer(issuer, nil), parent, exchangeCaller() -} - -// An exchange through Issue forwards the organisation, the request's or the -// parent's own, and an empty ask is the parent's own authority. -func TestAnIssueExchangesAParentToAnotherAudience(t *testing.T) { - for name, req := range map[string]*authorityv1.IssueRequest{ - "scopes asked, organisation named": {Audience: "documents", OrganizationId: renewOrgID, - Scopes: []*authorityv1.WorkScopeV1{ask("evidence", []string{"read"})}}, - "empty ask, organisation the parent's": {Audience: "documents"}, - } { - t.Run(name, func(t *testing.T) { - server, parent, ctx := exchangeFixture(t) - req.As = &authorityv1.IssueRequest_ParentToken{ParentToken: parent} - out, err := server.Issue(ctx, req) - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, "documents", wc.GetAudience()) - require.Equal(t, renewOrgID, wc.GetTenantId()) - require.Equal(t, renewActorID, wc.GetOwnerPrincipalId()) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("evidence", []string{"read"})}, wc.GetAuthorityScopes()) - }) - } -} - -// At the parent's own audience the issue is a renewal: the same task, a fresh -// session and window, the authority kept as it stands. An owner's own -// capability is renewed by its owner; a delegated one by its current actor, -// who may narrow it. -func TestAnIssueAtTheParentsAudienceRenewsIt(t *testing.T) { - server, parent, ctx := exchangeFixture(t) - delegated := mintDelegatedContext(t, server.workContexts) - for name, c := range map[string]struct { - parent string - asked []*authorityv1.WorkScopeV1 - want *basev0.WorkScopeV1 - }{ - "an owner's own": {parent, nil, hopScope("evidence", []string{"read"})}, - "a delegated one": {delegated, nil, hopScope("evidence", []string{"append", "read"})}, - "a delegated one, narrowed": {delegated, []*authorityv1.WorkScopeV1{ask("evidence", []string{"read"})}, hopScope("evidence", []string{"read"})}, - } { - t.Run(name, func(t *testing.T) { - decoded, err := workcontext.Decode(c.parent) - require.NoError(t, err) - out, err := server.Issue(ctx, &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: c.parent}, Audience: "tool.test", TtlSeconds: 600, Scopes: c.asked, - }) - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, "tool.test", wc.GetAudience()) - require.Equal(t, decoded.Context().GetTaskId(), wc.GetTaskId()) - require.NotEqual(t, decoded.Context().GetSessionId(), wc.GetSessionId()) - requireScopes(t, []*basev0.WorkScopeV1{c.want}, effectiveScopes(wc)) - _, err = server.workContexts.verifyFor(context.Background(), out.GetToken(), "tool.test") - require.NoError(t, err) - }) - } - - // An owner's renewal keeps its authority: narrowing it is an exchange. - _, err := server.Issue(ctx, &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, Audience: "tool.test", - Scopes: []*authorityv1.WorkScopeV1{ask("evidence", []string{"read"})}, - }) - requireRefused(t, err, codes.InvalidArgument, "names no scopes") - // Another person's bearer renews nothing of it. - stranger := stampVerifiedIdentity(context.Background(), renewOwnerID, renewOrgID, accountsauth.Assurance{}) - _, err = server.Issue(stranger, &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, Audience: "tool.test", - }) - require.Error(t, err) -} - -// A kind the chain narrowed to nothing stays dropped: it is carried as a -// denial, a scope naming the kind and no action, and no later hop gets any of -// it back; a kind narrowed to two actions on different resources keeps each -// action's own resources; a kind the chain never named is not limited. -func TestAKindTheChainDroppedStaysDropped(t *testing.T) { - n := parentNarrowings(&basev0.WorkContextV1{ - AuthorityScopes: []*basev0.WorkScopeV1{ - hopScope("tool.items", []string{"read"}), {ResourceKind: "datasets"}, - hopScope("profiles", []string{"read"}, "a"), hopScope("profiles", []string{"invoke"}, "b"), - }, - ActorChain: []*basev0.WorkActorV1{{GrantedScopes: []*basev0.WorkScopeV1{hopScope("tool.items", []string{"write"})}}}, - }) - require.Empty(t, n.keep([]*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"})}), "read and write meet in nothing") - require.Empty(t, n.keep([]*basev0.WorkScopeV1{hopScope("datasets", []string{"read"})}), "a denied kind gives nothing back") - require.Equal(t, []*basev0.WorkScopeV1{hopScope("profiles", []string{"read"}, "a")}, - n.keep([]*basev0.WorkScopeV1{hopScope("profiles", []string{"invoke", "read"}, "a")}), "each action keeps its own resources") - require.Len(t, n.keep([]*basev0.WorkScopeV1{hopScope("unnamed", []string{"read"})}), 1, "a kind never named is not limited") - - carried := n.outside([]*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}) - require.Contains(t, carried, &basev0.WorkScopeV1{ResourceKind: "datasets"}, "the denial is carried") - require.Contains(t, carried, &basev0.WorkScopeV1{ResourceKind: "tool.items"}, "a kind narrowed to nothing is carried as a denial") - require.Contains(t, carried, hopScope("profiles", []string{"read"}, "a")) - require.Contains(t, carried, hopScope("profiles", []string{"invoke"}, "b")) - - denied := n.deny([]*basev0.WorkScopeV1{hopScope("example-records", []string{"read"}), hopScope("records.types", []string{"read"})}, - []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}) - require.Empty(t, denied.keep([]*basev0.WorkScopeV1{hopScope("records.types", []string{"read"})}), "a held kind the grant left out is dropped") -} - -// The hop's mint runs the structural audience rule every mint runs. -func TestAHopMintRefusesTheModuleCapabilitySurface(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - _, parent := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - _, err := f.issuer.mintBindingHop(context.Background(), parent, "tools", "records", "invoke", ModuleWorkContextAudience, - []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}, nil, time.Minute, delegatedHop{}) - requireRefused(t, err, codes.PermissionDenied, "module capability surface") -} - -// A person's root names, as a denial, every kind of the audience its ask left -// out; a kind it carries is never denied, and nothing is denied without the -// interface's mark. -func TestARootDeniesTheAudienceKindsItsAskLeftOut(t *testing.T) { - ctx := withDroppedKinds(context.Background(), []string{"datasets", "profiles"}) - carried := []*basev0.WorkScopeV1{hopScope("profiles", []string{"read"}, "a")} - require.Equal(t, []*basev0.WorkScopeV1{{ResourceKind: "datasets"}}, droppedKindScopes(ctx, carried)) - require.Empty(t, droppedKindScopes(context.Background(), carried)) -} - -// A credential refusal names its reason, and a module refused as itself (its -// identity no longer the one its principal declares) is Unauthenticated, as the -// module tier answers it, so it mints again; a token the review refused outright -// stays PermissionDenied, the code the shared suite holds a forged one to. -func TestACredentialRefusalNamesItsReasonAndItsCode(t *testing.T) { - for name, tc := range map[string]struct { - err error - code codes.Code - }{ - "identity refused as itself": {fmt.Errorf("%w: the presented token's pod runs a container the approval does not cover", business.ErrModuleIdentityDenied), codes.Unauthenticated}, - "not a bound token": {fmt.Errorf("%w: no audience", business.ErrExecutionNotBoundToken), codes.Unauthenticated}, - "forged": {fmt.Errorf("%w: unknown token", business.ErrExecutionTokenRejected), codes.PermissionDenied}, - "ambiguous workload": {fmt.Errorf("%w: declared by 0 principals", business.ErrExecutionWorkloadAmbiguous), codes.PermissionDenied}, - "host cannot tell": {fmt.Errorf("%w: no cluster", business.ErrExecutionUnbound), codes.Unavailable}, - } { - t.Run(name, func(t *testing.T) { - err := credentialRefusal(tc.err, "workload") - require.Equal(t, tc.code, status.Code(err), "%v", err) - require.Contains(t, status.Convert(err).Message(), "workload credential") - if tc.code != codes.PermissionDenied || name == "ambiguous workload" { - require.Contains(t, status.Convert(err).Message(), strings.SplitN(tc.err.Error(), ": ", 2)[1]) - } - }) - } -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_kind_removal_test.go b/module/services/accounts/code/pkg/adapters/host_interface_kind_removal_test.go deleted file mode 100644 index fbf6fcdf7..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_kind_removal_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package adapters - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "connectrpc.com/connect" - "github.com/stretchr/testify/require" - - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - authorityv1connect "accounts/pkg/gen/host/authority/v1/authorityv1connect" -) - -// Regression: removing a complete kind upstream must invalidate descendants. -func TestUpstreamKindRemovalEndsDescendant(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - journal := &bindingJournal{hops: map[string]business.ActorChainHopInput{}} - f.issuer.journal = journal - mux := http.NewServeMux() - path, handler := authorityv1connect.NewAuthorityServiceHandler(&hostAuthorityConnectHandler{inner: f.server}) - mux.Handle(path, handler) - server := httptest.NewServer(mux) - defer server.Close() - client := authorityv1connect.NewAuthorityServiceClient(server.Client(), server.URL) - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - issue := func(parent, credential, binding, audience string) string { - out, err := client.Issue(context.Background(), connect.NewRequest(&authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, Actor: &authorityv1.WorkloadCredential{Credential: credential}, - OperationBindingId: binding, Audience: audience, - })) - require.NoError(t, err) - return out.Msg.GetToken() - } - first := issue(parent, f.tool, "records", "documents") - child := issue(first, f.docs, "items", "tools") - check := func() error { - _, err := client.Check(context.Background(), connect.NewRequest(&authorityv1.CheckRequest{Token: child, Audience: "tools"})) - return err - } - require.NoError(t, check()) - // Missing provenance cannot turn a historical grant into an unrelated kind. - recorded := journal.hops - journal.hops = map[string]business.ActorChainHopInput{} - require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(check())) - journal.hops = recorded - require.NoError(t, check()) - recompose(t, func(s string) string { - s = strings.Replace(s, `"invoke_scopes":[{"resource_kind":"example-records","actions":["invoke","read"]}]`, `"invoke_scopes":[{"resource_kind":"other-records","actions":["read"]}]`, 1) - return strings.Replace(s, `,"lookup_scopes":[{"resource_kind":"example-records","actions":["read"]}]`, ``, 1) - }) - err := check() - require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), "descendant Check must refuse after its upstream binding drops example-records entirely; got %v", err) - _, err = client.Issue(context.Background(), connect.NewRequest(&authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: child}, Actor: &authorityv1.WorkloadCredential{Credential: f.tool}, - OperationBindingId: "records", Audience: "documents", - })) - require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), "a stale descendant cannot issue another hop") -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_limits.go b/module/services/accounts/code/pkg/adapters/host_interface_limits.go deleted file mode 100644 index 4a34d0e50..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_limits.go +++ /dev/null @@ -1,254 +0,0 @@ -package adapters - -import ( - "context" - "slices" - "sort" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// The lasting limits a chain of capabilities puts on each resource kind it has -// visited (rule A: a narrowing is never recovered). -// -// A kind's limit is, per action, the resources the chain still allows: every -// resource, or the named ones. A kind the chain dropped is limited to no -// action at all, and is carried as a scope naming the kind and no action, an -// explicit denial a later hop reads back. A kind the chain never named is not -// limited: that is how a chain crosses into an audience whose kinds it never -// carried. - -// kindLimit is, per action, the resources allowed: nil is every resource. -type kindLimit map[string][]string - -// narrowings is each visited kind's limit. -type narrowings map[string]kindLimit - -// limitOf is the coverage of one scope set on kind: each row's actions on its -// resources, a kind-wide row covering every resource of its actions. -func limitOf(set []*basev0.WorkScopeV1, kind string) kindLimit { - out := kindLimit{} - for _, scope := range set { - if scope.GetResourceKind() != kind { - continue - } - for _, action := range scope.GetActions() { - ids, seen := out[action] - switch { - case seen && ids == nil: - case len(scope.GetResourceIds()) == 0: - out[action] = nil - default: - out[action] = union(ids, scope.GetResourceIds()) - } - } - } - return out -} - -// parentNarrowings is the limit a parent's every scope set puts on each kind -// any of them names: its authority scopes and each hop's grant, met. -func parentNarrowings(parent *basev0.WorkContextV1) narrowings { - sets := [][]*basev0.WorkScopeV1{parent.GetAuthorityScopes()} - for _, hop := range parent.GetActorChain() { - sets = append(sets, hop.GetGrantedScopes()) - } - out := narrowings{} - for _, set := range sets { - kinds := map[string]bool{} - for _, scope := range set { - kinds[scope.GetResourceKind()] = true - } - for kind := range kinds { - limit := limitOf(set, kind) - if previous, ok := out[kind]; ok { - limit = previous.meet(limit) - } - out[kind] = limit - } - } - return out -} - -// meet is what both limits allow. -func (l kindLimit) meet(other kindLimit) kindLimit { - out := kindLimit{} - for action, ids := range l { - theirs, ok := other[action] - if !ok { - continue - } - if met, any := meetIDs(ids, theirs); any { - out[action] = met - } - } - return out -} - -// meetIDs is the resources both allow, nil meaning every resource; any is -// false when they allow none in common. -func meetIDs(a, b []string) ([]string, bool) { - switch { - case a == nil: - return slices.Clone(b), true - case b == nil: - return slices.Clone(a), true - } - var out []string - for _, id := range a { - if slices.Contains(b, id) { - out = append(out, id) - } - } - return out, len(out) > 0 -} - -func union(a, b []string) []string { - out := slices.Clone(a) - for _, id := range b { - if !slices.Contains(out, id) { - out = append(out, id) - } - } - slices.Sort(out) - return out -} - -// keep is scopes with each limited kind held to its limit; what a limit allows -// none of is dropped. -func (n narrowings) keep(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - var out []*basev0.WorkScopeV1 - for _, scope := range scopes { - limit, limited := n[scope.GetResourceKind()] - if !limited { - out = append(out, scope) - continue - } - within := kindLimit{} - for _, action := range scope.GetActions() { - allowed, ok := limit[action] - if !ok { - continue - } - var ids []string - if len(scope.GetResourceIds()) > 0 { - ids = scope.GetResourceIds() - } - if met, any := meetIDs(ids, allowed); any { - within[action] = met - } - } - out = append(out, within.scopes(scope.GetResourceKind())...) - } - return out -} - -// deny is n with every kind of domain that granted does not name limited to -// nothing: a kind the owner could have carried here and the grant left out. -func (n narrowings) deny(domain, granted []*basev0.WorkScopeV1) narrowings { - out := narrowings{} - for kind, limit := range n { - out[kind] = limit - } - for _, scope := range domain { - kind := scope.GetResourceKind() - if !slices.ContainsFunc(granted, func(g *basev0.WorkScopeV1) bool { return g.GetResourceKind() == kind }) { - out[kind] = kindLimit{} - } - } - return out -} - -// outside is the limit of every kind scopes does not name, as scopes: a kind -// limited to nothing as its denial, a scope naming the kind and no action. -func (n narrowings) outside(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - named := map[string]bool{} - for _, scope := range scopes { - named[scope.GetResourceKind()] = true - } - kinds := make([]string, 0, len(n)) - for kind := range n { - if !named[kind] { - kinds = append(kinds, kind) - } - } - sort.Strings(kinds) - var out []*basev0.WorkScopeV1 - for _, kind := range kinds { - if rows := n[kind].scopes(kind); len(rows) > 0 { - out = append(out, rows...) - } else { - out = append(out, &basev0.WorkScopeV1{ResourceKind: kind}) - } - } - return out -} - -// scopes is a limit as rows of kind: actions allowed on the same resources -// share one row, in a stable order. -func (l kindLimit) scopes(kind string) []*basev0.WorkScopeV1 { - actions := make([]string, 0, len(l)) - for action := range l { - actions = append(actions, action) - } - sort.Strings(actions) - var out []*basev0.WorkScopeV1 - for _, action := range actions { - ids := l[action] - i := slices.IndexFunc(out, func(row *basev0.WorkScopeV1) bool { - return (row.ResourceIds == nil) == (ids == nil) && slices.Equal(row.ResourceIds, ids) - }) - if i >= 0 { - out[i].Actions = append(out[i].Actions, action) - continue - } - out = append(out, &basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceIds: slices.Clone(ids)}) - } - return out -} - -// droppedKindsKey carries, from the interface's person issue to the mint, the -// kinds the capability drops. It is set only inside this process, never read -// from a request. -type droppedKindsKey struct{} - -func withDroppedKinds(ctx context.Context, kinds []string) context.Context { - if len(kinds) == 0 { - return ctx - } - return context.WithValue(ctx, droppedKindsKey{}, kinds) -} - -// droppedAudienceKinds is every kind the audience declares that scopes do not -// name. -func droppedAudienceKinds(audience string, scopes []*gen.WorkContextScope) []string { - if service == nil { - return nil - } - kinds, err := service.ModuleContentResources(audience) - if err != nil { - return nil - } - var out []string - for _, kind := range kinds { - if !slices.ContainsFunc(scopes, func(s *gen.WorkContextScope) bool { return s.GetResourceKind() == kind }) { - out = append(out, kind) - } - } - return out -} - -// droppedKindScopes is the denial of each kind the context drops that scopes -// do not name: a scope naming the kind and no action. -func droppedKindScopes(ctx context.Context, scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - kinds, _ := ctx.Value(droppedKindsKey{}).([]string) - var out []*basev0.WorkScopeV1 - for _, kind := range kinds { - if !slices.ContainsFunc(scopes, func(s *basev0.WorkScopeV1) bool { return s.GetResourceKind() == kind }) { - out = append(out, &basev0.WorkScopeV1{ResourceKind: kind}) - } - } - return out -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_person_binding_test.go b/module/services/accounts/code/pkg/adapters/host_interface_person_binding_test.go deleted file mode 100644 index dc4f3b053..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_person_binding_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package adapters - -import ( - "context" - "testing" - - "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" - - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestHostPersonIssueCarriesItsNamedBinding(t *testing.T) { - c := moduleActorFixture(t, moduleActorScopes) - server := NewHostAuthorityServer(c.server, nil) - caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) - request := &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, - Audience: "reader", OperationBindingId: "act-source", - } - for _, operation := range []string{"", "invoke"} { - request.Operation = operation - out, err := server.Issue(caller, request) - require.NoError(t, err) - wc := claims(t, out) - require.Equal(t, readOwner, wc.GetOwnerPrincipalId()) - require.Len(t, wc.GetAuthorityScopes(), 1) - scope := wc.GetAuthorityScopes()[0] - require.Equal(t, business.SourceOperationResourceKind, scope.GetResourceKind()) - require.Equal(t, []string{"invoke", "read"}, scope.GetActions()) - require.Equal(t, []string{actorSource}, scope.GetResourceIds()) - _, err = c.exchange("act-source", out.GetToken(), c.moduleToken, false) - require.NoError(t, err, "the module redeems the person's installed binding") - } - request.Operation = "lookup" - _, err := server.Issue(caller, request) - require.Equal(t, codes.InvalidArgument, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), "act-source") - require.Contains(t, status.Convert(err).Message(), "lookup") - - // At a solution whose roles decide, the binding carries what those roles - // grant within it; roles that grant nothing are refused naming the binding. - request.Operation = "" - server.roles = fixedSolutionRoles{"reader": {}} - _, err = server.Issue(caller, request) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), `holds nothing at`) - require.Contains(t, status.Convert(err).Message(), "act-source") - server.roles = nil - - c.server.authority = sourceAccessWithdrawn{} - _, err = server.Issue(caller, request) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), "act-source") -} - -func TestStartTaskRefusesAnEmptyAuthorityAsk(t *testing.T) { - c := moduleActorFixture(t, moduleActorScopes) - caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) - _, err := c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{ - OrgId: readOrg, TaskId: readOrg, Audience: "reader", - }) - require.Equal(t, codes.InvalidArgument, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), "authority_scopes or operation_bindings") -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_resources.go b/module/services/accounts/code/pkg/adapters/host_interface_resources.go deleted file mode 100644 index 6ab3a8bfc..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_resources.go +++ /dev/null @@ -1,156 +0,0 @@ -package adapters - -import ( - "context" - "slices" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - accountsauth "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" -) - -// scopedResourceReader lists the resources a principal holds a kind and action -// on through grants limited to one resource each. -type scopedResourceReader interface { - WorkContextScopedResourceIDs(ctx context.Context, orgID, principalID, kind, action string) ([]string, error) -} - -// ListResources answers which resources of a kind a capability covers for an -// action, decided now from its owner's grants rather than carried in it. The -// capability must hold the action on the kind. A person's answer is what their -// grants cover: their solution roles' where a solution on the path declares -// roles, their organisation grants otherwise. Any other owner is bounded by -// the capability alone. A capability naming resource ids covers only those the -// owner's grants cover. Asking spends no single-use capability. -func (s *HostAuthorityServer) ListResources(ctx context.Context, req *authorityv1.ListResourcesRequest) (*authorityv1.ListResourcesResponse, error) { - kind, action := req.GetResourceKind(), req.GetAction() - if req.GetAudience() == "" || kind == "" || action == "" { - return nil, status.Error(codes.InvalidArgument, "a resource question names the audience, a kind and an action") - } - capability, err := s.workContexts.readBackFor(ctx, req.GetToken(), req.GetAudience()) - if err != nil { - return nil, interfaceStatus(err) - } - wc := capability.Context() - if err := s.workContexts.requireModuleChainStands(ctx, wc); err != nil { - return nil, err - } - // An installation's agent owns its capability by its installation's - // standing, re-read here as Check re-reads it. - if wc.GetOwnerPrincipalKind() == business.PrincipalKindAgent { - if _, _, err := s.workContexts.requireInstallationStands(ctx, wc); err != nil { - return nil, err - } - } - effective := effectiveScopes(wc) - held, whole, ids := false, false, []string{} - for _, scope := range effective { - if scope.GetResourceKind() != kind || !slices.Contains(scope.GetActions(), action) { - continue - } - held = true - whole = whole || len(scope.GetResourceIds()) == 0 - ids = append(ids, scope.GetResourceIds()...) - } - if !held { - return nil, status.Errorf(codes.PermissionDenied, "the capability holds no %s:%s", kind, action) - } - all := []*authorityv1.ResourceGrant{{Match: &authorityv1.ResourceGrant_All{All: true}}} - if wc.GetOwnerPrincipalKind() != business.PrincipalKindHuman { - if whole { - return &authorityv1.ListResourcesResponse{Grants: all}, nil - } - return &authorityv1.ListResourcesResponse{Grants: idGrants(ids)}, nil - } - tenant, owner := wc.GetTenantId(), wc.GetOwnerPrincipalId() - ctx = accountsauth.WithVerifiedDatabaseIdentity(ctx, owner, tenant) - ceiling, bounded, err := s.solutionCeiling(ctx, tenant, owner, solutionPath(wc, wc.GetAudience())) - if err != nil { - return nil, err - } - if bounded { - return &authorityv1.ListResourcesResponse{Grants: roleResources(business.IntersectWorkScopes(effective, ceiling), kind, action)}, nil - } - coveredAll, covered, err := s.workContexts.ownerResourceGrants(ctx, tenant, owner, kind, action) - if err != nil { - return nil, err - } - switch { - case coveredAll && whole: - return &authorityv1.ListResourcesResponse{Grants: all}, nil - case coveredAll: - return &authorityv1.ListResourcesResponse{Grants: idGrants(ids)}, nil - case whole: - return &authorityv1.ListResourcesResponse{Grants: idGrants(covered)}, nil - } - var both []string - for _, id := range ids { - if slices.Contains(covered, id) { - both = append(both, id) - } - } - return &authorityv1.ListResourcesResponse{Grants: idGrants(both)}, nil -} - -// ownerResourceGrants is what the owner's organisation grants cover of kind -// for action: every resource when they hold it organisation-wide (by the check -// every mint makes, content-read standing aside), the ids of their -// resource-limited grants otherwise. -func (s *WorkContextAuthorityServer) ownerResourceGrants(ctx context.Context, orgID, ownerID, kind, action string) (bool, []string, error) { - _, err := s.resolveAuthority(ctx, orgID, ownerID, "", []business.WorkContextPermission{{ResourceKind: kind, Action: action}}) - switch status.Code(err) { - case codes.OK: - return true, nil, nil - case codes.PermissionDenied: - default: - return false, nil, err - } - reader, ok := s.authority.(scopedResourceReader) - if !ok { - return false, nil, status.Error(codes.Unavailable, "this host's authority store lists no resource-limited grants") - } - ids, err := reader.WorkContextScopedResourceIDs(ctx, orgID, ownerID, kind, action) - if err != nil { - return false, nil, status.Error(codes.Unavailable, err.Error()) - } - return false, ids, nil -} - -// effectiveScopes are what the capability's current actor holds: the last -// hop's grant, or the owner's authority when no hop narrowed it. -func effectiveScopes(wc *basev0.WorkContextV1) []*basev0.WorkScopeV1 { - if chain := wc.GetActorChain(); len(chain) > 0 { - return chain[len(chain)-1].GetGrantedScopes() - } - return wc.GetAuthorityScopes() -} - -// roleResources is what role scopes cover of kind for action: every resource -// for a scope naming none, its ids otherwise. -func roleResources(scopes []*basev0.WorkScopeV1, kind, action string) []*authorityv1.ResourceGrant { - var ids []string - for _, scope := range scopes { - if scope.GetResourceKind() != kind || !slices.Contains(scope.GetActions(), action) { - continue - } - if len(scope.GetResourceIds()) == 0 { - return []*authorityv1.ResourceGrant{{Match: &authorityv1.ResourceGrant_All{All: true}}} - } - ids = append(ids, scope.GetResourceIds()...) - } - return idGrants(ids) -} - -func idGrants(ids []string) []*authorityv1.ResourceGrant { - ids = slices.Clone(ids) - slices.Sort(ids) - out := []*authorityv1.ResourceGrant{} - for _, id := range slices.Compact(ids) { - out = append(out, &authorityv1.ResourceGrant{Match: &authorityv1.ResourceGrant_Id{Id: id}}) - } - return out -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_resources_test.go b/module/services/accounts/code/pkg/adapters/host_interface_resources_test.go deleted file mode 100644 index a4decef7a..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_resources_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package adapters - -import ( - "context" - "errors" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - - "accounts/pkg/auth" - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// scopedAuthority is holdingAuthority whose owner also holds grants limited to -// named resources, by kind:action. -type scopedAuthority struct { - *holdingAuthority - scoped map[string][]string -} - -func (f scopedAuthority) WorkContextScopedResourceIDs(_ context.Context, _, _, kind, action string) ([]string, error) { - return f.scoped[kind+":"+action], nil -} - -// resourceFixture is the hop fixture's issuer over an owner holding held -// organisation-wide and scoped resource by resource. -func resourceFixture(t *testing.T, scoped map[string][]string, held ...string) hopFixture { - t.Helper() - f := newHopFixture(t, held...) - f.issuer.authority = scopedAuthority{holdingAuthority: f.holding, scoped: scoped} - return f -} - -func (f hopFixture) resources(t *testing.T, token, kind, action string) ([]*authorityv1.ResourceGrant, error) { - t.Helper() - out, err := f.server.ListResources(context.Background(), &authorityv1.ListResourcesRequest{Token: token, Audience: "documents", ResourceKind: kind, Action: action}) - return out.GetGrants(), err -} - -func grantIDs(grants []*authorityv1.ResourceGrant) []string { - var out []string - for _, g := range grants { - if g.GetAll() { - out = append(out, "*") - } else { - out = append(out, g.GetId()) - } - } - return out -} - -// A person's capability on a kind is answered from their grants now: every -// resource for an organisation-wide grant, the ids of resource-limited ones -// otherwise; a capability naming ids covers only those the grants cover; an -// action the capability does not hold is refused. Asking spends nothing. -func TestListResourcesAnswersFromTheOwnersGrants(t *testing.T) { - read := []string{"read"} - wide := resourceFixture(t, nil, "example-records:read") - token, _ := wide.parent(t, "documents", hopScope("example-records", read)) - grants, err := wide.resources(t, token, "example-records", "read") - require.NoError(t, err) - require.Equal(t, []string{"*"}, grantIDs(grants)) - - limited := resourceFixture(t, map[string][]string{"example-records:read": {"a", "b"}}) - token, _ = mintTestCapability(t, limited.issuer, testCapability{Audience: "documents", Tenant: moduleWorkContextTenant, Owner: hopOwner, - Replay: workcontext.ReplaySingleUse, Scopes: []*basev0.WorkScopeV1{hopScope("example-records", read)}}) - grants, err = limited.resources(t, token, "example-records", "read") - require.NoError(t, err) - require.Equal(t, []string{"a", "b"}, grantIDs(grants)) - _, err = limited.issuer.verifyFor(context.Background(), token, "documents") - require.NoError(t, err, "a question does not spend a single-use capability") - - named, _ := limited.parent(t, "documents", hopScope("example-records", read, "a", "c")) - grants, err = limited.resources(t, named, "example-records", "read") - require.NoError(t, err) - require.Equal(t, []string{"a"}, grantIDs(grants)) - - _, err = limited.resources(t, named, "example-records", "write") - requireRefused(t, err, codes.PermissionDenied, "the capability holds no example-records:write") -} - -// Where a solution's roles decide, the answer is what those roles grant. -func TestListResourcesAnswersASolutionRolesGrants(t *testing.T) { - f := resourceFixture(t, nil, "example-records:read") - f.server.roles = fixedSolutionRoles{"documents": {kinds: []string{"example-records"}, held: []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"}, "x")}}} - token, _ := f.parent(t, "documents", hopScope("example-records", []string{"read"})) - grants, err := f.resources(t, token, "example-records", "read") - require.NoError(t, err) - require.Equal(t, []string{"x"}, grantIDs(grants)) -} - -// An empty ask carries a kind the person holds only on named resources, on -// exactly those resources; a person holding nothing there is refused by name. -func TestAnEmptyAskCarriesResourceLimitedGrantsOnTheirResources(t *testing.T) { - f := resourceFixture(t, map[string][]string{"example-records:read": {"a", "b"}}) - withHopMember(t) - caller := stampVerifiedIdentity(context.Background(), hopOwner, moduleWorkContextTenant, auth.Assurance{}) - held, err := f.issuer.personHoldings(caller, moduleWorkContextTenant, "documents") - require.NoError(t, err) - require.Len(t, held, 1) - require.Equal(t, "example-records", held[0].GetResourceKind()) - require.Equal(t, []string{"read"}, held[0].GetActions()) - require.Equal(t, []string{"a", "b"}, held[0].GetResourceIds()) - - none := resourceFixture(t, nil) - withHopMember(t) - _, err = none.issuer.personHoldings(caller, moduleWorkContextTenant, "documents") - requireRefused(t, err, codes.PermissionDenied, "the owner holds nothing at this audience") -} - -// An ask is met with what the person holds: an id they do not hold is dropped, -// a kind-wide ask they hold only on named resources keeps those; an asked -// action they hold on none of what it names is refused by name, even beside -// one they hold. -func TestAnAskIsMetWithWhatThePersonHolds(t *testing.T) { - f := resourceFixture(t, map[string][]string{"example-records:read": {"a", "b"}}) - f.holding.held["example-records:read"] = false - withHopMember(t) - f.issuer.authority = idHoldingAuthority{scopedAuthority: f.issuer.authority.(scopedAuthority), ids: map[string]bool{"example-records:read:a": true, "example-records:read:b": true}} - caller := stampVerifiedIdentity(context.Background(), hopOwner, moduleWorkContextTenant, auth.Assurance{}) - held, err := f.issuer.personHeldOfAsk(caller, moduleWorkContextTenant, "documents", []*gen.WorkContextScope{ - {ResourceKind: "example-records", Actions: []string{"read"}, ResourceIds: []string{"a", "z"}}, - }) - require.NoError(t, err) - require.Equal(t, []string{"a"}, held[0].GetResourceIds(), "the unheld id is dropped") - - held, err = f.issuer.personHeldOfAsk(caller, moduleWorkContextTenant, "documents", []*gen.WorkContextScope{{ResourceKind: "example-records", Actions: []string{"read"}}}) - require.NoError(t, err) - require.Equal(t, []string{"a", "b"}, held[0].GetResourceIds(), "a kind-wide ask keeps the held resources") - - _, err = f.issuer.personHeldOfAsk(caller, moduleWorkContextTenant, "documents", []*gen.WorkContextScope{ - {ResourceKind: "example-records", Actions: []string{"read", "write"}, ResourceIds: []string{"a"}}, - }) - requireRefused(t, err, codes.PermissionDenied, "beyond what the owner holds at documents: example-records:write") - _, err = f.issuer.personHeldOfAsk(caller, moduleWorkContextTenant, "documents", []*gen.WorkContextScope{ - {ResourceKind: "example-records", Actions: []string{"read"}, ResourceIds: []string{"z"}}, - }) - requireRefused(t, err, codes.PermissionDenied, "held on none of what the ask names") -} - -// idHoldingAuthority holds the owner to the kind:action:id triples it names -// for a permission naming a resource. -type idHoldingAuthority struct { - scopedAuthority - ids map[string]bool -} - -func (f idHoldingAuthority) ResolveWorkContextAuthority(ctx context.Context, org, owner, actor string, permissions []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { - for _, p := range permissions { - if p.ResourceID != "" && f.ids[p.ResourceKind+":"+p.Action+":"+p.ResourceID] { - continue - } - if !f.holdingAuthority.held[p.ResourceKind+":"+p.Action] { - return nil, business.NewStoreError(errors.New(p.ResourceKind+":"+p.Action+" is not held"), business.ErrTypePermission) - } - } - return f.holdingAuthority.workContextAuthorityFake.ResolveWorkContextAuthority(ctx, org, owner, actor, permissions) -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_solution_roles.go b/module/services/accounts/code/pkg/adapters/host_interface_solution_roles.go deleted file mode 100644 index 6d9d554c8..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_solution_roles.go +++ /dev/null @@ -1,227 +0,0 @@ -package adapters - -import ( - "context" - "slices" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - accountsauth "accounts/pkg/auth" - "accounts/pkg/business" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// A solution's roles decide what a person holds through it -// (business.PersonSolutionRoles): at the solution's audience, the person's -// roles' grants on its own kinds; at a module the solution reaches for them, -// those grants within the binding. Organisation roles answer only where no -// solution in the path declares roles. - -// solutionRoleReader answers what a person holds through a solution's roles. -type solutionRoleReader interface { - PersonSolutionRoles(ctx context.Context, orgID, personID, principal string) (*business.SolutionAuthority, []*basev0.WorkScopeV1, error) -} - -func (s *HostAuthorityServer) solutionRoles() solutionRoleReader { - if s.roles != nil { - return s.roles - } - return service -} - -// solutionPersonIssue issues a person's capability at a solution whose roles -// decide it: everything those roles grant on the solution's own kinds for an -// empty ask, an ask within it otherwise. -func (s *HostAuthorityServer) solutionPersonIssue(ctx context.Context, orgID, audience string, authority *business.SolutionAuthority, held []*basev0.WorkScopeV1, asked []*gen.WorkContextScope, ttl int32) (*gen.IssuedWorkContext, error) { - held = authority.Own(held) - if len(held) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "the person holds nothing at %s: no role of %s is theirs at its installation", audience, audience) - } - scopes := held - if len(asked) > 0 { - _, wanted, err := workContextScopes(asked, mintContentReads(audience)) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - for _, scope := range wanted { - if !workcontext.ScopeContained(scope, held) { - return nil, status.Errorf(codes.PermissionDenied, "the person's roles at %s do not grant %s", audience, scopeString(scope)) - } - } - scopes = wanted - } - return s.workContexts.startRoleTask(ctx, orgID, audience, scopes, ttl) -} - -// solutionBindingIssue issues a person's capability at a solution whose roles -// decide it, naming one of the solution's bindings: beside the roles' grants on -// the solution's own kinds it carries what those roles grant within the -// binding, as installed in the organisation, so the solution's hop through it -// is held to the person. An empty ask is all of that; an ask beyond it is -// refused by name, and a person whose roles grant nothing through the binding -// is refused for that. -func (s *HostAuthorityServer) solutionBindingIssue(ctx context.Context, orgID, audience, bindingID string, authority *business.SolutionAuthority, held []*basev0.WorkScopeV1, asked []*gen.WorkContextScope, ttl int32) (*gen.IssuedWorkContext, error) { - installed, err := service.ModuleOperationAudience(ctx, business.ModuleCaller{PrincipalID: business.ModulePrincipalID(audience), BoundOrg: orgID}, orgID, audience, bindingID) - if err != nil { - return nil, interfaceStatus(err) - } - invokes, err := hopOperationScopes(installed, bindingID, "invoke", installed.Audience) - if err != nil { - return nil, err - } - through := business.IntersectWorkScopes(held, invokes) - if len(through) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "the person holds nothing at %s through operation binding %q: no role of %s grants them what it carries", installed.Audience, bindingID, audience) - } - ceiling := append(authority.Own(held), through...) - scopes := ceiling - if len(asked) > 0 { - _, wanted, err := workContextScopes(asked, mintContentReads(audience)) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - for _, scope := range wanted { - if !workcontext.ScopeContained(scope, ceiling) { - return nil, status.Errorf(codes.PermissionDenied, "asked beyond what the person's roles at %s grant through operation binding %q: %s", audience, bindingID, scopeString(scope)) - } - } - scopes = wanted - } - return s.workContexts.startRoleTask(ctx, orgID, audience, scopes, ttl) -} - -// startRoleTask mints a person's capability whose scopes their solution roles -// were held to: the membership, revision and team attribution are the owner's -// own, read as every mint reads them, and the capability is sealed to the -// audience's installation, whose revision moves with its exposure. -func (s *WorkContextAuthorityServer) startRoleTask(ctx context.Context, orgID, audience string, scopes []*basev0.WorkScopeV1, ttl int32) (*gen.IssuedWorkContext, error) { - if _, confined := accountsauth.VerifiedResourceConfinement(ctx); confined { - return nil, status.Errorf(codes.PermissionDenied, "a resource-confined credential mints only within its consumer's ceiling, and %s's roles are no such ceiling", audience) - } - if err := requireVocabularyAudience(ctx, audience); err != nil { - return nil, err - } - ownerID, err := s.authorizeOwner(ctx, orgID) - if err != nil { - return nil, err - } - facts, err := s.resolveAuthority(ctx, orgID, ownerID, "", nil) - if err != nil { - return nil, err - } - installation, err := s.audienceInstallation(ctx, orgID, audience) - if err != nil { - return nil, err - } - token, signed, err := s.mint(ctx, mintRequest{ - Start: workcontext.StartInput{ - TenantID: orgID, OwnerPrincipalID: ownerID, OwnerPrincipalKind: business.PrincipalKindHuman, OrganizationID: orgID, - TaskID: uuid.NewString(), Audience: audience, AuthorityScopes: scopes, AttributionTeamIDs: facts.AttributionTeamIDs, - InstallationID: installation, - }, - Session: callerSession(ctx), Replay: workcontext.ReplayIdempotent, TTL: workContextTTL(ttl), - }) - if err != nil { - return nil, mapWorkContextError(ctx, err) - } - return issuedWorkContext(token, signed), nil -} - -// solutionCeiling is what the owner's roles grant through every solution on a -// hop's path (the acting module and each module actor before it), met across -// them; bounded is false when no solution on the path declares roles. -func (s *HostAuthorityServer) solutionCeiling(ctx context.Context, orgID, owner string, path []string) (ceiling []*basev0.WorkScopeV1, bounded bool, err error) { - seen := map[string]bool{} - for _, principal := range path { - if seen[principal] { - continue - } - seen[principal] = true - authority, held, err := s.solutionRoles().PersonSolutionRoles(ctx, orgID, owner, principal) - if err != nil { - return nil, false, interfaceStatus(err) - } - if authority == nil { - continue - } - if bounded { - ceiling = business.IntersectWorkScopes(ceiling, held) - } else { - ceiling = held - } - bounded = true - } - return ceiling, bounded, nil -} - -// solutionPath is the acting module and every module actor the parent names. -func solutionPath(parent *basev0.WorkContextV1, acting string) []string { - path := []string{acting} - for _, hop := range parent.GetActorChain() { - if hop.GetPrincipalKind() == business.PrincipalKindService { - path = append(path, hop.GetPrincipalId()) - } - } - return path -} - -// solutionHoldsThrough is what the owner holds at audience through a binding -// (through) when a solution's roles decide it: the roles of the solutions on -// the path within the binding, and, at a solution audience that declares -// roles, that solution's own grant as well. decided is false when organisation -// roles answer instead. -func (s *HostAuthorityServer) solutionHoldsThrough(ctx context.Context, parent *basev0.WorkContextV1, acting, audience string, through []*basev0.WorkScopeV1) (held []*basev0.WorkScopeV1, decided bool, err error) { - tenant, owner := parent.GetTenantId(), parent.GetOwnerPrincipalId() - ceiling, bounded, err := s.solutionCeiling(ctx, tenant, owner, solutionPath(parent, acting)) - if err != nil { - return nil, false, err - } - target, targetHeld, err := s.solutionRoles().PersonSolutionRoles(ctx, tenant, owner, audience) - if err != nil { - return nil, false, interfaceStatus(err) - } - if !bounded && target == nil { - return nil, false, nil - } - held = slices.Clone(through) - if bounded { - held = business.IntersectWorkScopes(held, ceiling) - } - if target != nil { - held = business.IntersectWorkScopes(held, target.Own(targetHeld)) - } - return held, true, nil -} - -// solutionParentStands is requireParentStands for an owner-only parent issued -// at a solution whose roles decided it: what it carries must still be what the -// owner's roles grant there, at the owner's current revision. decided is false -// for any other parent. -func (s *HostAuthorityServer) solutionParentStands(ctx context.Context, parent *basev0.WorkContextV1) (decided bool, err error) { - if len(parent.GetActorChain()) > 0 { - return false, nil - } - tenant, owner := parent.GetTenantId(), parent.GetOwnerPrincipalId() - authority, held, err := s.solutionRoles().PersonSolutionRoles(ctx, tenant, owner, parent.GetAudience()) - if err != nil { - return true, interfaceStatus(err) - } - if authority == nil { - return false, nil - } - if !workcontext.ScopesAttenuate(parent.GetAuthorityScopes(), authority.Own(held)) { - return true, status.Errorf(codes.PermissionDenied, "the parent carries more than %s's roles at %s now grant", owner, parent.GetAudience()) - } - facts, err := s.workContexts.resolveAuthority(ctx, tenant, owner, "", nil) - if err != nil { - return true, err - } - if facts.EffectiveRevision() != parent.GetAuthorizationRevision() { - return true, status.Error(codes.FailedPrecondition, "parent Work Context authorization revision is stale") - } - return true, nil -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_solution_roles_test.go b/module/services/accounts/code/pkg/adapters/host_interface_solution_roles_test.go deleted file mode 100644 index e8c5c7751..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_solution_roles_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package adapters - -import ( - "context" - "testing" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - - "accounts/pkg/auth" - "accounts/pkg/business" - "accounts/pkg/business/moduleregistrytest" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// fixedSolutionRoles answers, for each solution principal it names, the -// solution's own kinds and what the owner holds through its roles; any other -// principal is not a solution whose roles decide it. -type fixedSolutionRoles map[string]struct { - kinds []string - held []*basev0.WorkScopeV1 -} - -func (f fixedSolutionRoles) PersonSolutionRoles(_ context.Context, _, _, principal string) (*business.SolutionAuthority, []*basev0.WorkScopeV1, error) { - solution, ok := f[principal] - if !ok { - return nil, nil, nil - } - return &business.SolutionAuthority{Principal: principal, Kinds: solution.kinds}, solution.held, nil -} - -// The live refusal: a solution acting for a person through its binding to a -// module carries what the person's role at the solution grants there, although -// the person holds nothing at that module through organisation roles. -func TestASolutionsHopCarriesWhatThePersonsSolutionRoleGrants(t *testing.T) { - f := newHopFixture(t) // organisation roles grant nothing - f.server.roles = fixedSolutionRoles{"tools": {kinds: []string{"tool.items"}, held: []*basev0.WorkScopeV1{ - hopScope("tool.items", []string{"read"}), hopScope("example-records", []string{"read"}), - }}} - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - out, err := f.hop(parent, f.tool, "records", "documents", ask("example-records", []string{"read"})) - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}, claims(t, out).GetActorChain()[0].GetGrantedScopes()) - - // The binding still bounds it: invoke is in the binding, not in the role. - _, err = f.hop(parent, f.tool, "records", "documents", ask("example-records", []string{"invoke"})) - requireRefused(t, err, codes.PermissionDenied, "widens what its owner holds") -} - -// Where a solution's roles decide, organisation roles do not add to them. -func TestASolutionsHopIsNotWidenedByOrganisationRoles(t *testing.T) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - f.server.roles = fixedSolutionRoles{"tools": {kinds: []string{"tool.items"}, held: []*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"})}}} - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - _, err := f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "holds nothing at documents through operation binding \"records\"") -} - -// A parent issued at a solution stands only while its roles still grant what -// it carries. -func TestASolutionParentCarryingMoreThanItsRolesNowGrantIsRefused(t *testing.T) { - f := newHopFixture(t) - f.server.roles = fixedSolutionRoles{"tools": {kinds: []string{"tool.items"}, held: []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}}} - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - _, err := f.hop(parent, f.tool, "records", "documents") - requireRefused(t, err, codes.PermissionDenied, "now grant") -} - -// A person is issued at a solution what their roles there grant on its own -// kinds, and refused what they do not. -func TestAPersonAtASolutionHoldsTheirSolutionRolesGrants(t *testing.T) { - _, server, _ := hostChildFixture(t) - server.roles = fixedSolutionRoles{"documents": {kinds: []string{"documents"}, held: []*basev0.WorkScopeV1{ - hopScope("documents", []string{"read"}), hopScope("example-records", []string{"read"}), - }}} - caller := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, auth.Assurance{}) - issue := func(scopes ...*authorityv1.WorkScopeV1) (*authorityv1.IssueResponse, error) { - return server.Issue(caller, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents", Scopes: scopes}) - } - out, err := issue() - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("documents", []string{"read"})}, claims(t, out).GetAuthorityScopes()) - _, err = issue(ask("documents", []string{"write"})) - requireRefused(t, err, codes.PermissionDenied, "do not grant documents:write") - - server.roles = fixedSolutionRoles{"documents": {kinds: []string{"documents"}}} - _, err = issue() - requireRefused(t, err, codes.PermissionDenied, "the person holds nothing at documents") -} - -// An audience the organisation never installed is a failed precondition, named, -// for a person whether a solution's roles or the organisation's would decide -// what they hold there: never that they hold nothing. -func TestAPersonAtAnUninstalledAudienceIsAFailedPrecondition(t *testing.T) { - issuer, server, _ := hostChildFixture(t) - issuer.seals.(*sealAnswersFake).uninstalled["documents"] = true - caller := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, auth.Assurance{}) - for name, roles := range map[string]solutionRoleReader{ - // What PersonSolutionRoles answers for a solution the organisation - // never installed: its declaration, and nothing held through it. - "a solution's roles": fixedSolutionRoles{"documents": {kinds: []string{"documents"}}}, - "organisation roles": fixedSolutionRoles{}, - } { - t.Run(name, func(t *testing.T) { - server.roles = roles - _, err := server.Issue(caller, &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents"}) - requireRefused(t, err, codes.FailedPrecondition, `audience "documents" is not installed in this organization, so there is no installation to seal the capability to`) - }) - } -} - -// A person issued at a solution through one of its bindings carries what the -// solution's roles grant them within that binding: an ask within it is issued -// as asked, an ask beyond it is refused by name, and a person whose roles -// grant nothing through it is refused for that. -func TestAPersonAtASolutionThroughItsBindingCarriesTheirRolesWithinIt(t *testing.T) { - f := newHopFixture(t) - withHopMember(t) - caller := stampVerifiedIdentity(context.Background(), hopOwner, moduleWorkContextTenant, auth.Assurance{}) - issue := func(scopes ...*authorityv1.WorkScopeV1) (*authorityv1.IssueResponse, error) { - return f.server.Issue(caller, &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "tools", OperationBindingId: "records", Scopes: scopes, - }) - } - f.server.roles = fixedSolutionRoles{"tools": {kinds: []string{"tool.items"}, held: []*basev0.WorkScopeV1{ - hopScope("tool.items", []string{"read"}), hopScope("example-records", []string{"read", "write"}), - }}} - out, err := issue(ask("example-records", []string{"read"})) - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("example-records", []string{"read"})}, claims(t, out).GetAuthorityScopes()) - - out, err = issue() - require.NoError(t, err) - requireScopes(t, []*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"}), hopScope("example-records", []string{"read"})}, claims(t, out).GetAuthorityScopes()) - - _, err = issue(ask("example-records", []string{"write"})) - requireRefused(t, err, codes.PermissionDenied, "beyond what the person's roles at tools grant") - - f.server.roles = fixedSolutionRoles{"tools": {kinds: []string{"tool.items"}, held: []*basev0.WorkScopeV1{hopScope("tool.items", []string{"read"})}}} - _, err = issue() - requireRefused(t, err, codes.PermissionDenied, "holds nothing at documents through operation binding \"records\"") -} - -// withHopMember recomposes the hop fixture's principals over a store in which -// the person is a member of the pinned organisation holding no organisation -// role, as an issue through the interface reads them. -func withHopMember(t *testing.T) { - t.Helper() - svc, err := business.NewService(hopMemberStore{recordingModuleStore: &recordingModuleStore{tenantExists: true}}) - require.NoError(t, err) - registry, err := moduleregistrytest.Parse(hopPrincipals) - require.NoError(t, err) - svc.SetModuleAuthorityReads(currentModuleAuthority{service: svc}, nil) - svc.SetModuleCapabilities(nil, nil, registry) - bindModuleExecution(t, svc, registry) - WithService(svc) -} - -// hopMemberStore is the hop fixture's store with the person a member of the -// pinned organisation and no organisation role defined. -type hopMemberStore struct{ *recordingModuleStore } - -func (hopMemberStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { - return fn(ctx) -} - -func (hopMemberStore) ListRoles(context.Context, string) ([]*gen.Role, error) { return nil, nil } - -func (hopMemberStore) GetOrgMembership(_ context.Context, orgID, userID string) (*gen.OrgMembership, error) { - if orgID == moduleWorkContextTenant && userID == hopOwner { - return &gen.OrgMembership{UserId: userID, Role: gen.OrgRole_ORG_ROLE_MEMBER}, nil - } - return nil, nil -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_test.go b/module/services/accounts/code/pkg/adapters/host_interface_test.go deleted file mode 100644 index 519cdbf87..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package adapters - -import ( - "context" - "fmt" - "strings" - "testing" - - "accounts/pkg/auth" - "accounts/pkg/business" - "accounts/pkg/business/executiontest" - authorityv1 "accounts/pkg/gen/host/authority/v1" - sessionv1 "accounts/pkg/gen/host/session/v1" - gen "accounts/pkg/gen/saas/accounts/v1" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// hostChildFixture is a host whose one composed module, tool.test, runs as a -// workload the cluster attests: the issuer, the interface over it and the -// module's credential. -func hostChildFixture(t *testing.T) (*WorkContextAuthorityServer, *HostAuthorityServer, string) { - t.Helper() - previous := service - svc, err := business.NewService(renewMembershipStore{}) - require.NoError(t, err) - service = svc - t.Cleanup(func() { service = previous }) - cluster := executiontest.New() - svc.SetExecutionBinding(cluster) - workload := business.ModuleWorkload{Namespace: "example", ServiceAccount: "tool", Container: "app"} - svc.SetModulePrincipals(business.ModulePrincipalRegistry{ - business.ModulePrincipalID("tool.test"): {Prefix: "tool.test", Workloads: []business.ModuleWorkload{workload}}, - }) - declareTestAudiences(svc) - credential := cluster.Run(executiontest.Workload{Namespace: workload.Namespace, ServiceAccount: workload.ServiceAccount, Container: workload.Container}) - issuer := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, newSealAnswersFake(12)) - return issuer, NewHostAuthorityServer(issuer, nil), credential -} - -// A module hop names the binding it exercises; one naming none is refused by -// name, before the parent is verified, so a single-use parent is not spent. -func TestAHopNamingNoBindingIsRefusedUnspent(t *testing.T) { - issuer, server, credential := hostChildFixture(t) - parent, _ := mintTestCapability(t, issuer, testCapability{ - Audience: "tool.test", Tenant: renewOrgID, Owner: renewActorID, Replay: workcontext.ReplaySingleUse, - Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, - }) - out, err := server.Issue(context.Background(), &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, - Actor: &authorityv1.WorkloadCredential{Credential: credential}, Audience: "documents", - Scopes: []*authorityv1.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, - }) - require.Nil(t, out) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), "names the binding it exercises") - _, err = issuer.verifyFor(context.Background(), parent, "tool.test") - require.NoError(t, err) -} - -// A check naming a method holds it to the audience's delivered method policy. -// No module has one on this host yet (gap 4), so the interface's rule for that -// case applies: refused by name, or reported beside the verified capability -// when advisory. A capability that does not verify is refused either way. -func TestHostCheckAppliesTheMethodPolicyOrAdvisesIt(t *testing.T) { - issuer, server, _ := hostChildFixture(t) - token, _ := mintTestCapability(t, issuer, testCapability{ - Audience: "documents", Tenant: renewOrgID, Owner: renewActorID, - Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, - }) - ctx := context.Background() - - out, err := server.Check(ctx, &authorityv1.CheckRequest{Token: token, Audience: "documents"}) - require.NoError(t, err) - require.Empty(t, out.GetRefusal()) - - const method = "/example.v1.RecordService/GetRecord" - _, err = server.Check(ctx, &authorityv1.CheckRequest{Token: token, Audience: "documents", Method: method}) - require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), method) - - out, err = server.Check(ctx, &authorityv1.CheckRequest{Token: token, Audience: "documents", Method: method, Advisory: true}) - require.NoError(t, err) - require.Contains(t, out.GetRefusal(), method) - require.Equal(t, renewActorID, out.GetContext().GetOwnerPrincipalId(), "advisory answers the verified capability") - - _, err = server.Check(ctx, &authorityv1.CheckRequest{Token: token, Audience: "model", Method: method, Advisory: true}) - require.Equal(t, codes.Unauthenticated, status.Code(err), "advisory never passes a capability addressed elsewhere") -} - -// Current answers only a session: a call with no verified identity, or one -// whose credential names no session (an API key, a Work Context), is not one. -func TestHostCurrentRefusesWhatIsNotASession(t *testing.T) { - server := NewHostSessionServer(nil) - _, err := server.Current(context.Background(), &sessionv1.CurrentRequest{}) - require.Equal(t, codes.Unauthenticated, status.Code(err)) - sessionless := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) - _, err = server.Current(sessionless, &sessionv1.CurrentRequest{}) - require.Equal(t, codes.Unauthenticated, status.Code(err)) -} - -// The candidates of an empty ask are every action this host names on the kind -// at the audience, from the organisation's roles and the composition's -// bindings to that audience; wildcards and other audiences' bindings add none. -func TestEmptyAskCandidatesAreTheActionsTheHostNames(t *testing.T) { - previous := service - svc, err := business.NewService(renewMembershipStore{}) - require.NoError(t, err) - service = svc - t.Cleanup(func() { service = previous }) - svc.SetModulePrincipals(business.ModulePrincipalRegistry{ - business.ModulePrincipalID("reader"): {Prefix: "reader", OperationAudiences: map[string]business.ModuleOperationAudience{ - "records": {Audience: "records", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "example-records", Actions: []string{"invoke", "read"}}}}, - "elsewhere": {Audience: "model", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "example-records", Actions: []string{"train"}}}}, - }}, - }) - roles := []*gen.Role{ - {Permissions: []*gen.Permission{{Resource: "example-records", Action: "write"}, {Resource: "example-records", Action: "*"}, {Resource: "*", Action: "delete"}}}, - {Permissions: []*gen.Permission{{Resource: "other", Action: "export"}}}, - } - require.Equal(t, []string{"invoke", "read", "write"}, candidateActions("example-records", "records", roles)) - require.Empty(t, candidateActions("unnamed", "records", roles)) -} - -// The interface's installation names its target as the composition does — the -// route alias a frontend binds to ("notes") — never this host's presence row id; -// a row whose presence is gone keeps the id rather than an empty target. -func TestInstallationWireNamesItsTargetByName(t *testing.T) { - row := &gen.Installation{Id: "i", OrgId: "o", TargetId: "76f50dc4-6209-4a04-ae77-27eff13c110e", Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE} - if got := installationToWire(row, "notes").GetTarget(); got != "notes" { - t.Fatalf("target = %q, want the route alias", got) - } - if got := installationToWire(row, "").GetTarget(); got != row.TargetId { - t.Fatalf("target = %q, want the presence id when no alias is known", got) - } -} - -// Only an issue through a binding the acting module declares (a workload's, or -// a child's with the workload as actor) may leave its audience to the binding; -// every other shape that names no audience is refused by name. -func TestAnIssueWithNoAudienceIsAWorkloadThroughABinding(t *testing.T) { - for name, req := range map[string]*authorityv1.IssueRequest{ - "person": {As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, OperationBindingId: "documents"}, - "workload, no binding": {As: &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: "x"}}}, - "child": {As: &authorityv1.IssueRequest_ParentToken{ParentToken: "p"}, OperationBindingId: "documents"}, - } { - _, err := bindingAudience(context.Background(), req) - if status.Code(err) != codes.InvalidArgument || !strings.Contains(err.Error(), "names its audience") { - t.Errorf("%s: %v, want the audience refused by name", name, err) - } - } -} - -// An envelope write refused for want of the policy log's witness tells the -// writer why, as FailedPrecondition, instead of an internal error. -func TestAnUnwitnessedEnvelopeNarrowingIsRefusedByName(t *testing.T) { - err := moduleEnvelopeError(fmt.Errorf("cannot write the module envelope: %w", business.ErrPolicyLogUnreachable), "cannot write the module envelope") - if status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "policy log") { - t.Fatalf("got %v, want FailedPrecondition naming the policy log", err) - } -} - -// A refusal to expose an installation names the status that stops it. -func TestAnInstallationStatusIsNamedInItsRefusal(t *testing.T) { - for st, want := range map[gen.InstallationStatus]string{ - gen.InstallationStatus_INSTALLATION_STATUS_PENDING: "pending", - gen.InstallationStatus_INSTALLATION_STATUS_REVOKED: "revoked", - gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE: "active", - gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED: "not active", - } { - if got := installationStatusWord(st); got != want { - t.Errorf("%v names %q, want %q", st, got, want) - } - } -} diff --git a/module/services/accounts/code/pkg/adapters/host_interface_workload.go b/module/services/accounts/code/pkg/adapters/host_interface_workload.go deleted file mode 100644 index 0de3ac05a..000000000 --- a/module/services/accounts/code/pkg/adapters/host_interface_workload.go +++ /dev/null @@ -1,153 +0,0 @@ -package adapters - -// A workload's own issue behind the host interface: the module proves which -// module it is by its credential alone, and is issued either the host's -// operation context through a binding it declares (the headless mint) or its -// own context to this surface. The ask is honoured below what the workload is -// entitled to and never above it: scopes, operation and lifetime narrow the -// binding's grant, an audience other than the binding's is refused by name, -// and anything outside the grant is refused rather than issued. - -import ( - "context" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/workcontext" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "accounts/pkg/business" - authorityv1 "accounts/pkg/gen/host/authority/v1" - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// workloadIssue authenticates the workload, then issues through the binding -// req names, or the module's own context when it names none. -func (s *HostAuthorityServer) workloadIssue(ctx context.Context, credential string, req *authorityv1.IssueRequest, ttl int32) (*gen.IssuedWorkContext, error) { - prefix, err := service.ModulePrefixOfWorkload(ctx, credential) - if err != nil { - return nil, credentialRefusal(err, "workload") - } - binding := req.GetOperationBindingId() - if binding == "" { - return s.workloadOwnContext(ctx, prefix, credential, req, ttl) - } - if err := requireComposed(req.GetAudience()); err != nil { - return nil, err - } - declared, ok := service.ModulePrincipals()[business.ModulePrincipalID(prefix)].OperationAudiences[binding] - if !ok { - return nil, status.Errorf(codes.NotFound, "operation binding %q is not declared by %s", binding, prefix) - } - if declared.Audience != req.GetAudience() { - return nil, status.Errorf(codes.PermissionDenied, "operation binding %q is addressed to %s, not %s", binding, declared.Audience, req.GetAudience()) - } - lookup := false - switch operation := req.GetOperation(); operation { - case "", "headless": - case "lookup": - if len(declared.LookupScopes) == 0 { - return nil, status.Errorf(codes.PermissionDenied, "operation binding %q declares no lookup", binding) - } - lookup = true - case "invoke": - return nil, status.Errorf(codes.InvalidArgument, "a workload acts as itself: invoke is a person's operation on binding %q; a workload's are headless and lookup", binding) - default: - return nil, status.Errorf(codes.InvalidArgument, "operation %q is none of invoke, headless and lookup", operation) - } - narrow := func(authority business.ModuleOperationContextAuthority) (business.ModuleOperationContextAuthority, error) { - return narrowOperationContext(authority, declared, lookup, req.GetScopes(), workContextTTL(ttl)) - } - out, err := askingModuleCapabilities(s.modules, moduleMintAsk{narrow: narrow}).MintModuleOperationContext(ctx, &gen.ModuleMintOperationContextRequest{ - Prefix: prefix, ServiceAccountToken: credential, Binding: binding, OrganizationId: req.GetOrganizationId(), - }) - if err != nil { - return nil, err - } - return &gen.IssuedWorkContext{Token: out.GetToken(), ExpiresAt: out.GetExpiresAt()}, nil -} - -// workloadOwnContext is the module's own context to this surface: its identity -// here, which carries no scopes and names no operation (what it may do is -// re-read on every call), so an ask for either is refused by name. -func (s *HostAuthorityServer) workloadOwnContext(ctx context.Context, prefix, credential string, req *authorityv1.IssueRequest, ttl int32) (*gen.IssuedWorkContext, error) { - if req.GetAudience() != ModuleWorkContextAudience { - return nil, status.Errorf(codes.InvalidArgument, "a module's own context is addressed to %q, not %q", ModuleWorkContextAudience, req.GetAudience()) - } - if len(req.GetScopes()) > 0 { - return nil, status.Errorf(codes.InvalidArgument, "%s's own context carries no scopes: it is the module's identity to this host, and what it may do is re-read on every call", prefix) - } - if operation := req.GetOperation(); operation != "" { - return nil, status.Errorf(codes.InvalidArgument, "%s's own context names no operation; %q is one of a binding's", prefix, operation) - } - out, err := askingModuleCapabilities(s.modules, moduleMintAsk{ttl: workContextTTL(ttl)}).MintModuleWorkContext(ctx, &gen.ModuleMintWorkContextRequest{ - Prefix: prefix, ServiceAccountToken: credential, OrganizationId: req.GetOrganizationId(), - }) - if err != nil { - return nil, err - } - return &gen.IssuedWorkContext{Token: out.GetToken(), ExpiresAt: out.GetExpiresAt()}, nil -} - -// narrowOperationContext holds a resolved headless operation context to the -// ask. The ceiling is the installed binding's headless grant, or for a lookup -// the binding's lookup scopes within it; an empty ask is the whole ceiling, an -// ask outside it is refused naming the scope, and a lifetime only shortens. -func narrowOperationContext( - authority business.ModuleOperationContextAuthority, declared business.ModuleOperationAudience, - lookup bool, asked []*authorityv1.WorkScopeV1, ttl time.Duration, -) (business.ModuleOperationContextAuthority, error) { - reads := mintContentReads(authority.Audience) - _, ceiling, err := workContextScopes(authority.WireScopes(), reads) - if err != nil { - return authority, status.Error(codes.Internal, err.Error()) - } - operation := "headless" - if lookup { - operation = "lookup" - _, lookups, err := workContextScopes(declared.WireScopes(true), reads) - if err != nil { - return authority, status.Error(codes.Internal, err.Error()) - } - ceiling = withinScopes(lookups, ceiling) - if len(ceiling) == 0 { - return authority, status.Errorf(codes.PermissionDenied, "operation binding %q's lookup lies outside what it grants headless", authority.BindingID) - } - } - scopes := ceiling - if len(asked) > 0 { - _, wanted, err := workContextScopes(scopesToHost(asked), reads) - if err != nil { - return authority, status.Error(codes.InvalidArgument, err.Error()) - } - for _, scope := range wanted { - if !workcontext.ScopeContained(scope, ceiling) { - return authority, status.Errorf(codes.PermissionDenied, "a workload's issue widens what operation binding %q grants it for %s: %s", authority.BindingID, operation, scopeString(scope)) - } - } - scopes = wanted - } - authority.Scopes = operationScopesFromCore(scopes) - authority.TTL = ttl - return authority, nil -} - -// withinScopes is each scope of want held to the first scope of its kind in -// bound, as core reads a set; a kind bound does not carry, or one left with -// nothing in common, is dropped. -func withinScopes(want, bound []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - var out []*basev0.WorkScopeV1 - for _, scope := range want { - for _, limit := range bound { - if limit.GetResourceKind() != scope.GetResourceKind() { - continue - } - if kept := intersectScope(limit, scope); kept != nil { - out = append(out, kept) - } - break - } - } - return out -} diff --git a/module/services/accounts/code/pkg/adapters/impersonation_method_restriction_test.go b/module/services/accounts/code/pkg/adapters/impersonation_method_restriction_test.go index b8ac04d56..d113b16fe 100644 --- a/module/services/accounts/code/pkg/adapters/impersonation_method_restriction_test.go +++ b/module/services/accounts/code/pkg/adapters/impersonation_method_restriction_test.go @@ -99,7 +99,7 @@ func TestRestrictedProceduresAreDeniedOnlyWhileImpersonating(t *testing.T) { withoutCentralEnforcement(t) restricted := restrictedProcedures(t) - require.Len(t, restricted, 65, "the declared restriction set changed; confirm it against AUTHZ_MATRIX.md") // +8: Issue, Delegate, RevokeDelegation, GetDelegation, Install, GrantToTeam, List, Current of the implemented interfaces; Check and ListResources are public, authenticated by the token they check + require.Len(t, restricted, 68, "the declared restriction set changed; confirm it against AUTHZ_MATRIX.md") // +11: Issue, Delegate, RevokeDelegation, GetDelegation, Install, Review, GrantRole, RevokeRole, ListRoleGrants, List, Current of the implemented interfaces; Check and ListResources are public, authenticated by the token they check for _, procedure := range restricted { t.Run(procedure, func(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/installation_exposure_rpcs_test.go b/module/services/accounts/code/pkg/adapters/installation_exposure_rpcs_test.go index 180758e30..36c7d342c 100644 --- a/module/services/accounts/code/pkg/adapters/installation_exposure_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_exposure_rpcs_test.go @@ -84,8 +84,6 @@ func TestInstallationExposureErrorMapping(t *testing.T) { {"unknown installation", business.NewStoreError(errors.New("installation x is not an active installation in this organization"), business.ErrTypeNotFound), codes.NotFound}, {"team outside organization", fmt.Errorf("cannot expose installation to team: %w", business.ErrSubjectOutsideOrganization), codes.PermissionDenied}, {"role exposes nothing", business.NewStoreError(business.ErrRoleDoesNotExposeSolution, business.ErrTypeValidation), codes.InvalidArgument}, - // The host's own refusal of an unwitnessed narrowing keeps its words. - {"unwitnessed narrowing", fmt.Errorf("cannot revoke scope: %w: this host has no policy log, so authority cannot be narrowed", business.ErrPolicyLogUnreachable), codes.FailedPrecondition}, {"store status", fmt.Errorf("cannot grant scope: %w", status.Error(codes.FailedPrecondition, `role "r" is not a role in this org`)), codes.FailedPrecondition}, } for _, tc := range cases { diff --git a/module/services/accounts/code/pkg/adapters/installation_rpcs.go b/module/services/accounts/code/pkg/adapters/installation_rpcs.go index d599dd01d..f33cd802f 100644 --- a/module/services/accounts/code/pkg/adapters/installation_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/installation_rpcs.go @@ -320,8 +320,7 @@ func (s *InstallationServer) GrantInstallationToTeam( } // RevokeInstallationFromTeam removes a team's grant at an installation. It is a -// narrowing under the policy log; a host that cannot witness it refuses, and the -// refusal carries the host's reason. +// narrowing and applies directly. func (s *InstallationServer) RevokeInstallationFromTeam( ctx context.Context, req *gen.RevokeInstallationFromTeamRequest, ) (*emptypb.Empty, error) { @@ -345,9 +344,7 @@ func (s *InstallationServer) RevokeInstallationFromTeam( // mapInstallationExposureError maps the exposure RPCs' failures. Unlike // mapInstallationError it answers NotFound for an installation the caller's // organization does not have — another organization's installation id included, -// which RLS makes indistinguishable from one that never existed — and it names a -// narrowing the policy log could not witness as the refusal it is, in the host's -// words, rather than as an internal fault. +// which RLS makes indistinguishable from one that never existed. func mapInstallationExposureError(err error) error { if err == nil { return nil @@ -365,9 +362,6 @@ func mapInstallationExposureError(err error) error { return status.Error(codes.InvalidArgument, err.Error()) } } - if errors.Is(err, business.ErrPolicyLogUnreachable) { - return status.Error(codes.FailedPrecondition, err.Error()) - } if st, ok := status.FromError(err); ok && st.Code() != codes.Unknown { return status.Error(st.Code(), err.Error()) } @@ -391,9 +385,6 @@ func mapInstallationError(err error) error { return status.Error(codes.InvalidArgument, err.Error()) } } - if errors.Is(err, business.ErrPolicyLogUnreachable) { - return status.Error(codes.Unavailable, err.Error()) - } if st, ok := status.FromError(err); ok { return st.Err() } diff --git a/module/services/accounts/code/pkg/adapters/installation_scope_rpcs_test.go b/module/services/accounts/code/pkg/adapters/installation_scope_rpcs_test.go index 50f6546b3..5882a6ef4 100644 --- a/module/services/accounts/code/pkg/adapters/installation_scope_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_scope_rpcs_test.go @@ -1,7 +1,6 @@ package adapters import ( - "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" "context" "testing" @@ -34,7 +33,6 @@ func TestNarrowInstallationRequiresOrgAdminAndOwnOrganization(t *testing.T) { } func TestInstallationNarrowingErrorMapping(t *testing.T) { - require.Equal(t, codes.Unavailable, status.Code(mapInstallationError(business.ErrPolicyLogUnreachable))) require.Equal(t, codes.PermissionDenied, status.Code(mapInstallationError(status.Error(codes.PermissionDenied, "outside ceiling")))) } diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_test.go index 5d4986318..e08dc0f01 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_test.go @@ -69,17 +69,11 @@ func (s *recordingModuleStore) GetSourceDelegation(context.Context, string, stri return nil, nil } -// WithOrgTx and ModuleDelegations answer that this organization records no -// delegation to a module, which is what a hop asks of every delegation id its -// chain names: none of them is a stored delegation here. +// WithOrgTx runs fn in the organization's transaction. func (s *recordingModuleStore) WithOrgTx(ctx context.Context, _ string, fn func(ctx context.Context) error) error { return fn(ctx) } -func (s *recordingModuleStore) ModuleDelegations(context.Context, string, []string) ([]*business.ModuleDelegation, error) { - return nil, nil -} - func (s *recordingModuleStore) OrganizationIDExists(_ context.Context, id string) (bool, error) { s.tenantChecks++ s.tenantCheckedID = id diff --git a/module/services/accounts/code/pkg/adapters/permission_servers.go b/module/services/accounts/code/pkg/adapters/permission_servers.go index bb9cf09f6..3d1af7fb4 100644 --- a/module/services/accounts/code/pkg/adapters/permission_servers.go +++ b/module/services/accounts/code/pkg/adapters/permission_servers.go @@ -1,6 +1,10 @@ package adapters import ( + "context" + + "github.com/codefly-dev/core/wool" + "accounts/pkg/business" "accounts/pkg/permissionsplugin" ) @@ -55,4 +59,11 @@ func configurePermissionServerKeys() { Authority: authority, Seals: seals, }) + // The host interfaces are decided by the shared authority core, which signs + // with the same Work Context key Keys publishes. + if service != nil { + if err := service.ConfigureAuthorityCore(context.Background(), plugin.WorkContextIssuer(), plugin.WorkContextKeyID(), plugin.Ed25519Key()); err != nil { + wool.Get(context.Background()).In("configurePermissionServerKeys").Warn("the authority core is not configured; every host interface call answers Unavailable", wool.ErrField(err)) + } + } } diff --git a/module/services/accounts/code/pkg/adapters/platform_module_envelope_rpcs.go b/module/services/accounts/code/pkg/adapters/platform_module_envelope_rpcs.go index de426810a..6654f9a16 100644 --- a/module/services/accounts/code/pkg/adapters/platform_module_envelope_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/platform_module_envelope_rpcs.go @@ -161,13 +161,6 @@ func moduleEnvelopeError(err error, internal string) error { if errors.Is(err, business.ErrModuleEnvelopeUnavailable) { return status.Error(codes.FailedPrecondition, "this host has no module envelope store") } - // A write that changes or removes what a principal is already granted is - // a narrowing, which only a policy-log witness admits. The writer can act - // on that (add whole principals only, or run with a witness), so it is - // told, not handed an internal error. - if errors.Is(err, business.ErrPolicyLogUnreachable) { - return status.Error(codes.FailedPrecondition, "the envelope changes what an existing principal is granted, a narrowing this host admits only under the policy log's witness, and the policy log is unreachable") - } return status.Error(codes.Internal, internal) } diff --git a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go deleted file mode 100644 index 22086a1ee..000000000 --- a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go +++ /dev/null @@ -1,51 +0,0 @@ -package adapters - -import ( - "context" - "sync/atomic" -) - -// The policy log's serving gate, as the transports reach it. -// -// WHY A REGISTERED GATE RATHER THAN A SERVICE REFERENCE. The authorization -// interceptors are built by generated code (grpc_gen.go, connect_gen.go) which -// hands them a minter accessor and nothing else, and generated code is not -// hand-editable. The host registers the gate at boot the same way it registers -// its non-proto HTTP routes, and the interceptors call it through this file. -// -// WHY AN UNSET GATE ADMITS. Registration is boot wiring: a deployment with no -// policy log has nothing unreconciled to honour, which is exactly what -// Service.MayServe answers for a host with no log. The refusal that a host -// without a log owes is on the NARROWING path — it cannot reduce authority — -// and WithPolicyLoggedNarrowing makes it there, not here. Refusing every -// request instead would take a host out of service for a protocol it is not -// participating in. -var policyLogServingGate atomic.Pointer[func(context.Context) error] - -// RegisterPolicyLogServingGate installs the host's serving gate. -// -// Boot wiring, called once by the host before it listens. It takes the function -// rather than the service so this package keeps no handle on the service beyond -// the one question it asks. -func RegisterPolicyLogServingGate(gate func(ctx context.Context) error) { - if gate == nil { - policyLogServingGate.Store(nil) - return - } - policyLogServingGate.Store(&gate) -} - -// enforcePolicyLogServing refuses a call when the policy log says this host must -// not serve. -// -// It runs on every unary and streaming call of both transports, AFTER -// authorization rather than before it: an unauthenticated caller learns nothing -// about this host's reconciliation state, and the refusal reaches only callers -// that would otherwise have been served. -func enforcePolicyLogServing(ctx context.Context) error { - gate := policyLogServingGate.Load() - if gate == nil { - return nil - } - return (*gate)(ctx) -} diff --git a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go deleted file mode 100644 index b89be1ac5..000000000 --- a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package adapters - -import ( - "context" - "errors" - "go/ast" - "go/parser" - "go/token" - "testing" - - "github.com/stretchr/testify/require" -) - -// The gate is consulted, and it is consulted on every call both transports -// serve. -// -// Two halves, because the regression this file exists to catch has two shapes. -// One is a gate that is registered and then never asked — which is the state -// this whole protocol was in before: built, tested, and on no code path. The -// other is a gate asked on the unary path only, so a streaming call serves -// straight through a host that must not be serving. - -func TestARegisteredGateRefusesTheCall(t *testing.T) { - t.Cleanup(func() { RegisterPolicyLogServingGate(nil) }) - - refusal := errors.New("not serving") - var asked int - RegisterPolicyLogServingGate(func(context.Context) error { - asked++ - return refusal - }) - - require.ErrorIs(t, enforcePolicyLogServing(context.Background()), refusal) - require.Equal(t, 1, asked, "the gate must be asked, not assumed") -} - -func TestNoRegisteredGateAdmits(t *testing.T) { - t.Cleanup(func() { RegisterPolicyLogServingGate(nil) }) - RegisterPolicyLogServingGate(nil) - - // A deployment with no policy log has narrowed nothing through the - // protocol, so it has nothing unreconciled to honour. The refusal such a - // host owes is on the narrowing path, not here. - require.NoError(t, enforcePolicyLogServing(context.Background())) -} - -// Every authorization path of both transports asks the gate. -// -// A source check rather than four transport round trips, because what can -// regress is a missing CALL: the interceptors are four nearly identical -// sequences of enforce* steps, and a step dropped from one of them is invisible -// to any test that drives the other three. The four functions are named -// explicitly so that a NEW interceptor path does not silently inherit a pass. -func TestBothTransportsAskTheGateOnEveryPath(t *testing.T) { - want := map[string]string{ - "grpc_auth_interceptor.go:grpcAuthInterceptor": "unary gRPC", - "grpc_auth_interceptor.go:grpcStreamAuthInterceptor": "streaming gRPC", - "connect_auth_interceptor.go:WrapUnary": "unary Connect", - "connect_auth_interceptor.go:WrapStreamingHandler": "streaming Connect", - } - found := map[string]bool{} - - for _, file := range []string{"grpc_auth_interceptor.go", "connect_auth_interceptor.go"} { - fileSet := token.NewFileSet() - parsed, err := parser.ParseFile(fileSet, file, nil, 0) - require.NoError(t, err) - for _, decl := range parsed.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - key := file + ":" + fn.Name.Name - ast.Inspect(fn, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "enforcePolicyLogServing" { - found[key] = true - } - return true - }) - } - } - - for key, what := range want { - require.True(t, found[key], - "%s (%s) does not consult the policy log serving gate, so that path serves a host that must not be serving", - key, what) - } -} diff --git a/module/services/accounts/code/pkg/adapters/refusals.go b/module/services/accounts/code/pkg/adapters/refusals.go index 5caecb08a..53aa6c5fd 100644 --- a/module/services/accounts/code/pkg/adapters/refusals.go +++ b/module/services/accounts/code/pkg/adapters/refusals.go @@ -52,7 +52,6 @@ var businessRefusals = []struct { {business.ErrImpersonationTargetInactive, codes.FailedPrecondition}, {business.ErrPublicOriginUnavailable, codes.FailedPrecondition}, {business.ErrPrivacyWorkflowUnavailable, codes.FailedPrecondition}, - {business.ErrPolicyLogUnreachable, codes.FailedPrecondition}, {business.ErrDatasourceSourceNotFound, codes.NotFound}, {business.ErrDatasourceSyncNotFound, codes.NotFound}, // The source moved under the editor. FailedPrecondition and not Aborted: diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_http.go b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go index ae3956221..324d15cee 100644 --- a/module/services/accounts/code/pkg/adapters/solution_delivery_http.go +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go @@ -216,8 +216,7 @@ func (h *SolutionDeliveryHTTPHandler) serveApplied(w http.ResponseWriter, r *htt // 503 cannot verify RETRYABLE — the host could not reach the API // server, so nothing was checked // 503 not completed RETRYABLE — e.g. the committed envelope moved -// during admission, or a delivered narrowing found -// no policy log to witness it (named) +// during admission func solutionDeliveryStatus(err error) (int, string) { switch { case errors.Is(err, business.ErrSolutionDeliveryMalformed): diff --git a/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go b/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go index 1ad8b0fdb..46addd840 100644 --- a/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go +++ b/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go @@ -421,7 +421,10 @@ func sourcePreparedBinding(t *testing.T, method, address, source string) *runnab t.Helper() pkg, spec, err := runnable.PackageFromMethod(protoregistry.GlobalFiles, &resources.RunnableLocation{Identity: &resources.RunnableIdentity{Name: "invoke-source", Module: "saas-starter", Workspace: "example", Version: "0.1.0"}, WorkspacePath: t.TempDir(), RelativeToWorkspace: "modules/saas-starter"}, runnable.ServiceOwner{Module: "saas-starter", Service: "accounts", Endpoint: "connect", Agent: &basev0.Agent{Kind: basev0.Agent_SERVICE, Name: "go-grpc", Publisher: "codefly.dev", Version: "0.1.53"}}, method) require.NoError(t, err) - require.Nil(t, spec.Tool) + // Core derives the operation's tool exposure from the method itself (core + // b23dc135, which the authority core requires): the tool calls exactly it. + require.NotNil(t, spec.Tool) + require.Equal(t, method[strings.LastIndex(method, "/")+1:], spec.Tool.GetName()) scoped, err := spec.ResolveScopeSlots([]*runnablev0.ScopeSelection{{Slot: "source", Invoke: []*basev0.WorkScopeV1{{ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{source}}}, Lookup: []*basev0.WorkScopeV1{{ResourceKind: "datasource.sources", Actions: []string{"read"}, ResourceIds: []string{source}}}}}) require.NoError(t, err) encoded, err := runnable.EncodePrepared(&runnablev0.PreparedBinding{Operation: &runnablev0.PreparedOperation{Module: "saas-starter", Service: "accounts", Endpoint: "connect", Spelling: method}, Call: &runnablev0.PreparedCall{Address: address, Route: &runnablev0.PreparedCall_Connect{Connect: &runnablev0.ConnectProcedure{Procedure: method}}}, Contract: pkg.Contract, Policy: scoped.Policy()}) diff --git a/module/services/accounts/code/pkg/adapters/testdata/host-interface-conformance.yaml b/module/services/accounts/code/pkg/adapters/testdata/host-interface-conformance.yaml index 96877ec4a..e7ec4f9e4 100644 --- a/module/services/accounts/code/pkg/adapters/testdata/host-interface-conformance.yaml +++ b/module/services/accounts/code/pkg/adapters/testdata/host-interface-conformance.yaml @@ -18,6 +18,21 @@ users: # no role and is on no team. - {id: 00000000-0000-7000-8000-0000000000b7, email: owner@acme.test, name: Olivia Owner, role: member, provider: email, provider_id: fixture-owner} - {id: 00000000-0000-7000-8000-0000000000b8, email: no-roles@acme.test, name: Nora Noroles, role: member, provider: email, provider_id: fixture-no-roles} + # The installer of two solutions who administers one existing collection + # through her own organization grant, and nothing else: no administrator. + - {id: 00000000-0000-7000-8000-0000000000b9, email: ivy@acme.test, name: Ivy Installer, role: member, provider: email, provider_id: fixture-ivy} + # The solution-principal target's people: two whose own organization grants + # reach one existing resource of the store's kind (one administers it, one + # reads it), an installer holding nothing yet, two members of a team no + # installation is granted to (one leaves it), one who leaves the + # organization, and a reader of one solution's notes. + - {id: 00000000-0000-7000-8000-0000000000d1, email: store-admin@acme.test, name: Sam Storeadmin, role: member, provider: email, provider_id: fixture-store-administrator} + - {id: 00000000-0000-7000-8000-0000000000d2, email: store-reader@acme.test, name: Rita Storereader, role: member, provider: email, provider_id: fixture-store-reader} + - {id: 00000000-0000-7000-8000-0000000000d3, email: installer@acme.test, name: Ian Installer, role: member, provider: email, provider_id: fixture-principal-installer} + - {id: 00000000-0000-7000-8000-0000000000d4, email: team-member@acme.test, name: Tom Teammember, role: member, provider: email, provider_id: fixture-principal-team-member} + - {id: 00000000-0000-7000-8000-0000000000d5, email: team-leaver@acme.test, name: Tess Teamleaver, role: member, provider: email, provider_id: fixture-principal-team-leaver} + - {id: 00000000-0000-7000-8000-0000000000d6, email: org-leaver@acme.test, name: Otto Orgleaver, role: member, provider: email, provider_id: fixture-principal-org-leaver} + - {id: 00000000-0000-7000-8000-0000000000d7, email: note-reader@acme.test, name: Nina Notereader, role: member, provider: email, provider_id: fixture-principal-note-reader} organizations: - id: 00000000-0000-7000-8000-0000000000c1 @@ -32,6 +47,14 @@ organizations: - {email: dev@acme.test, role: admin} - {email: owner@acme.test, role: owner} - {email: no-roles@acme.test, role: member} + - {email: ivy@acme.test, role: member} + - {email: store-admin@acme.test, role: member} + - {email: store-reader@acme.test, role: member} + - {email: installer@acme.test, role: member} + - {email: team-member@acme.test, role: member} + - {email: team-leaver@acme.test, role: member} + - {email: org-leaver@acme.test, role: member} + - {email: note-reader@acme.test, role: member} - id: 00000000-0000-7000-8000-0000000000c2 name: Globex owner: zoe@globex.test @@ -45,6 +68,8 @@ teams: # moves the organisation's authority and grants nobody anything. - {name: Unexposed, org: Acme Corp, members: []} - {name: Ops, org: Globex, members: [zoe@globex.test, omar@globex.test]} + # No installation is granted to it until the target grants one. + - {name: Principal team, org: Acme Corp, members: [team-member@acme.test, team-leaver@acme.test]} roles: - org: Acme Corp @@ -52,6 +77,15 @@ roles: description: Read collection content in explicitly granted collections permissions: - {resource: example-records, action: read} + # The suite's viewer reads example records and directory entries across the + # organization: a role scoped to nothing holds the whole kind, where a + # collection's or a record's grant holds only what it names. + - org: Acme Corp + name: Record viewer + description: Read every example record and directory entry of the organization + permissions: + - {resource: example-records, action: read} + - {resource: directory.entry, action: read} # A record reader holds example records one id at a time (a resource-limited # grant): what ListResources answers by grant. - org: Acme Corp @@ -67,8 +101,38 @@ roles: permissions: - {resource: conformance-agent.conversation, action: read} + # The transfer seam reads a person's own organization grant on one existing + # resource, never a solution's owner-selected scope: Ivy administers + # collection-x alone. + - org: Acme Corp + name: Collection transfer + description: Administer the one collection granted to them by id + permissions: + - {resource: conformance.collection, action: admin} + + - org: Acme Corp + name: Principal store admin + description: Administer the one existing collection granted to them by id + permissions: + - {resource: principal.collection, action: admin} + - org: Acme Corp + name: Principal store reader + description: Read the one existing collection granted to them by id + permissions: + - {resource: principal.collection, action: read} + - org: Acme Corp + name: Principal note reader + description: Read the notes of one solution's principal + permissions: + - {resource: principal-a.note, action: read} + assignments: + - {org: Acme Corp, role: Principal store admin, email: store-admin@acme.test, scope: store-x} + - {org: Acme Corp, role: Principal store reader, email: store-reader@acme.test, scope: store-x} + - {org: Acme Corp, role: Principal note reader, email: note-reader@acme.test} - {org: Acme Corp, role: Record reader, email: maya@acme.test, scope: conformance-record-a} + - {org: Acme Corp, role: Collection transfer, email: ivy@acme.test, scope: collection-x} + - {org: Acme Corp, role: Record viewer, email: bob@acme.com} - {org: Acme Corp, role: Agent user, email: maya@acme.test} - {org: Acme Corp, role: Agent user, email: remi@acme.test} diff --git a/module/services/accounts/code/pkg/adapters/work_context_module_chain.go b/module/services/accounts/code/pkg/adapters/work_context_module_chain.go new file mode 100644 index 000000000..dbb7ffded --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_module_chain.go @@ -0,0 +1,79 @@ +package adapters + +// The native Work Context authority's re-read of a module chain: every module +// hop a capability carries is held to the binding it hopped through, and a +// module principal is stored under its registry id. + +import ( + "context" + "errors" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/wool" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/pkg/business" +) + +// storedPrincipal is the principal as storage holds it: a module a capability +// names by its name is stored under its registry id (the identity columns and +// grant subjects are ids), and every other principal is itself. +func storedPrincipal(principal string) string { + if service != nil { + if id, isModule := service.ModuleRegistryID(principal); isModule { + return id + } + } + return principal +} + +// requireModuleChainStands re-reads every module hop of a capability against +// the binding it hopped through (business.ConfirmModuleChain): core re-reads +// only the binding the capability seals, the last hop's, so a binding narrowed +// or withdrawn upstream would otherwise go unnoticed until the child expired. +// +// Each hop is held to the exact scope set it was issued under (invoke, lookup +// or headless), and the original granted kinds recorded beside its binding. +// Missing journal provenance refuses the chain. +func (s *WorkContextAuthorityServer) requireModuleChainStands(ctx context.Context, wc *basev0.WorkContextV1) error { + chain := wc.GetActorChain() + // A chain of hops through bindings seals its last hop's module binding; a + // context sealed to a source delegation or an installed read is confirmed + // by its own checker. + if len(chain) == 0 || !business.NamesModuleOperationBinding(wc.GetOperationBinding().GetBindingId()) { + return nil + } + if service == nil { + return status.Error(codes.Unavailable, "no module authority is wired, so a module chain cannot be re-read") + } + recorded := map[string]business.ActorChainHopBinding{} + if reader, ok := s.journal.(business.ActorChainHopBindingReader); ok && reader != nil { + var err error + if recorded, err = reader.ActorChainHopBindings(ctx, wc.GetTenantId(), journalKeys(wc.GetTaskId(), chain)); err != nil { + wool.Get(ctx).In("requireModuleChainStands").Warn("the bindings this chain went through could not be read", + wool.Field("org", wc.GetTenantId()), wool.ErrField(err)) + return status.Error(codes.Unavailable, "cannot read the bindings this chain went through") + } + } + hops := make([]business.ModuleChainHop, 0, len(chain)) + for _, hop := range chain { + // The per-issue row of a delegation's hop is the one that issue wrote; + // the hop's own id is every other hop's. + through, issued := recorded[delegatedHopRowID(hop.GetDelegationId(), wc.GetTaskId())] + if !issued { + through = recorded[hop.GetDelegationId()] + } + hops = append(hops, business.ModuleChainHop{ + Principal: hop.GetPrincipalId(), Scopes: operationScopesFromCore(hop.GetGrantedScopes()), + Through: through, + }) + } + if err := service.ConfirmModuleChain(ctx, wc.GetTenantId(), wc.GetAudience(), hops); err != nil { + if errors.Is(err, business.ErrModuleHopStale) { + return status.Error(codes.PermissionDenied, "a binding a hop of this chain went through no longer grants what it carries") + } + return interfaceStatus(err) + } + return nil +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index 939511f41..53df59692 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -645,9 +645,6 @@ func (s *WorkContextAuthorityServer) StartTask( return nil, err } - if facts.Actor == nil { - scopes = append(scopes, droppedKindScopes(ctx, scopes)...) - } request := mintRequest{ Start: workcontext.StartInput{ TenantID: req.GetOrgId(), diff --git a/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go b/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go index 10bbae69f..25ce8691a 100644 --- a/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go @@ -36,7 +36,6 @@ import ( "accounts/pkg/auth" "accounts/pkg/business" "accounts/pkg/business/moduleregistrytest" - authorityv1 "accounts/pkg/gen/host/authority/v1" gen "accounts/pkg/gen/saas/accounts/v1" ) @@ -224,25 +223,6 @@ func workloadOperationRows() []admissionRow { require.NoError(t, err) return workContextSingleton, "reports", issued.GetToken() }}, - // A module's hop through its binding on a person's capability (gap 14). - {"host interface binding hop", "", human, person, - func(t *testing.T) (*WorkContextAuthorityServer, string, string) { - f := newHopFixture(t, "tool.items:read", "example-records:read") - parent, _ := f.parent(t, "tools", hopScope("tool.items", []string{"read"})) - out, err := f.hop(parent, f.tool, "records", "documents") - require.NoError(t, err) - return f.issuer, "documents", out.GetToken() - }}, - // A person's issue at a solution whose roles decide what they hold. - {"host interface solution role issue", "", human, person, - func(t *testing.T) (*WorkContextAuthorityServer, string, string) { - issuer, server, _ := hostChildFixture(t) - server.roles = fixedSolutionRoles{"documents": {kinds: []string{"documents"}, held: []*basev0.WorkScopeV1{hopScope("documents", []string{"read"})}}} - out, err := server.Issue(stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, auth.Assurance{}), - &authorityv1.IssueRequest{As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "documents"}) - require.NoError(t, err) - return issuer, "documents", out.GetToken() - }}, // Person-owned unattended work: sealed elsewhere by design, still a person's. {"source-operation context", "ModuleCapabilitiesService/MintSourceOperationContext", human, person, func(t *testing.T) (*WorkContextAuthorityServer, string, string) { @@ -258,14 +238,6 @@ func workloadOperationRows() []admissionRow { require.NoError(t, err) return server, installTaskRequest().GetAudience(), issued.GetToken() }}, - // An installation acting for itself: owned by its agent, still no workload's. - {"host interface installation issue", "", business.PrincipalKindAgent, person, - func(t *testing.T) (*WorkContextAuthorityServer, string, string) { - server, _, solution, _ := installationIssueFixture(t) - out, err := server.Issue(context.Background(), installationIssue(notesInstallation, solution, "")) - require.NoError(t, err) - return server.workContexts, "notes.test", out.GetToken() - }}, {"installation step", "", human, person, func(t *testing.T) (*WorkContextAuthorityServer, string, string) { server := newInstallationMintServer(t, healthyInstallationFacts(), nil) @@ -471,11 +443,9 @@ var mintEntrypointRows = map[string][]string{ "(*WorkContextAuthorityServer).StartChildSession": {"StartChildSession"}, "(*WorkContextAuthorityServer).exchangeInstalledBinding": {"operation exchange, content_read, invoke", "operation exchange, module_actor, invoke"}, "(*WorkContextAuthorityServer).startSourceOperation": {"source-operation context", "operation exchange, source delegation reference", "operation exchange, source delegation parent"}, - "(*WorkContextAuthorityServer).mintInstallationTask": {"installation task", "installation step", "host interface installation issue"}, + "(*WorkContextAuthorityServer).mintInstallationTask": {"installation task", "installation step"}, "(*WorkContextAuthorityServer).StartModuleTask": {"module identity", "installer token route"}, "(*WorkContextAuthorityServer).StartModuleOperationTask": {"module operation context", "module operation context for its own module"}, - "(*WorkContextAuthorityServer).mintBindingHop": {"host interface binding hop"}, - "(*WorkContextAuthorityServer).startRoleTask": {"host interface solution role issue"}, } // isSigningMethod reports the methods a signing function references: every method diff --git a/module/services/accounts/code/pkg/business/api_keys.go b/module/services/accounts/code/pkg/business/api_keys.go index 10c5a4609..e5233fdaa 100644 --- a/module/services/accounts/code/pkg/business/api_keys.go +++ b/module/services/accounts/code/pkg/business/api_keys.go @@ -270,26 +270,19 @@ func (s *Service) ListAPIKeys(ctx context.Context, req *gen.ListAPIKeysRequest) // goes away and org-admins can revoke their own keys without // platform-admin perms. // It is a NARROWING — a credential that authenticates stops authenticating — -// so it runs under the policy log: the entry is appended to the external record -// and receipted BEFORE the key is marked, and the revocation commits in the -// same transaction as the receipt's commit. A host that cannot witness the -// append refuses rather than revoking unwitnessed: a key revocation a restore -// could silently undo is a live credential nobody knows is live. +// so it applies directly (narrow): the revocation and its audit event commit in +// one transaction. func (s *Service) RevokeAPIKey(ctx context.Context, actorID string, req *gen.RevokeAPIKeyRequest) error { // Org-scoped revoke: the store statement pins id AND organization_id, so an // org admin can never revoke another org's key by id (handler authorized // the actor for req.OrganizationId; the WHERE enforces the binding). That - // WHERE is also what keeps the revoke confined now the transaction is the - // policy log's control-plane one — the receipt relation is control-plane - // only, and the receipt and the revocation have to be the same transaction. - if err := s.WithPolicyLoggedNarrowing(ctx, - revokeAPIKeyPolicyLogEntry(actorID, req.OrganizationId, req.Id), - func(ctx context.Context) error { - if err := s.store.RevokeAPIKey(ctx, req.Id, req.OrganizationId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventAPIKeyRevoked, "api_key", req.Id, req.OrganizationId) - }); err != nil { + // WHERE is also what keeps the revoke confined under the control plane. + if err := s.narrow(ctx, func(ctx context.Context) error { + if err := s.store.RevokeAPIKey(ctx, req.Id, req.OrganizationId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventAPIKeyRevoked, "api_key", req.Id, req.OrganizationId) + }); err != nil { return err } return nil diff --git a/module/services/accounts/code/pkg/business/audit_actor_type_gate_test.go b/module/services/accounts/code/pkg/business/audit_actor_type_gate_test.go index d1b34cd3b..c912d6ba3 100644 --- a/module/services/accounts/code/pkg/business/audit_actor_type_gate_test.go +++ b/module/services/accounts/code/pkg/business/audit_actor_type_gate_test.go @@ -40,6 +40,8 @@ var auditActorTypeIndirectSites = map[string]string{ "installations.go:uninstallSolutionTx": "both callers (UninstallSolution, DeleteOrganization) pass actorTypeForCreator's result, which returns a registered constant on every branch.", "delegated_audience_audit.go:ObserveDelegatedAudienceExchange": "delegatedAudienceActorType returns only registered constants; TestDelegatedAudienceActorTypeSpeaksTheRegisteredVocabulary pins every branch.", "webhooks.go:CreateSubscription": "AuditActor.Type, rejected by AuditActor.validate before the mutation opens its transaction.", + "authority_core_audit.go:journalAuthority": "authorityActor returns ActorTypeUser or ActorTypeSystem on every branch; TestAuthorityChangesAreRecordedAsRegisteredMutations pins it.", + "authority_core_audit.go:RecordAuthorityIssue": "authorityActor returns ActorTypeUser or ActorTypeSystem on every branch; TestAuthorityChangesAreRecordedAsRegisteredMutations pins it.", "webhooks.go:DeleteSubscription": "AuditActor.Type, rejected by AuditActor.validate before the mutation opens its transaction.", "webhooks.go:ReplayWebhookDelivery": "AuditActor.Type, rejected by AuditActor.validate before the mutation opens its transaction.", "webhooks.go:RotateWebhookSecret": "AuditActor.Type, rejected by AuditActor.validate before the mutation opens its transaction.", diff --git a/module/services/accounts/code/pkg/business/audit_durability_gate_test.go b/module/services/accounts/code/pkg/business/audit_durability_gate_test.go index 4e9384fbb..8b8a1e80d 100644 --- a/module/services/accounts/code/pkg/business/audit_durability_gate_test.go +++ b/module/services/accounts/code/pkg/business/audit_durability_gate_test.go @@ -56,7 +56,8 @@ var auditEmitExemptions = map[string]string{ // constant the gate can resolve. Each must be justified: an unresolvable type is // a classification the gate cannot check. var unresolvedAuditEmitSites = map[string]string{ - "waitlist.go:ReviewWaitlist": "the event type is composed from the target state; every saas.waitlist.* event is observational, so no transactional classification can hide here.", + "waitlist.go:ReviewWaitlist": "the event type is composed from the target state; every saas.waitlist.* event is observational, so no transactional classification can hide here.", + "authority_core_audit.go:journalAuthority": "the event is one of authorityEvents' saas.authority.* constants, each registered as a mutation and emitted with emitTx on the core's own transaction; TestAuthorityChangesAreRecordedAsRegisteredMutations pins every entry.", } type auditEmitSite struct { diff --git a/module/services/accounts/code/pkg/business/audit_parity_test.go b/module/services/accounts/code/pkg/business/audit_parity_test.go index 35683ff51..8ac13957e 100644 --- a/module/services/accounts/code/pkg/business/audit_parity_test.go +++ b/module/services/accounts/code/pkg/business/audit_parity_test.go @@ -14,6 +14,10 @@ import ( // nothing can reach. var eventsEmittedOutsideRPC = []EventType{ EventActivationAchieved, + // The authority core journals an uninstall in its own transaction; the + // host interfaces declare no uninstall RPC (codefly.dev/installation has + // none), so the host's own teardown is its one caller today. + EventAuthorityInstallationUninstalled, // The declared-presence reconcile pass emits this: delivery declares a // generation and the host applies it, with no request behind it (#952). EventSolutionHostBindingApplied, diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 237ca0805..1c03099d9 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -385,6 +385,18 @@ const ( EventInstallationRevoked EventType = "saas.installation.revoked" EventInstallationOwnershipTransferred EventType = "saas.installation.ownership_transferred" + // The authority core's changes (authority_core_audit.go), recorded at + // platform scope in the core's own transaction, the organization named in + // the payload: the core decides across organizations. + EventAuthorityInstallationRequested EventType = "saas.authority.installation_requested" + EventAuthorityInstallationActivated EventType = "saas.authority.installation_activated" + EventAuthorityInstallationUninstalled EventType = "saas.authority.installation_uninstalled" + EventAuthorityRoleGranted EventType = "saas.authority.role_granted" + EventAuthorityRoleRevoked EventType = "saas.authority.role_revoked" + EventAuthorityResourceAttached EventType = "saas.authority.resource_attached" + EventAuthorityResourceDetached EventType = "saas.authority.resource_detached" + EventAuthorityResourceTransferred EventType = "saas.authority.resource_transferred" + EventWorkContextTaskStarted EventType = "saas.work_context.task_started" EventWorkContextRootSession EventType = "saas.work_context.root_session_started" EventWorkContextChildSession EventType = "saas.work_context.child_session_started" @@ -693,6 +705,14 @@ var auditEventCatalog = []AuditEventDefinition{ uid("owner_principal_id")), mutation(EventRecordShared, CategoryAccess, "A record was shared with a principal or team.", uid("role_id"), uid("subject_id")), mutation(EventRecordShareRevoked, CategoryAccess, "A record share was revoked.", uid("role_id"), uid("subject_id")), + mutation(EventAuthorityInstallationRequested, CategoryAccess, "An installation attempt was recorded pending, under the consent it was reviewed with.", authorityChangeFields()...), + mutation(EventAuthorityInstallationActivated, CategoryAccess, "An installation's install step converged and it became active.", authorityChangeFields()...), + mutation(EventAuthorityInstallationUninstalled, CategoryAccess, "An installation was uninstalled: its grants cleared and every capability sealed to it ended.", authorityChangeFields()...), + mutation(EventAuthorityRoleGranted, CategoryAccess, "A solution role was granted on an installation to a person or a team.", authorityChangeFields()...), + mutation(EventAuthorityRoleRevoked, CategoryAccess, "A solution role grant was revoked; its subject's capabilities through the installation ended.", authorityChangeFields()...), + mutation(EventAuthorityResourceAttached, CategoryAccess, "A resource created through an installation was attached to it by the kind's serving module.", authorityChangeFields()...), + mutation(EventAuthorityResourceDetached, CategoryAccess, "A deleted resource was released from the installation that owned it.", authorityChangeFields()...), + mutation(EventAuthorityResourceTransferred, CategoryAccess, "An existing resource joined an installed solution's principal, authorized by a person administering it.", authorityChangeFields()...), mutation(EventWorkContextTaskStarted, CategoryAccess, "A signed Work Context was issued for a new agent task and root session."), mutation(EventWorkContextRootSession, CategoryAccess, "A new root agent session was started under an existing task."), mutation(EventWorkContextChildSession, CategoryAccess, "An attenuated child agent session was started."), @@ -754,7 +774,7 @@ var auditEventCatalog = []AuditEventDefinition{ mutation(EventOrgCreated, CategoryOrganization, "An organization was created.", str("name")), mutation(EventOrgMemberAdded, CategoryOrganization, "A member was added to an organization."), - mutation(EventOrgMemberRemoved, CategoryOrganization, "A member was removed from an organization."), + mutation(EventOrgMemberRemoved, CategoryOrganization, "A member was removed from an organization.", uid("user_id")), mutation(EventOrgMemberLeft, CategoryOrganization, "A member left an organization."), mutation(EventOrgUpdated, CategoryOrganization, "An organization was renamed or its slug changed.", str("name"), str("slug")), mutation(EventOrgDeleted, CategoryOrganization, "An organization was deleted: archived, its members removed and its credentials revoked.", str("slug")), @@ -764,7 +784,7 @@ var auditEventCatalog = []AuditEventDefinition{ mutation(EventTeamUpdated, CategoryOrganization, "A team was updated."), mutation(EventTeamDeleted, CategoryOrganization, "A team was deleted."), mutation(EventTeamMemberAdded, CategoryOrganization, "A member was added to a team."), - mutation(EventTeamMemberRemoved, CategoryOrganization, "A member was removed from a team."), + mutation(EventTeamMemberRemoved, CategoryOrganization, "A member was removed from a team.", uid("user_id")), mutation(EventSSOSetupStarted, CategoryOrganization, "SSO configuration was started."), mutation(EventSSODisabled, CategoryOrganization, "SSO was disabled for an organization."), observation(EventOnboardingStepDone, CategoryOrganization, "An onboarding step was completed.", str("step")), @@ -1357,3 +1377,12 @@ func (d AuditEventDefinition) payloadJSONSchema() map[string]any { } return schema } + +// authorityChangeFields are the payload of an authority core change: where it +// happened, who acted, what changed and the change's own details, each +// present when the change names it. +func authorityChangeFields() []PayloadField { + return optionalFields([]PayloadField{str("organization"), str("actor"), str("subject"), str("target"), str("role"), str("person"), str("team"), + str("principal"), str("installed_by"), str("revision"), str("operation"), str("module"), str("purpose"), str("kind"), str("resource"), + str("from"), str("to"), str("store")}) +} diff --git a/module/services/accounts/code/pkg/business/authority_core.go b/module/services/accounts/code/pkg/business/authority_core.go new file mode 100644 index 000000000..6e6f9c41f --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core.go @@ -0,0 +1,143 @@ +package business + +// The host interfaces are decided by the shared authority core +// (codefly-dev/authority libraries/authority-core), the same one the local +// authority runs. This file builds it over this host's store and signing key; +// accounts keeps what is its own: identity, the signing key, delivery, the +// review of workload credentials, and the audit journal. + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "slices" + "strings" + "sync" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/core/workcontext" + + "accounts/pkg/permissioncatalog" +) + +// AuthorityCoreStore is a store that keeps the authority core's records. +type AuthorityCoreStore interface { + AuthorityCoreWithConsent(ModuleConsent) authority.Store + AuthorityCoreReplay() workcontext.ReplayStore +} + +// ModuleConsent answers, for one module installation's recorded consent (its +// installations.scope) and the envelope revision committed in the reading +// transaction, whether the installation allows any capability and what it +// allows of the module's operation bindings. The authority core bounds every +// issue through a binding by it (domain.Installation.Bindings). +type ModuleConsent func(prefix string, consent []byte, committedEnvelope uint64) (bindings map[string]domain.Binding, active bool, err error) + +// ErrAuthorityCoreUnconfigured reports a host interface call this host cannot +// decide: the authority core is not built (no signing key, or a store that +// keeps no core records). +var ErrAuthorityCoreUnconfigured = errors.New("the authority core is not configured") + +type authorityCoreState struct { + mu sync.RWMutex + core *authority.Service + err error + // step is the install step a solution installation runs before it is + // active; nil activates at once, as the local authority does. + step authority.InstallStep +} + +func (s *Service) authorityCoreState() *authorityCoreState { + if s.authorityCore == nil { + s.authorityCore = &authorityCoreState{err: ErrAuthorityCoreUnconfigured} + } + return s.authorityCore +} + +type staticKeys map[string]ed25519.PublicKey + +func (k staticKeys) PublicKeys(context.Context) (map[string]ed25519.PublicKey, error) { return k, nil } + +// ConfigureAuthorityCore builds the core over this host's store, signing as +// issuer with key under keyID: the Work Context key, so every verifier pinning +// Keys verifies what the core issues. A module is installed at an organization +// only by accounts' own consent (an administrator's install, within the module +// envelope): the core installs none itself and reads accounts' records live +// (HostInstallsModules), so an uninstall or a narrowed ceiling ends it there at +// once. +func (s *Service) ConfigureAuthorityCore(ctx context.Context, issuer, keyID string, key ed25519.PrivateKey) error { + state := s.authorityCoreState() + core, err := s.newAuthorityCore(issuer, keyID, key) + state.mu.Lock() + state.core, state.err = core, err + state.mu.Unlock() + if err != nil { + return err + } + if err := s.reconcileAuthorityCore(ctx); err != nil { + return fmt.Errorf("authority core: compose the applied deliveries: %w", err) + } + return nil +} + +func (s *Service) newAuthorityCore(issuer, keyID string, key ed25519.PrivateKey) (*authority.Service, error) { + store, ok := s.store.(AuthorityCoreStore) + if !ok { + return nil, fmt.Errorf("%w: the store keeps no authority core records", ErrAuthorityCoreUnconfigured) + } + if strings.TrimSpace(issuer) == "" || strings.TrimSpace(keyID) == "" || len(key) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("%w: an issuer, a key id and an Ed25519 signing key are required", ErrAuthorityCoreUnconfigured) + } + public, _ := key.Public().(ed25519.PublicKey) + return authority.New(store.AuthorityCoreWithConsent(s.moduleConsent), authority.Config{ + Issuer: issuer, KeyID: keyID, Signer: key, Keys: staticKeys{keyID: public}, + Replay: store.AuthorityCoreReplay(), MaxTTL: workcontext.DefaultMaxTTL, Journal: s.journalAuthority, + HostInstallsModules: true, + }) +} + +// AuthorityCore is the core every host interface operation is decided by. +func (s *Service) AuthorityCore() (*authority.Service, error) { + state := s.authorityCoreState() + state.mu.RLock() + defer state.mu.RUnlock() + if state.core == nil { + return nil, state.err + } + return state.core, nil +} + +// SetAuthorityInstallStep sets the step a solution installation runs before +// it is active; nil activates it at once. +func (s *Service) SetAuthorityInstallStep(step authority.InstallStep) { + state := s.authorityCoreState() + state.mu.Lock() + state.step = step + state.mu.Unlock() +} + +// AuthorityInstallStep is the step a solution installation runs. +func (s *Service) AuthorityInstallStep() authority.InstallStep { + state := s.authorityCoreState() + state.mu.RLock() + defer state.mu.RUnlock() + return state.step +} + +// MemberPermissions are the contributed permissions every current member of +// an organization holds as a person (a contribution's `members: true`), +// sorted by resource and action. +func MemberPermissions() []permissioncatalog.Permission { + var out []permissioncatalog.Permission + for _, p := range permissioncatalog.Permissions() { + if p.Members { + out = append(out, p) + } + } + slices.SortFunc(out, func(a, b permissioncatalog.Permission) int { + return strings.Compare(a.Resource+":"+a.Action, b.Resource+":"+b.Action) + }) + return out +} diff --git a/module/services/accounts/code/pkg/business/authority_core_audit.go b/module/services/accounts/code/pkg/business/authority_core_audit.go new file mode 100644 index 000000000..b70435fa4 --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core_audit.go @@ -0,0 +1,126 @@ +package business + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/google/uuid" +) + +// authorityEvents maps each change the core journals onto this host's audit +// record. A module's delivery and withdrawal are recorded by the delivery +// path (saas.solution.host_binding_applied), so they are not recorded twice. +var authorityEvents = map[domain.EventKind]struct { + event EventType + resource string +}{ + domain.InstallationRequested: {EventAuthorityInstallationRequested, "installation"}, + domain.InstallationActivated: {EventAuthorityInstallationActivated, "installation"}, + domain.InstallationUninstalled: {EventAuthorityInstallationUninstalled, "installation"}, + domain.RoleGranted: {EventAuthorityRoleGranted, "installation"}, + domain.RoleRevoked: {EventAuthorityRoleRevoked, "installation"}, + domain.DelegationGranted: {EventModuleDelegationGranted, "module_delegation"}, + domain.DelegationRevoked: {EventModuleDelegationRevoked, "module_delegation"}, + domain.ResourceAttached: {EventAuthorityResourceAttached, "installation"}, + domain.ResourceDetached: {EventAuthorityResourceDetached, "installation"}, + domain.ResourceTransferred: {EventAuthorityResourceTransferred, "installation"}, +} + +// boundTx is the core's transaction as this host's store opened it: the +// context its own writes bind to. +type boundTx interface{ Context() context.Context } + +// journalAuthority records one change of the core in the transaction that +// makes it. The record is platform-scoped, the organization named in the +// payload: the core's transaction is the control plane's, which writes no +// tenant fan-out, and the core decides across organizations. +func (s *Service) journalAuthority(_ context.Context, tx authority.Tx, e domain.Event) error { + mapped, recorded := authorityEvents[e.Kind] + if !recorded { + return nil + } + bound, ok := tx.(boundTx) + if !ok { + return errors.New("the authority core's transaction binds no context to record its audit in") + } + payload, err := authorityPayload(tx, e) + if err != nil { + return err + } + actorID, actorType := authorityActor(e.Actor) + if err := s.emitTx(bound.Context(), actorID, actorType, mapped.event, mapped.resource, e.Subject, "", payload); err != nil { + return fmt.Errorf("record %s: %w", mapped.event, err) + } + return nil +} + +// authorityPayload is a change's audit payload. A delegation's is the record of +// what was delegated, to which module and by whom, as delegations to a module +// have always been recorded; every other change names where it happened, who +// acted, what changed and the change's own details. +func authorityPayload(tx authority.Tx, e domain.Event) (map[string]any, error) { + if e.Kind == domain.DelegationGranted || e.Kind == domain.DelegationRevoked { + d, err := tx.Delegation(e.Subject) + if err != nil { + return nil, fmt.Errorf("read delegation %s to record it: %w", e.Subject, err) + } + scopes := make([]string, 0, len(d.Scopes)) + for _, sc := range d.Scopes { + entry := sc.GetResourceKind() + ":" + strings.Join(sc.GetActions(), ",") + if len(sc.GetResourceIds()) > 0 { + entry += "[" + strings.Join(sc.GetResourceIds(), ",") + "]" + } + scopes = append(scopes, entry) + } + payload := map[string]any{"delegation_id": d.ID, "grantor_id": d.Grantor, "module": d.Module, "scopes": scopes} + if e.Kind == domain.DelegationRevoked { + payload["revoked_by"] = e.Actor + } + return payload, nil + } + payload := map[string]any{"subject": e.Subject} + if e.Organization != "" { + payload["organization"] = e.Organization + } + if e.Actor != "" { + payload["actor"] = e.Actor + } + for key, value := range e.Detail { + if value != "" { + payload[key] = value + } + } + return payload, nil +} + +// authorityActor is who an authority change or issue is recorded as: a person +// (a user id) as themselves, anything else (a module's workload, the host's own +// setup) as the system, its name kept in the payload. +func authorityActor(actor string) (id, actorType string) { + if _, err := uuid.Parse(actor); err == nil { + return actor, ActorTypeUser + } + return "", ActorTypeSystem +} + +// RecordAuthorityIssue records a capability the core issued, in a transaction +// of the organization it was issued in, before it is handed out: a child hop +// is a child session, every other shape a task started. An issue whose record +// cannot be written is refused. +func (s *Service) RecordAuthorityIssue(ctx context.Context, organization, actor string, child bool, task string) error { + event := EventWorkContextTaskStarted + if child { + event = EventWorkContextChildSession + } + actorID, actorType := authorityActor(actor) + if _, err := uuid.Parse(organization); err != nil { + return fmt.Errorf("record %s: organization %q is not one of this host's", event, organization) + } + return s.store.WithOrgTx(ctx, organization, func(ctx context.Context) error { + return s.emitTx(ctx, actorID, actorType, event, "work_context", task, organization) + }) +} diff --git a/module/services/accounts/code/pkg/business/authority_core_audit_test.go b/module/services/accounts/code/pkg/business/authority_core_audit_test.go new file mode 100644 index 000000000..203b0c6f4 --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core_audit_test.go @@ -0,0 +1,38 @@ +package business + +import ( + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/stretchr/testify/require" +) + +// Every change the core journals that this host records is a registered +// mutation, emitted on the core's own transaction, and recorded as a person +// or as the system: the values the audit column admits. +func TestAuthorityChangesAreRecordedAsRegisteredMutations(t *testing.T) { + catalog := map[EventType]AuditEventDefinition{} + for _, d := range AuditEventCatalog() { + catalog[d.Type] = d + } + for kind, mapped := range authorityEvents { + d, ok := catalog[mapped.event] + require.True(t, ok, "%s records %s, which is not registered", kind, mapped.event) + require.Equal(t, DurabilityTransactional, d.Durability, "%s records %s as a mutation", kind, mapped.event) + } + for _, kind := range []domain.EventKind{domain.InstallationRequested, domain.InstallationActivated, domain.InstallationUninstalled, + domain.RoleGranted, domain.RoleRevoked, domain.DelegationGranted, domain.DelegationRevoked, + domain.ResourceAttached, domain.ResourceDetached, domain.ResourceTransferred} { + _, recorded := authorityEvents[kind] + require.True(t, recorded, "%s is a change of authority this host records", kind) + } + id, actorType := authorityActor("00000000-0000-7000-8000-0000000000b1") + require.Equal(t, "00000000-0000-7000-8000-0000000000b1", id) + require.Equal(t, ActorTypeUser, actorType) + id, actorType = authorityActor("documents") + require.Empty(t, id) + require.Equal(t, ActorTypeSystem, actorType) + id, actorType = authorityActor("") + require.Empty(t, id) + require.Equal(t, ActorTypeSystem, actorType) +} diff --git a/module/services/accounts/code/pkg/business/authority_core_consent.go b/module/services/accounts/code/pkg/business/authority_core_consent.go new file mode 100644 index 000000000..c90fc4ae9 --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core_consent.go @@ -0,0 +1,69 @@ +package business + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// moduleConsent is what one module installation's consent allows the +// authority core to issue through, decided exactly as the native mint decides +// it: the installed registry's grant for the module (its manifest within the +// module envelope) intersected with what the organization's administrator +// consented to (consentedGrant). An installation whose consent allows no +// capability, or that awaits a re-consent, is not active for the core; a +// binding the grant or the consent drops is not usable there, and one either +// narrows is used within both. A widening the administrator has not consented +// to therefore reaches no issue. +// +// The registry is in memory and derived per replica; committedEnvelope is the +// revision committed in the core's reading transaction, and a registry +// derived from any other refuses, as fenceModuleRegistrySnapshot does, so a +// ceiling a newer envelope replaced is never read. +func (s *Service) moduleConsent(prefix string, raw []byte, committedEnvelope uint64) (map[string]domain.Binding, bool, error) { + var consent InstallationScope + if len(raw) > 0 { + if err := json.Unmarshal(raw, &consent); err != nil { + return nil, false, fmt.Errorf("module %s: the recorded consent does not read: %w", prefix, err) + } + } + if !consent.Capabilities { + return nil, false, nil + } + snapshot := s.moduleRegistrySnapshot() + if s.moduleEnvelopes != nil && snapshot.envelopeRevision != committedEnvelope { + s.requestRegistryPass() + return nil, false, fmt.Errorf("%w: the registry was derived from envelope revision %d and revision %d is committed", + ErrModuleRegistryStale, snapshot.envelopeRevision, committedEnvelope) + } + grant, registered := snapshot.registry[ModulePrincipalID(prefix)] + if !registered { + // Not in the registry: the envelope allows the module nothing. + return map[string]domain.Binding{}, true, nil + } + consented, err := snapshot.consentedGrant(grant, consent) + if errors.Is(err, ErrModuleInstallationConsentRequired) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + bindings := make(map[string]domain.Binding, len(consented.OperationAudiences)) + for id, b := range consented.OperationAudiences { + bindings[id] = domain.Binding{ID: id, Audience: b.Audience, + InvokeScopes: workScopes(b.InvokeScopes), HeadlessScopes: workScopes(b.HeadlessScopes), LookupScopes: workScopes(b.LookupScopes)} + } + return bindings, true, nil +} + +func workScopes(in []ModuleOperationScope) []*basev0.WorkScopeV1 { + out := make([]*basev0.WorkScopeV1, 0, len(in)) + for _, sc := range in { + out = append(out, &basev0.WorkScopeV1{ResourceKind: sc.ResourceKind, Actions: slices.Clone(sc.Actions), ResourceIds: slices.Clone(sc.ResourceIDs)}) + } + return out +} diff --git a/module/services/accounts/code/pkg/business/authority_core_consent_test.go b/module/services/accounts/code/pkg/business/authority_core_consent_test.go new file mode 100644 index 000000000..ac9bf7248 --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core_consent_test.go @@ -0,0 +1,79 @@ +package business + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// fencedEnvelopes stands in for an envelope store, so the consent decision's +// fence has a committed revision to compare with; it is never read here. +type fencedEnvelopes struct{ ModuleEnvelopeStore } + +// The authority core reads a module installation's consent through +// moduleConsent, decided as the native mint decides it: the module's grant +// within the envelope, intersected with what the administrator consented to. +func TestAModuleInstallationsConsentIsWhatTheCoreIssuesWithin(t *testing.T) { + const prefix = "reports" + read := []ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"read"}}} + readWrite := []ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"read", "write"}}} + grant := ModulePrincipalGrant{Prefix: prefix, CrossTenant: true, OperationAudiences: map[string]ModuleOperationAudience{ + "model": {Audience: "model", HeadlessScopes: readWrite}, + }} + consentOf := func(t *testing.T, s InstallationScope) []byte { + raw, err := json.Marshal(s) + require.NoError(t, err) + return raw + } + for _, tc := range []struct { + name string + consent InstallationScope + active bool + bindings map[string][]string // binding -> headless actions + }{ + {name: "no capability consented", consent: InstallationScope{Capabilities: false}}, + {name: "a following consent with no envelope awaits re-consent", consent: InstallationScope{Capabilities: true, Follows: true}}, + {name: "the binding consented", active: true, consent: InstallationScope{Capabilities: true, OperationAudiences: map[string]ModuleOperationAudience{ + "model": {Audience: "model", HeadlessScopes: readWrite}}}, bindings: map[string][]string{"model": {"read", "write"}}}, + {name: "the binding consented narrower", active: true, consent: InstallationScope{Capabilities: true, OperationAudiences: map[string]ModuleOperationAudience{ + "model": {Audience: "model", HeadlessScopes: read}}}, bindings: map[string][]string{"model": {"read"}}}, + {name: "the binding dropped from the consent", active: true, consent: InstallationScope{Capabilities: true}, bindings: map[string][]string{}}, + {name: "the binding consented to another audience", active: true, consent: InstallationScope{Capabilities: true, OperationAudiences: map[string]ModuleOperationAudience{ + "model": {Audience: "elsewhere", HeadlessScopes: readWrite}}}, bindings: map[string][]string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Service{} + s.SetModulePrincipals(ModulePrincipalRegistry{ModulePrincipalID(prefix): grant}) + bindings, active, err := s.moduleConsent(prefix, consentOf(t, tc.consent), 0) + require.NoError(t, err) + require.Equal(t, tc.active, active) + if !tc.active { + return + } + require.Len(t, bindings, len(tc.bindings)) + for id, actions := range tc.bindings { + require.Len(t, bindings[id].HeadlessScopes, 1) + require.ElementsMatch(t, actions, bindings[id].HeadlessScopes[0].GetActions()) + } + }) + } + + t.Run("a module the registry does not hold is allowed no binding", func(t *testing.T) { + s := &Service{} + s.SetModulePrincipals(ModulePrincipalRegistry{}) + bindings, active, err := s.moduleConsent(prefix, consentOf(t, InstallationScope{Capabilities: true}), 0) + require.NoError(t, err) + require.True(t, active) + require.Empty(t, bindings) + }) + + t.Run("a registry derived from an older envelope than the one committed refuses", func(t *testing.T) { + s := &Service{} + s.SetModulePrincipals(ModulePrincipalRegistry{ModulePrincipalID(prefix): grant}) + s.SetModuleEnvelopeStore(fencedEnvelopes{}) + _, _, err := s.moduleConsent(prefix, consentOf(t, InstallationScope{Capabilities: true}), 3) + require.True(t, errors.Is(err, ErrModuleRegistryStale), "%v", err) + }) +} diff --git a/module/services/accounts/code/pkg/business/authority_core_delivery.go b/module/services/accounts/code/pkg/business/authority_core_delivery.go new file mode 100644 index 000000000..7135736a3 --- /dev/null +++ b/module/services/accounts/code/pkg/business/authority_core_delivery.go @@ -0,0 +1,178 @@ +package business + +// A delivery composes a module twice over: accounts' own delivery records it +// (the signed carrier, its applied generation, the registry every route and +// workload review reads), and the authority core records the module it +// decides with. Both read the same two signed documents; the core reads them +// through its shared delivered package, never a reading of its own here. + +import ( + "context" + "errors" + "fmt" + + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + coredelivered "github.com/codefly-dev/authority/libraries/authority-core/go/delivered" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/wool" + "google.golang.org/protobuf/proto" + + authorityv1 "accounts/pkg/gen/host/authority/v1" + compositionv1 "accounts/pkg/gen/host/composition/v1" +) + +// coreModuleOf is what the core composes from a delivery's manifest and the +// authority document paired with it, if any. +func coreModuleOf(manifest *solutionhost.ModuleManifest, carrierDocument []byte, signed *compositionv1.SignedManifestV1) (domain.Module, error) { + var doc *coredelivered.Document + if signed != nil { + var wire compositionv1.AuthorityManifestV1 + if err := proto.Unmarshal(signed.GetDocument(), &wire); err != nil { + return domain.Module{}, domain.Malformed("the authority's document does not read: %v", err) + } + doc = documentOf(&wire) + if err := coredelivered.Pair(*doc, carrierDocument, manifest); err != nil { + return domain.Module{}, err + } + } + return coredelivered.Module(manifest, doc) +} + +// documentOf reads this host's binding of the authority's document as the core's. +func documentOf(in *compositionv1.AuthorityManifestV1) *coredelivered.Document { + doc := &coredelivered.Document{Module: in.GetModule(), Generation: in.GetGeneration(), CoreManifestDigest: in.GetCoreManifestDigest(), + ScopeCeilings: wireScopes(in.GetScopeCeilings()), OwnerAware: in.GetOwnerAware()} + for _, m := range in.GetMethods() { + doc.Methods = append(doc.Methods, coredelivered.Method{Method: m.GetMethod(), Requires: wireScopes(m.GetRequires()), AnyCheckedCaller: m.GetAnyCheckedCaller()}) + } + for _, r := range in.GetRoles() { + doc.Roles = append(doc.Roles, coredelivered.Role{Name: r.GetName(), Scopes: wireScopes(r.GetScopes()), InstallationOwned: wireScopes(r.GetInstallationOwnedScopes())}) + } + return doc +} + +// wireScopes converts the interface's scopes to core's, byte for byte. +func wireScopes(in []*authorityv1.WorkScopeV1) []*basev0.WorkScopeV1 { + out := make([]*basev0.WorkScopeV1, 0, len(in)) + for _, sc := range in { + raw, _ := proto.Marshal(sc) + c := &basev0.WorkScopeV1{} + _ = proto.Unmarshal(raw, c) + out = append(out, c) + } + return out +} + +// precheckCoreModule refuses, before anything is recorded, a delivery the core +// would refuse to compose. It reads the carrier unverified, so it only ever +// refuses: what is applied is read again from the verified, applied record. +func precheckCoreModule(req *compositionv1.ApplyManifestRequest) error { + manifest, err := solutionhost.ManifestFromVerified(req.GetCarrier().GetDocument()) + if err != nil { + return nil // the delivery path names a carrier that does not read + } + if manifest.Removed { + return nil + } + m, err := coreModuleOf(manifest, req.GetCarrier().GetDocument(), req.GetAuthority()) + if err != nil { + return err + } + m.ResourceKinds = decide.Served(m) + return decide.ValidateModule(m) +} + +// applyCoreModule records the applied, verified delivery in the core: the +// module it composes at its generation, or its withdrawal. A replay of the +// generation the core already holds changes nothing. +func (s *Service) applyCoreModule(ctx context.Context, manifest *solutionhost.ModuleManifest, carrierDocument []byte, signed *compositionv1.SignedManifestV1) ([]string, error) { + core, err := s.AuthorityCore() + if errors.Is(err, ErrAuthorityCoreUnconfigured) { + // A host whose core is not built yet composes this delivery when it is + // (ConfigureAuthorityCore reconciles every applied delivery). + wool.Get(ctx).In("applyCoreModule").Warn("the authority core is not configured; it composes this delivery when it is", wool.Field("module", manifest.Alias)) + return nil, nil + } + if err != nil { + return nil, err + } + if manifest.Removed { + if _, err := core.Composed(ctx, manifest.Alias); err != nil { + if code, ok := domain.CodeOf(err); ok && code == domain.NotFound { + return nil, nil + } + return nil, err + } + return nil, core.WithdrawModule(ctx, manifest.Alias) + } + m, err := coreModuleOf(manifest, carrierDocument, signed) + if err != nil { + return nil, err + } + if held, err := core.Composed(ctx, m.Name); err == nil && held.Generation == m.Generation && !held.StandIn { + return nil, nil + } + return core.ApplyModule(ctx, m) +} + +// coreDeliveryError is a refusal of the core's, carried through the delivery +// path's own error vocabulary. +func coreDeliveryError(err error) error { + if err == nil { + return nil + } + code, ok := domain.CodeOf(err) + switch { + case !ok: + return fmt.Errorf("%w: the authority core: %w", ErrSolutionDeliveryHostState, err) + case code == domain.Invalid: + return fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) + case code == domain.Precondition: + return fmt.Errorf("%w: %w", ErrSolutionDeliveryConflict, err) + } + return fmt.Errorf("%w: %w", ErrSolutionDeliveryUnattested, err) +} + +// reconcileAuthorityCore composes, in the core, every delivery accounts has +// applied: what a host runs once its core is built, so a module delivered +// before this host decided through the core, or while its core could not be +// built, is composed there too. Each is read from the inbox's exact applied +// generation, the signed documents accounts verified when it took them. +func (s *Service) reconcileAuthorityCore(ctx context.Context) error { + if s.deliveryStore == nil { + return nil + } + records, err := s.ListSolutionHostBindings(ctx) + if err != nil { + return err + } + var failures []error + for _, record := range records { + manifest, err := record.AppliedManifest() + if err != nil || manifest == nil { + continue + } + var delivered *SolutionDeliveryRecord + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + delivered, err = s.deliveryStore.ReadDeliveredDocument(ctx, record.BindingID, record.Applied.Generation) + return err + }); err != nil { + failures = append(failures, fmt.Errorf("binding %s: %w", record.BindingID, err)) + continue + } + if delivered == nil { + continue + } + var signed *compositionv1.SignedManifestV1 + if len(delivered.AuthorityDocument) > 0 { + signed = &compositionv1.SignedManifestV1{Document: delivered.AuthorityDocument, Envelope: delivered.AuthorityEnvelope} + } + if _, err := s.applyCoreModule(ctx, manifest, delivered.Payload, signed); err != nil { + failures = append(failures, fmt.Errorf("binding %s: %w", record.BindingID, err)) + } + } + return errors.Join(failures...) +} diff --git a/module/services/accounts/code/pkg/business/authority_delivery.go b/module/services/accounts/code/pkg/business/authority_delivery.go index 071a922fb..2f33b18bb 100644 --- a/module/services/accounts/code/pkg/business/authority_delivery.go +++ b/module/services/accounts/code/pkg/business/authority_delivery.go @@ -39,6 +39,11 @@ func (s *Service) ApplyComposition(ctx context.Context, req *compositionv1.Apply if s.compositionReconcile == nil { return nil, fmt.Errorf("%w: composition reconciler is not wired", ErrSolutionDeliveryHostState) } + // A delivery the authority core would refuse to compose is refused before + // anything is recorded, so accounts' record and the core's never part. + if err := precheckCoreModule(req); err != nil { + return nil, coreDeliveryError(err) + } raw, err := solutionhost.MarshalSigned(&solutionhost.Signed{Document: req.GetCarrier().GetDocument(), Envelope: req.GetCarrier().GetEnvelope()}) if err != nil { return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) @@ -72,11 +77,16 @@ func (s *Service) ApplyComposition(ctx context.Context, req *compositionv1.Apply if err != nil { return nil, err } + // The applied, verified delivery is what the core composes. + ids, err := s.applyCoreModule(ctx, manifest, req.GetCarrier().GetDocument(), req.GetAuthority()) + if err != nil { + return nil, coreDeliveryError(err) + } principal := manifest.Alias if manifest.Authority != nil && manifest.Authority.Principal != "" { principal = manifest.Authority.Principal } - return &compositionv1.ApplyManifestResponse{Principal: principal, Generation: record.Generation}, nil + return &compositionv1.ApplyManifestResponse{Principal: principal, Generation: record.Generation, InstallationIds: ids}, nil } return nil, fmt.Errorf("%w: binding %s has no applied record", ErrSolutionDeliveryHostState, record.Binding) } diff --git a/module/services/accounts/code/pkg/business/derived_module_access.go b/module/services/accounts/code/pkg/business/derived_module_access.go index 7e11e4c1d..f8d4497fd 100644 --- a/module/services/accounts/code/pkg/business/derived_module_access.go +++ b/module/services/accounts/code/pkg/business/derived_module_access.go @@ -55,7 +55,7 @@ import ( // # How it is revoked, with its cause // // RevokeInstallationFromTeam revokes every derived grant of the grant it is -// removing, in the same policy-logged narrowing, before the cause goes. +// removing, in the same narrowing transaction, before the cause goes. // UninstallSolution revokes every derived grant whose cause is a grant at that // installation's authority root. Both go through revokeScopeApply, so each // carries the same scope.revoked event and audit record as any other @@ -400,10 +400,9 @@ func declaredLabels(resources, actions []string) []string { // revokeScopeApply the cause's own revocation runs, so each carries the scope // revocation's event and audit record instead of disappearing silently. // -// Runs inside the caller's policy-logged narrowing: the derived rows and the -// cause go in one transaction, witnessed by one entry. A revocation that -// witnessed the cause and lost the derived rows to a later failure is not a -// state this can reach. +// Runs inside the caller's narrowing: the derived rows and the cause go in one +// transaction. A revocation that removed the cause and lost the derived rows to +// a later failure is not a state this can reach. func (s *Service) revokeDerivedGrantsTx(ctx context.Context, actorID, orgID, causeGrantID, causeScopeNodeID string) error { derived, err := s.installationStore().DerivedScopeGrants(ctx, orgID, causeGrantID, causeScopeNodeID) if err != nil { diff --git a/module/services/accounts/code/pkg/business/executiontest/cluster.go b/module/services/accounts/code/pkg/business/executiontest/cluster.go index 760378f05..c70e5f4e4 100644 --- a/module/services/accounts/code/pkg/business/executiontest/cluster.go +++ b/module/services/accounts/code/pkg/business/executiontest/cluster.go @@ -16,7 +16,9 @@ package executiontest import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "sync" "time" @@ -80,13 +82,13 @@ func (c *Cluster) Run(workload Workload) string { // RunWithAudience is Run with the projected token minted for another audience, // the way a pod's token for some other service would be. func (c *Cluster) RunWithAudience(workload Workload, audience string) string { - return c.run("projected."+random(16), workload, audience) + return c.run(projectedToken(audience), workload, audience) } // RunWithLifetime is Run with a projected token valid for lifetime, the way a // projection rendered with another expirationSeconds would be. func (c *Cluster) RunWithLifetime(workload Workload, lifetime time.Duration) string { - token := c.run("projected."+random(16), workload, Audience) + token := c.run(projectedToken(Audience), workload, Audience) c.mu.Lock() defer c.mu.Unlock() issued := c.tokens[token] @@ -234,3 +236,13 @@ var _ business.ExecutionReviewer = (*Cluster)(nil) // NativeDevelopment identifies this in-memory cluster fixture; it models no mesh. func (*Cluster) NativeDevelopment() bool { return true } + +// projectedToken is shaped as the kubelet's projected token is, a compact JWS +// whose claims name its audience, so a workload reads the audience it was +// issued for (a hop's registration proof names it). The fake reviews a token +// by its exact text and verifies no signature. +func projectedToken(audience string) string { + encode := base64.RawURLEncoding.EncodeToString + claims, _ := json.Marshal(map[string]any{"aud": []string{audience}, "jti": random(16)}) + return encode([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + encode(claims) + "." + encode([]byte(random(32))) +} diff --git a/module/services/accounts/code/pkg/business/fixture_exposure_integration_test.go b/module/services/accounts/code/pkg/business/fixture_exposure_integration_test.go index 5a6be597e..12e46cff4 100644 --- a/module/services/accounts/code/pkg/business/fixture_exposure_integration_test.go +++ b/module/services/accounts/code/pkg/business/fixture_exposure_integration_test.go @@ -55,6 +55,7 @@ func wireSignedDelivery(t *testing.T) *solutionhost.InMemorySigner { }) require.NoError(t, err) testService.SetCompositionReconcile(reconciler.RunOnce) + legacyReconcile = reconciler.RunOnce t.Cleanup(func() { testService.SetSolutionDelivery(nil, nil, nil) testService.SetAuthorityDocumentVerifier(nil) @@ -63,8 +64,14 @@ func wireSignedDelivery(t *testing.T) *solutionhost.InMemorySigner { return signer } +// legacyReconcile is the reconcile pass wireSignedDelivery wired. +var legacyReconcile func(context.Context) error + // deliverSolution applies one signed solution delivery, with its authority -// document carrying roles when roles are given. +// document carrying roles when roles are given. A solution delivered without +// roles is a legacy delivery the composition interface refuses (a solution +// declares an installer role, the authority core's contract), so it arrives +// through the HTTP delivery endpoint and the reconcile pass, as it did. func deliverSolution(t *testing.T, signer *solutionhost.InMemorySigner, alias string, generation uint64, roles []*compositionv1.RoleV1) { t.Helper() m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: alias, Alias: alias, Kind: solutionhost.KindSolution, Generation: generation, @@ -72,6 +79,12 @@ func deliverSolution(t *testing.T, signer *solutionhost.InMemorySigner, alias st Workloads: []solutionhost.Workload{{Service: "api", Container: "app"}}, Authority: &solutionhost.Authority{Principal: alias, ResourceKinds: []string{"doc"}}} raw, err := solutionhost.SignedPayload(m) require.NoError(t, err) + if roles == nil { + _, _, err = testService.ReceiveSolutionDelivery(testCtx, deliverytest.Carrier(t, raw, signer.KeyID()), "Bearer delivery:"+alias) + require.NoError(t, err) + require.NoError(t, legacyReconcile(testCtx)) + return + } req := &compositionv1.ApplyManifestRequest{Carrier: &compositionv1.SignedManifestV1{Document: raw, Envelope: deliverytest.Envelope(t, raw, signer.KeyID())}, Credential: "delivery:" + alias} if roles != nil { doc, err := proto.Marshal(&compositionv1.AuthorityManifestV1{Module: alias, Generation: generation, CoreManifestDigest: delivery.Digest(raw), Roles: roles, diff --git a/module/services/accounts/code/pkg/business/hop_admission_keys.go b/module/services/accounts/code/pkg/business/hop_admission_keys.go index 1efd022c9..939df1818 100644 --- a/module/services/accounts/code/pkg/business/hop_admission_keys.go +++ b/module/services/accounts/code/pkg/business/hop_admission_keys.go @@ -1,6 +1,6 @@ package business -// The hop's admission keys (codefly.dev/authority@0.3.3, RegisterAdmissionKey). +// The hop's admission keys (codefly.dev/authority@0.3.4, RegisterAdmissionKey). // // The composition's hop signs each call it admits with a key of its own, and // every module behind it verifies that admission by the public half, which it @@ -15,11 +15,11 @@ package business import ( "context" "crypto/ed25519" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "time" + + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" ) // AdmissionKeyLease is how long a registered admission key stays published @@ -32,22 +32,6 @@ const AdmissionKeyLease = 2 * time.Minute // and a few more absorb rotations closer together than one lease. const MaxAdmissionKeys = 8 -// AdmissionKeyID is the id a hop signs its admissions under, derived from the -// public key so the same seed always publishes the same entry: "hop-" and the -// first eight bytes of the key's SHA-256, in hex. -// -// Labelled stopgap: this restates stamp.KeyID (codefly-dev/authority -// libraries/authority-client/go/stamp at 49101ae1), the one derivation the hop -// signs with, instead of importing it. That revision of the authority client -// no longer builds with the conformance suite revision this module's harness -// pins (6870db87, the suite's 0.2 contract), and the suite at 49101ae1 asserts -// contracts this host does not serve yet. TestAdmissionKeyIDIsTheStampDerivation -// pins a vector stamp.KeyID produced; import stamp.KeyID once the harness moves. -func AdmissionKeyID(public ed25519.PublicKey) string { - sum := sha256.Sum256(public) - return "hop-" + hex.EncodeToString(sum[:8]) -} - // HopModule and HopService name the one workload whose admission keys this // host publishes: the hop service of the composition's authority module, the // same pair the local authority admits. @@ -75,9 +59,27 @@ var ErrAdmissionKeysFull = errors.New("the published admission keys are at their // publishes none and registers none, and says so. var ErrAdmissionKeysUnwired = errors.New("this host has no admission key store wired") +// ErrAdmissionKeyProof refuses a registration whose proof does not verify, +// names another credential, key or audience, is stale or ahead of the clock, +// or repeats a nonce already used (stamp.CheckRegistration). +var ErrAdmissionKeyProof = errors.New("the admission key's registration proof is refused") + +// ErrAdmissionKeyPinned refuses a key for a hop instance that holds another +// until its registration lapses: one key is pinned per hop instance. +var ErrAdmissionKeyPinned = errors.New("the hop instance holds another admission key") + +// AdmissionRegistration is what one registration binds beside its key: the +// hop instance (its pod) the key is pinned to, and the proof's nonce, used +// once until it could no longer be judged fresh. +type AdmissionRegistration struct { + Instance string + Nonce string + NonceUntil time.Time +} + // AdmissionKey is one published hop admission key. type AdmissionKey struct { - // ID is AdmissionKeyID of the key: the id the hop signs under. + // ID is stamp.KeyID of the key: the id the hop signs under. ID string PublicKey ed25519.PublicKey // ExpiresAt is when the registration lapses unless renewed. @@ -87,11 +89,14 @@ type AdmissionKey struct { // AdmissionKeyStore persists registrations. Both methods judge a lease by the // store's clock, so replicas whose clocks differ publish the same set. type AdmissionKeyStore interface { - // RegisterAdmissionKey publishes key for lease from now, renewing it when - // it is already published, and answers when it lapses and whether this - // call began publishing it (false for a renewal). It refuses - // ErrAdmissionKeysFull when max other keys are published and unlapsed. - RegisterAdmissionKey(ctx context.Context, key AdmissionKey, lease time.Duration, max int) (expires time.Time, published bool, err error) + // RegisterAdmissionKey reserves the registration's nonce and publishes key + // for lease from now, pinned to the registration's instance, renewing it + // when that instance already holds it, and answers when it lapses and + // whether this call began publishing it (false for a renewal). It refuses + // ErrAdmissionKeyProof for a nonce already used, ErrAdmissionKeyPinned when + // the instance holds another unlapsed key, and ErrAdmissionKeysFull when max + // other keys are published and unlapsed. + RegisterAdmissionKey(ctx context.Context, key AdmissionKey, registration AdmissionRegistration, lease time.Duration, max int) (expires time.Time, published bool, err error) // AdmissionKeys are the keys whose lease has not lapsed, by key id. AdmissionKeys(ctx context.Context) ([]AdmissionKey, error) } @@ -102,8 +107,12 @@ func (s *Service) SetAdmissionKeyStore(store AdmissionKeyStore) { s.admissionKey // RegisterAdmissionKey records the public key the hop signs call admissions // with and publishes it until the answered time; registering it again renews // it. The caller is attested by its workload credential, and any workload but -// the hop is refused by name. -func (s *Service) RegisterAdmissionKey(ctx context.Context, credential string, public []byte) (AdmissionKey, error) { +// the hop is refused by name. Each registration carries a fresh proof signed +// by the key it registers over that credential and this host's audience, used +// once; and one key is pinned per hop instance (its pod) until its lease +// lapses, so a captured hop credential cannot replace or add a key for an +// instance that has registered (interface 0.3.4). +func (s *Service) RegisterAdmissionKey(ctx context.Context, credential string, public []byte, proof string) (AdmissionKey, error) { reviewed, err := s.reviewWorkloadCredential(ctx, credential) if err != nil { return AdmissionKey{}, err @@ -113,15 +122,25 @@ func (s *Service) RegisterAdmissionKey(ctx context.Context, credential string, p return AdmissionKey{}, fmt.Errorf("%w: the caller is %s/%s, not the hop (%s/%s)", ErrAdmissionKeyNotTheHop, reviewed.Namespace, reviewed.ServiceAccount, hopNamespace, HopService) } + if reviewed.PodName == "" { + return AdmissionKey{}, fmt.Errorf("%w: the hop's credential names no pod, so no admission key can be pinned to it", ErrAdmissionKeyNotTheHop) + } if len(public) != ed25519.PublicKeySize { return AdmissionKey{}, fmt.Errorf("%w: %d bytes, not %d", ErrAdmissionKeyMalformed, len(public), ed25519.PublicKeySize) } + now := time.Now() + proved, err := stamp.CheckRegistration(proof, public, credential, executionTokenAudience, now) + if err != nil { + return AdmissionKey{}, fmt.Errorf("%w: %w", ErrAdmissionKeyProof, err) + } if s.admissionKeys == nil { return AdmissionKey{}, ErrAdmissionKeysUnwired } - key := AdmissionKey{ID: AdmissionKeyID(public), PublicKey: append(ed25519.PublicKey(nil), public...)} + registration := AdmissionRegistration{Instance: reviewed.PodName, Nonce: proved.Nonce, + NonceUntil: now.Add(stamp.RegistrationWindow + 2*stamp.Skew)} + key := AdmissionKey{ID: stamp.KeyID(public), PublicKey: append(ed25519.PublicKey(nil), public...)} err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { - expires, published, err := s.admissionKeys.RegisterAdmissionKey(ctx, key, AdmissionKeyLease, MaxAdmissionKeys) + expires, published, err := s.admissionKeys.RegisterAdmissionKey(ctx, key, registration, AdmissionKeyLease, MaxAdmissionKeys) if err != nil { return err } @@ -132,7 +151,7 @@ func (s *Service) RegisterAdmissionKey(ctx context.Context, credential string, p // A key every module behind the hop now trusts is recorded in the // same transaction that publishes it; a renewal is not. return s.emitTx(ctx, admissionKeyActor, ActorTypeSystem, EventHopAdmissionKeyPublished, "hop_admission_key", key.ID, "", map[string]any{ - "key_id": key.ID, "workload": reviewed.Namespace + "/" + reviewed.ServiceAccount, "expires_at": expires.UTC().Format(time.RFC3339), + "key_id": key.ID, "workload": reviewed.Namespace + "/" + reviewed.ServiceAccount, "instance": reviewed.PodName, "expires_at": expires.UTC().Format(time.RFC3339), }) }) if err != nil { diff --git a/module/services/accounts/code/pkg/business/installation_batch_lock_integration_test.go b/module/services/accounts/code/pkg/business/installation_batch_lock_integration_test.go index dffde85b7..17f4c2951 100644 --- a/module/services/accounts/code/pkg/business/installation_batch_lock_integration_test.go +++ b/module/services/accounts/code/pkg/business/installation_batch_lock_integration_test.go @@ -99,7 +99,6 @@ func TestDeleteOrganizationSerializesWithAnotherInstallationUninstall(t *testing cancel() workers.Wait() testService.SetStore(testStore) - closeEveryGap(t, testCtx) }) deleted, uninstalled := make(chan error, 1), make(chan error, 1) workers.Add(1) @@ -213,9 +212,7 @@ func TestDeleteOrganizationWaitsForUninstallBeforeMembershipDeletion(t *testing. cancel() workers.Wait() testService.SetStore(testStore) - closeEveryGap(t, testCtx) }) - mark := testPolicyLog.mark() uninstalled, deleted := make(chan error, 1), make(chan error, 1) require.NoError(t, testStore.WithControlPlane(ctx, func(barrierCtx context.Context) error { tx := storetx.Tx(barrierCtx) @@ -275,13 +272,6 @@ func TestDeleteOrganizationWaitsForUninstallBeforeMembershipDeletion(t *testing. require.NoError(t, uninstallErr, "standalone uninstall must complete without 40P01") require.NoError(t, deletionErr, "organization deletion must complete without 40P01") - entries := testPolicyLog.appended(mark) - require.Len(t, entries, 2, "both narrowings must be witnessed") - require.Equal(t, installationID, entries[0].SubjectID) - require.Equal(t, orgID, entries[1].SubjectID) - for _, entry := range entries { - requireCommitted(t, testCtx, entry.OperationID) // Assert before cleanup can close a gap. - } require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { archived, err := testStore.GetOrganization(ctx, orgID) if err != nil { diff --git a/module/services/accounts/code/pkg/business/installation_exposure.go b/module/services/accounts/code/pkg/business/installation_exposure.go index 1775029db..dd94ad457 100644 --- a/module/services/accounts/code/pkg/business/installation_exposure.go +++ b/module/services/accounts/code/pkg/business/installation_exposure.go @@ -343,9 +343,8 @@ func (s *Service) organizationInstallation(ctx context.Context, orgID, installat // CAUSED at the modules the solution reaches. // // The node is resolved in the organization's transaction, then the revocation is -// RevokeScope's own: the same policy-log entry and the same apply, so it is -// witnessed before it lands and refused — with the host's reason — when the log -// cannot witness it. +// RevokeScope's own: the same apply, in one narrowing transaction with its +// audit record. // // The derived grants go FIRST and in the same narrowing, so there is no moment, // and no failure, that leaves a carried grant alive after the grant that caused @@ -395,9 +394,7 @@ func (s *Service) RevokeInstallationFromTeam( ScopePath: path, RoleId: roleID, } - if err := s.WithPolicyLoggedNarrowing(ctx, revokeScopePolicyLogEntry( - actorID, orgID, teamID, req.SubjectKind.String(), path, roleID, - ), func(ctx context.Context) error { + if err := s.narrow(ctx, func(ctx context.Context) error { if causeGrantID != "" { if err := s.revokeDerivedGrantsTx(ctx, actorID, orgID, causeGrantID, ""); err != nil { return err diff --git a/module/services/accounts/code/pkg/business/installation_exposure_integration_test.go b/module/services/accounts/code/pkg/business/installation_exposure_integration_test.go index 321bbabac..c778f2054 100644 --- a/module/services/accounts/code/pkg/business/installation_exposure_integration_test.go +++ b/module/services/accounts/code/pkg/business/installation_exposure_integration_test.go @@ -203,10 +203,9 @@ func TestGrantInstallationToTeamRefusals(t *testing.T) { require.Empty(t, entitledInstallations(t, w.otherOrg, w.otherAdmin)) } -// Revoking is a narrowing under the policy log: with a witness it hides the -// solution from the team again; with none, the host refuses in its own words and -// the team keeps what it was granted. -func TestRevokeInstallationFromTeamIsAWitnessedNarrowing(t *testing.T) { +// Revoking is a narrowing and applies directly: the next read no longer offers +// the solution to the team, and the revocation is in the audit journal. +func TestRevokeInstallationFromTeamAppliesDirectly(t *testing.T) { w := newExposureWorld(t) server := &adapters.InstallationServer{} admin := storyCallerContext(testCtx, w.admin, w.org) @@ -218,19 +217,16 @@ func TestRevokeInstallationFromTeamIsAWitnessedNarrowing(t *testing.T) { } _, err := server.GrantInstallationToTeam(admin, grant) require.NoError(t, err) + require.Equal(t, []string{w.installation}, entitledInstallations(t, w.org, w.inTeam)) - testService.SetPolicyLog(nil, nil) - t.Cleanup(func() { wirePolicyLogForTests(testService, testStore) }) - _, err = server.RevokeInstallationFromTeam(admin, revoke) - require.Equal(t, codes.FailedPrecondition, status.Code(err)) - require.Contains(t, status.Convert(err).Message(), "this host has no policy log, so authority cannot be narrowed") - require.Equal(t, []string{w.installation}, entitledInstallations(t, w.org, w.inTeam), - "an unwitnessed revoke changed nothing") - - wirePolicyLogForTests(testService, testStore) _, err = server.RevokeInstallationFromTeam(admin, revoke) require.NoError(t, err) require.Empty(t, entitledInstallations(t, w.org, w.inTeam), "revoking hides it again") + audit, _, _, err := testService.QueryAuditLog(testCtx, business.AuditQuery{ + OrgID: w.org, EventType: string(business.EventScopeRevoked), PageSize: 10, + }) + require.NoError(t, err) + require.NotEmpty(t, audit, "the revocation is recorded in the audit journal") } // requireARowLockWaiter proves two transactions genuinely contend on a row diff --git a/module/services/accounts/code/pkg/business/installation_registry.go b/module/services/accounts/code/pkg/business/installation_registry.go index f92ba241b..ad6c7f5bb 100644 --- a/module/services/accounts/code/pkg/business/installation_registry.go +++ b/module/services/accounts/code/pkg/business/installation_registry.go @@ -118,8 +118,10 @@ func consentedInstallationScope(kind string, grant ModulePrincipalGrant, envelop // NarrowInstallationScope is a human administration act. Authorization belongs // at the adapter like InstallSolution; the store compares the expected revision -// and the old scope under the row lock. Policy receipt, scope/revision and -// cancellation commit together. A no-op does not advance the revision. +// and the old scope under the row lock. The scope, the revision, the +// cancellation of its work and the audit record commit together, and the +// authority core reads the new consent at its next issue. A no-op does not +// advance the revision. func (s *Service) NarrowInstallationScope(ctx context.Context, actorID, orgID, id string, revision uint64, scope InstallationScope) error { store, ok := s.store.(InstallationScopeStore) if !ok { @@ -151,18 +153,13 @@ func (s *Service) NarrowInstallationScope(ctx context.Context, actorID, orgID, i if InstallationScopeWithin(current.Scope, scope) { return nil } - entry := uninstallPolicyLogEntry(actorID, orgID, id) - // The complete resulting scope belongs in the log, never only an increment. - raw, _ := json.Marshal(scope) - var policy map[string]any - if err := json.Unmarshal(raw, &policy); err != nil { + // The complete resulting scope is recorded, never only an increment. + raw, err := json.Marshal(scope) + if err != nil { return err } - entry.OperationID = newPolicyLogOperationID("narrow-installation") - entry.EnvelopeRevision = snapshot.envelopeRevision - entry.Policy = map[string]any{"org_id": orgID, "status": "active", "scope": policy, "revision": revision + 1} actorType := s.actorTypeForCreator(ctx, actorID) - return s.WithPolicyLoggedNarrowing(ctx, entry, func(ctx context.Context) error { + return s.narrow(ctx, func(ctx context.Context) error { if err := store.NarrowInstallationScope(ctx, orgID, id, revision, scope, snapshot.registry, snapshot.envelopeRevision); err != nil { return err } diff --git a/module/services/accounts/code/pkg/business/installation_scope_integration_test.go b/module/services/accounts/code/pkg/business/installation_scope_integration_test.go index d7c31f634..e7169502a 100644 --- a/module/services/accounts/code/pkg/business/installation_scope_integration_test.go +++ b/module/services/accounts/code/pkg/business/installation_scope_integration_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestInstallationScopeInstallNarrowAuditAndPolicyLog(t *testing.T) { +func TestInstallationScopeInstallNarrowAndAudit(t *testing.T) { clearData(t) owner, org := mustUserAndOrg(t, testCtx, "scope@example.com", "scope-admin", "Acme Scope") role := seedInstallableRole(t, testCtx, org) @@ -41,31 +41,14 @@ func TestInstallationScopeInstallNarrowAuditAndPolicyLog(t *testing.T) { return out } require.Equal(t, scope, state().Scope) - mark := testPolicyLog.mark() wider := business.FullInstallationScope(grant) require.Error(t, testService.NarrowInstallation(testCtx, owner, org, installed.Id, wider)) - require.Empty(t, testPolicyLog.appended(mark)) require.Equal(t, uint64(1), state().Revision) scope.Queues = nil - testPolicyLog.mu.Lock() - testPolicyLog.failWith = errPolicyLogDown - testPolicyLog.mu.Unlock() - err = testService.NarrowInstallation(testCtx, owner, org, installed.Id, scope) - testPolicyLog.mu.Lock() - testPolicyLog.failWith = nil - testPolicyLog.mu.Unlock() - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) - require.Equal(t, uint64(1), state().Revision) + // A narrowing applies directly: no witness is asked first. require.NoError(t, testService.NarrowInstallation(testCtx, owner, org, installed.Id, scope)) require.Equal(t, uint64(2), state().Revision) require.Equal(t, scope, state().Scope) - entries := testPolicyLog.appended(mark) - require.Len(t, entries, 1) - require.Equal(t, installed.Id, entries[0].SubjectID) - require.Equal(t, business.PolicyLogNarrowed, entries[0].Decision) - require.Equal(t, "active", entries[0].Policy["status"]) - require.Contains(t, entries[0].Policy, "scope") - require.Empty(t, uncommittedOperations(t, testCtx)) audit, _, _, err := testService.QueryAuditLog(testCtx, business.AuditQuery{OrgID: org, EventType: string(business.EventInstallationNarrowed), PageSize: 10}) require.NoError(t, err) require.Len(t, audit, 1) @@ -74,7 +57,9 @@ func TestInstallationScopeInstallNarrowAuditAndPolicyLog(t *testing.T) { require.Equal(t, "2", audit[0].Payload["revision"]) require.NoError(t, testService.NarrowInstallation(testCtx, owner, org, installed.Id, scope)) require.Equal(t, uint64(2), state().Revision, "a no-op does not cancel work again") - require.Len(t, testPolicyLog.appended(mark), 1) + audit, _, _, err = testService.QueryAuditLog(testCtx, business.AuditQuery{OrgID: org, EventType: string(business.EventInstallationNarrowed), PageSize: 10}) + require.NoError(t, err) + require.Len(t, audit, 1, "a no-op narrowing records nothing") _, foreignOrg := mustUserAndOrg(t, testCtx, "foreign-scope@example.com", "foreign-scope", "ExampleCorp") require.Error(t, testService.NarrowInstallation(testCtx, owner, foreignOrg, installed.Id, scope)) require.Equal(t, uint64(2), state().Revision) diff --git a/module/services/accounts/code/pkg/business/installations.go b/module/services/accounts/code/pkg/business/installations.go index 1047c8b6c..d3de89418 100644 --- a/module/services/accounts/code/pkg/business/installations.go +++ b/module/services/accounts/code/pkg/business/installations.go @@ -22,7 +22,7 @@ type InstallSolutionParams struct { Scope *InstallationScope // ModuleCeiling is resolved by prepareInstallation, never from the request. // Re-consent may approve this current ceiling; partial reductions use the - // witnessed NarrowInstallation lifecycle instead. + // NarrowInstallation lifecycle instead. ModuleCeiling *InstallationScope EnvelopeRevision uint64 // InstallerPrincipalID records ownership for bounded nonhuman reconciliation. @@ -344,27 +344,16 @@ func (s *Service) TransferInstallationOwnership(ctx context.Context, actorID, or // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation (no second audit event). // -// It is a NARROWING, so it runs under the policy log: the entry is appended to -// the external record and its receipt is written here BEFORE anything is -// revoked, and the revocation commits in the same transaction as the receipt's -// commit. A host that cannot witness the append refuses the uninstall rather -// than performing one a restore could silently undo. -// -// The transaction is the policy log's CONTROL-PLANE one rather than this -// organisation's, because the receipt lives in a control-plane relation that -// app_tenant holds no grant on — and the receipt and the revocation have to be -// the same transaction or the protocol's one forbidden state ("applied but -// unwitnessed") becomes reachable. The organisation's own scoping is not lost: -// every statement below names orgID explicitly, which is what the tenant policy -// would have checked. +// It is a NARROWING, so it applies directly (narrow): every revocation and its +// audit event commit in one control-plane transaction. The organisation's own +// scoping is not lost: every statement below names orgID explicitly, which is +// what the tenant policy would have checked. func (s *Service) UninstallSolution(ctx context.Context, actorID, orgID, installationID string) error { w := wool.Get(ctx).In("UninstallSolution", wool.Field("installation_id", installationID)) actorType := s.actorTypeForCreator(ctx, actorID) - if err := s.WithPolicyLoggedNarrowing(ctx, - uninstallPolicyLogEntry(actorID, orgID, installationID), - func(ctx context.Context) error { - return s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID) - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + return s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID) + }); err != nil { return w.Wrapf(err, "cannot uninstall solution") } return nil diff --git a/module/services/accounts/code/pkg/business/interface_policies.go b/module/services/accounts/code/pkg/business/interface_policies.go index b9643161c..bdfb8db12 100644 --- a/module/services/accounts/code/pkg/business/interface_policies.go +++ b/module/services/accounts/code/pkg/business/interface_policies.go @@ -48,8 +48,8 @@ const HostApplyManifestMethod = "/host.composition.v1.CompositionService/ApplyMa const HostRegisterAdmissionKeyMethod = "/host.authority.v1.AuthorityService/RegisterAdmissionKey" // The installation-owned resource methods (authority 0.3.0), each called by a -// kind's serving module with its workload credential. This host refuses them -// by name until it records installation-owned resources. +// kind's serving module with its workload credential, decided by the +// authority core. const ( HostAttachResourceMethod = "/host.authority.v1.AuthorityService/AttachResource" HostDetachResourceMethod = "/host.authority.v1.AuthorityService/DetachResource" @@ -130,9 +130,16 @@ func InterfaceMethodPolicies() map[string]*policyv1.MethodPolicy { p.ResponseSensitivity = policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL return p } - // The policy describes the grant owner's transactional emission; it does - // not emit a second event at the interface adapter. - exposureGrant := adminWrite("saas.scope.granted") + // A role change is an administrator's or the installation's installer's, + // at the organization the session was opened at: the core decides both, + // answering another organization's installation and an absent one alike, + // so the perimeter adds no tenant floor that would answer differently. + // The core journals the change in its own transaction. + roleChange := func(event EventType) *policyv1.MethodPolicy { + p := adminWrite(string(event)) + p.Tenant = policyv1.TenantRequirement_TENANT_REQUIREMENT_NONE + return p + } // A workload's call authenticated by the credential in its request, from // inside the cell (ServedInClusterOnly). workloadCall := func(request policyv1.Sensitivity) *policyv1.MethodPolicy { @@ -147,12 +154,24 @@ func InterfaceMethodPolicies() map[string]*policyv1.MethodPolicy { PlatformRole: policyv1.PlatformRoleRequirement_PLATFORM_ROLE_REQUIREMENT_NONE, } } + // A resource change is journaled by the core in the transaction that makes it. + resourceChange := func(event EventType) *policyv1.MethodPolicy { + p := workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET) + p.Audit = &policyv1.AuditPolicy{Events: []string{string(event)}, Emission: policyv1.AuditEmission_AUDIT_EMISSION_SUCCESS} + return p + } admissionKeyRegistration := workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET) admissionKeyRegistration.Audit = &policyv1.AuditPolicy{ Events: []string{string(EventHopAdmissionKeyPublished)}, Emission: policyv1.AuditEmission_AUDIT_EMISSION_SUCCESS, } return map[string]*policyv1.MethodPolicy{ - "/host.authority.v1.AuthorityService/Issue": authenticatedMint("saas.work_context.task_started"), + // Every issue is recorded before it is handed out: a child hop as a child + // session, every other shape as a task started. + "/host.authority.v1.AuthorityService/Issue": func() *policyv1.MethodPolicy { + p := authenticatedMint(string(EventWorkContextTaskStarted)) + p.Audit.Events = append(p.Audit.Events, string(EventWorkContextChildSession)) + return p + }(), // Check is authenticated by the token it checks: the caller is whoever holds it, // and the answer says nothing the token does not already carry but its standing. "/host.authority.v1.AuthorityService/Check": read(policyv1.Exposure_EXPOSURE_PUBLIC, policyv1.TenantRequirement_TENANT_REQUIREMENT_NONE, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), @@ -161,9 +180,9 @@ func InterfaceMethodPolicies() map[string]*policyv1.MethodPolicy { // key travels. A key's first publication is audited in the transaction // that publishes it; its renewal every lease is not an event. HostRegisterAdmissionKeyMethod: admissionKeyRegistration, - HostAttachResourceMethod: workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET), - HostDetachResourceMethod: workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET), - HostAuthorizeTransferMethod: workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET), + HostAttachResourceMethod: resourceChange(EventAuthorityResourceAttached), + HostDetachResourceMethod: resourceChange(EventAuthorityResourceDetached), + HostAuthorizeTransferMethod: resourceChange(EventAuthorityResourceTransferred), // ListResources is authenticated by the capability it is asked about, like Check. "/host.authority.v1.AuthorityService/ListResources": read(policyv1.Exposure_EXPOSURE_PUBLIC, policyv1.TenantRequirement_TENANT_REQUIREMENT_NONE, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), // A delegation names no organisation: it is the bearer's, so the floor is @@ -184,9 +203,19 @@ func InterfaceMethodPolicies() map[string]*policyv1.MethodPolicy { ResponseSensitivity: policyv1.Sensitivity_SENSITIVITY_INTERNAL, PlatformRole: policyv1.PlatformRoleRequirement_PLATFORM_ROLE_REQUIREMENT_NONE, }, - "/host.installation.v1.InstallationService/Install": adminWrite("saas.installation.created"), - "/host.installation.v1.InstallationService/GrantToTeam": exposureGrant, - "/host.installation.v1.InstallationService/List": read(policyv1.Exposure_EXPOSURE_AUTHENTICATED, policyv1.TenantRequirement_TENANT_REQUIREMENT_ORG_ADMIN, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), // an administrator's read; a member's view is the entitlement listing + "/host.installation.v1.InstallationService/Install": func() *policyv1.MethodPolicy { + p := adminWrite(string(EventAuthorityInstallationRequested)) + p.Audit.Events = append(p.Audit.Events, string(EventAuthorityInstallationActivated)) + return p + }(), + // A module authenticates by its workload credential and a current parent + // addressed to it; it reads its own installation's metadata and nothing more. + "/host.installation.v1.InstallationService/Current": workloadCall(policyv1.Sensitivity_SENSITIVITY_SECRET), + "/host.installation.v1.InstallationService/Review": read(policyv1.Exposure_EXPOSURE_AUTHENTICATED, policyv1.TenantRequirement_TENANT_REQUIREMENT_ORG_ADMIN, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), + "/host.installation.v1.InstallationService/GrantRole": roleChange(EventAuthorityRoleGranted), + "/host.installation.v1.InstallationService/RevokeRole": roleChange(EventAuthorityRoleRevoked), + "/host.installation.v1.InstallationService/ListRoleGrants": read(policyv1.Exposure_EXPOSURE_AUTHENTICATED, policyv1.TenantRequirement_TENANT_REQUIREMENT_NONE, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), + "/host.installation.v1.InstallationService/List": read(policyv1.Exposure_EXPOSURE_AUTHENTICATED, policyv1.TenantRequirement_TENANT_REQUIREMENT_ORG_ADMIN, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), // an administrator's read; a member's view is the entitlement listing // Current is the caller's own session: authenticated by the bearer it // describes, organisation-less like the session itself may be. "/host.session.v1.SessionService/Current": read(policyv1.Exposure_EXPOSURE_AUTHENTICATED, policyv1.TenantRequirement_TENANT_REQUIREMENT_NONE, policyv1.Sensitivity_SENSITIVITY_CONFIDENTIAL), @@ -230,9 +259,15 @@ const ServiceAPIPackage = "saas.accounts.v1" // implementedHere reports whether this service serves a descriptor. Every // accounts service is served; of the interface packages, InstallableService is -// the solution's side of the installation interface and is not served here. +// the solution's side of the installation interface, and OfferService is the +// local authority's setup read beside it (codefly.dev/installation declares +// InstallationService alone), so neither is served here. func implementedHere(service protoreflect.ServiceDescriptor) bool { - return string(service.FullName()) != "host.installation.v1.InstallableService" + switch string(service.FullName()) { + case "host.installation.v1.InstallableService", "host.installation.v1.OfferService": + return false + } + return true } // interfacePackage reports whether a proto package is one of the published host @@ -252,12 +287,16 @@ var interfaceDescriptions = map[string]string{ "AuthorityService/Check": "Verify a presented Work Context for the audience the caller is, re-checking the revision and the seal live.", "AuthorityService/Keys": "Return the issuer and the public keys a verifier pins: the Work Context keys and, apart from them, the hop's admission keys.", "AuthorityService/RegisterAdmissionKey": "Publish the hop's admission public key in Keys for one lease; only the hop's workload registers one.", - "AuthorityService/AttachResource": "Record that one resource belongs to an installation; refused by name until this host records installation-owned resources.", - "AuthorityService/DetachResource": "Release one resource from the installation that owns it; refused by name until this host records installation-owned resources.", - "AuthorityService/AuthorizeTransfer": "Decide whether an existing resource may join an installed solution's principal; refused by name until this host records installation-owned resources.", + "AuthorityService/AttachResource": "Record that one resource created through an installation belongs to it; the kind's serving module asks.", + "AuthorityService/DetachResource": "Release one deleted resource from the installation that owns it; the kind's serving module asks.", + "AuthorityService/AuthorizeTransfer": "Decide whether an existing resource may join an installed solution's principal, and record it.", "CompositionService/ApplyManifest": "Deliver one module instance's manifest at one generation; the ceiling is the manifest.", - "InstallationService/Install": "Install one target at one organisation under the least role it needs; pending until its install step is acknowledged.", - "InstallationService/GrantToTeam": "Expose an installation to a team under the installation's role.", + "InstallationService/Current": "Read the authenticated module's own active installation in the tenant of a current parent addressed to it.", + "InstallationService/Install": "Install one solution at one organisation under the consent its administrator reviewed; pending until its install step converges.", + "InstallationService/Review": "Read the exact authority offer an administrator consents to, without recording consent.", + "InstallationService/GrantRole": "Grant a person or a team one of an installed solution's roles.", + "InstallationService/RevokeRole": "Revoke one role grant; its subject's capabilities through the installation end.", + "InstallationService/ListRoleGrants": "List who holds which role on one installation.", "InstallationService/List": "List what one organisation has installed.", "AuthorityService/ListResources": "Answer which resources of one kind a checked capability covers for one action, from its owner's current grants.", "AuthorityService/Delegate": "Record a signed-in person's grant to a module, within what they hold.", diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index 05ea48f06..6b7fe3e2f 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -160,7 +160,7 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "InstallationService/ListAvailableSolutions": "List the solution targets an administrator may install: accepted applied presence, never a refused desired generation.", "InstallationService/ListOrganizationInstallations": "List the caller's organization's active installations with health, installed target, authority root and the teams it is exposed to.", "InstallationService/GrantInstallationToTeam": "Expose an installation to a team: grant a role permitting solution use at the installation's authority root.", - "InstallationService/RevokeInstallationFromTeam": "Remove a team's grant at an installation's authority root, under the policy log.", + "InstallationService/RevokeInstallationFromTeam": "Remove a team's grant at an installation's authority root.", "SolutionEntitlementService/ListSolutionEntitlements": "List the installed solutions a subject may use in one organization.", "DelegationService/ListPendingDelegations": "List pending organization delegations.", "DelegationService/RequestDelegation": "Request a scoped authority delegation.", diff --git a/module/services/accounts/code/pkg/business/introspection_test.go b/module/services/accounts/code/pkg/business/introspection_test.go index 1d61d9da2..93c1e8c0c 100644 --- a/module/services/accounts/code/pkg/business/introspection_test.go +++ b/module/services/accounts/code/pkg/business/introspection_test.go @@ -211,8 +211,10 @@ func TestIntrospection_RPCListMatchesDescriptors(t *testing.T) { } for i := 0; i < file.Services().Len(); i++ { service := file.Services().Get(i) - if string(service.FullName()) == "host.installation.v1.InstallableService" { - continue // the solution's side of the installation interface; not served here + // The solution's side of the installation interface, and the local + // authority's setup read beside it: not served here. + if name := string(service.FullName()); name == "host.installation.v1.InstallableService" || name == "host.installation.v1.OfferService" { + continue } for j := 0; j < service.Methods().Len(); j++ { wanted[string(service.Name())+"/"+string(service.Methods().Get(j).Name())] = struct{}{} diff --git a/module/services/accounts/code/pkg/business/layered_access.go b/module/services/accounts/code/pkg/business/layered_access.go index 6acdc8eb2..9b0e277a4 100644 --- a/module/services/accounts/code/pkg/business/layered_access.go +++ b/module/services/accounts/code/pkg/business/layered_access.go @@ -188,21 +188,13 @@ func (s *Service) grantScopeTx(ctx context.Context, actorID string, req *gen.Gra // RevokeScope removes a hierarchical scope grant. // -// A NARROWING, so it runs under the policy log: appended and receipted before -// the grant goes, and the delete commits in the same transaction as the -// receipt's commit. A host that cannot witness the append refuses rather than -// revoking unwitnessed — a revocation a restore could silently undo is worse -// than a refusal the caller can see. -// -// The transaction is the policy log's control-plane one, because the receipt -// relation is control-plane only and the two writes must be atomic. The -// statement below names req.OrgId explicitly, so the delete stays confined to -// the organisation the tenant policy would have confined it to. +// A NARROWING, so it applies directly (narrow): the delete and its audit +// record commit in one control-plane transaction. The statement below names +// req.OrgId explicitly, so the delete stays confined to the organisation the +// tenant policy would have confined it to. func (s *Service) RevokeScope(ctx context.Context, actorID string, req *gen.RevokeScopeRequest) error { w := wool.Get(ctx).In("RevokeScope") - if err := s.WithPolicyLoggedNarrowing(ctx, revokeScopePolicyLogEntry( - actorID, req.OrgId, req.SubjectId, req.SubjectKind.String(), req.ScopePath, req.RoleId, - ), func(ctx context.Context) error { + if err := s.narrow(ctx, func(ctx context.Context) error { return s.revokeScopeApply(ctx, actorID, req) }); err != nil { return w.Wrapf(err, "cannot revoke scope") @@ -210,11 +202,10 @@ func (s *Service) RevokeScope(ctx context.Context, actorID string, req *gen.Revo return nil } -// revokeScopeApply is one scope revocation inside the policy log's narrowing -// transaction: the delete, the tenant-visible scope.revoked event and the audit -// record. RevokeScope and RevokeInstallationFromTeam both run it, under the same -// entry builder, so a team removed from an installation is witnessed and -// recorded exactly as any other scope revocation is. +// revokeScopeApply is one scope revocation inside a narrowing transaction: the +// delete, the tenant-visible scope.revoked event and the audit record. +// RevokeScope and RevokeInstallationFromTeam both run it, so a team removed +// from an installation is recorded exactly as any other scope revocation is. func (s *Service) revokeScopeApply(ctx context.Context, actorID string, req *gen.RevokeScopeRequest) error { if e := s.store.RevokeScope(ctx, req.OrgId, req.SubjectId, req.SubjectKind, req.ScopePath, req.RoleId); e != nil { return e @@ -274,21 +265,12 @@ func (s *Service) ShareRecord(ctx context.Context, actorID string, req *gen.Shar // RevokeShare removes a per-record share. // -// A NARROWING, so it runs under the policy log, exactly as RevokeScope above -// does: appended and receipted before the share goes, with the delete and the -// receipt's commit in one transaction, and a refusal rather than an unwitnessed -// revocation when the log cannot be reached. -// -// That transaction is the policy log's control-plane one, because the receipt -// relation is control-plane only and the two writes must be atomic. The delete -// names req.OrgId in its WHERE, so it stays confined to the organisation the -// tenant policy would have confined it to. +// A NARROWING, so it applies directly, exactly as RevokeScope above does. The +// delete names req.OrgId in its WHERE, so it stays confined to the organisation +// the tenant policy would have confined it to. func (s *Service) RevokeShare(ctx context.Context, actorID string, req *gen.RevokeShareRequest) error { w := wool.Get(ctx).In("RevokeShare") - if err := s.WithPolicyLoggedNarrowing(ctx, revokeSharePolicyLogEntry( - actorID, req.OrgId, req.ResourceType, req.ResourceId, - req.SubjectId, req.SubjectKind.String(), req.RoleId, - ), func(ctx context.Context) error { + if err := s.narrow(ctx, func(ctx context.Context) error { if err := s.store.RevokeShare(ctx, req.OrgId, req.ResourceType, req.ResourceId, req.SubjectId, req.SubjectKind, req.RoleId); err != nil { return err } diff --git a/module/services/accounts/code/pkg/business/module_delegation.go b/module/services/accounts/code/pkg/business/module_delegation.go deleted file mode 100644 index e162a421c..000000000 --- a/module/services/accounts/code/pkg/business/module_delegation.go +++ /dev/null @@ -1,184 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - "unicode/utf8" -) - -// Delegations to a module (migration 51): the host authority interface's -// Delegate. A signed-in person grants one composed module the right to act for -// them beyond any session, on scopes they hold. The module issues from it -// through a binding it declares; each issue re-reads the delegation and what -// its grantor holds then, so it never confers more than the grantor still -// holds. Revoking stamps the row once; every capability issued under it is -// sealed to it, so each fails its next verification, and nothing else the -// grantor holds moves. -// -// This is not a source delegation: that one binds a person's datasource to one -// binding of a module and confers the binding's declared delegation scopes; this -// one names scopes and no source, and any binding of the module may exercise it -// within them. - -// ModuleDelegation is one row of module_delegations. -type ModuleDelegation struct { - ID string - OrgID string - PrincipalID string // the grantor, whom every capability issued from it is for - ModulePrefix string - Scopes []ModuleOperationScope - Purpose string - CreatedAt time.Time - RevokedAt *time.Time - RevokedBy string -} - -// Active reports whether the delegation has not been revoked. -func (d *ModuleDelegation) Active() bool { return d != nil && d.RevokedAt == nil } - -// ModuleDelegationPurposeLimit is the longest purpose a delegation records, -// the bound migration 51 holds the column to. -const ModuleDelegationPurposeLimit = 1024 - -var ( - // ErrModuleDelegationNotFound: no delegation by that id that the caller - // may see. - ErrModuleDelegationNotFound = errors.New("module delegation not found") - // ErrModuleDelegationInvalid: a delegation that names no module, no - // grantor or nothing it grants. - ErrModuleDelegationInvalid = errors.New("a delegation names its organization, its grantor, the module granted and what it grants") - // ErrModuleDelegationPurposeTooLong: a purpose past - // ModuleDelegationPurposeLimit characters. - ErrModuleDelegationPurposeTooLong = fmt.Errorf("a delegation's purpose is at most %d characters", ModuleDelegationPurposeLimit) - // ErrModuleDelegationUnrevocable: this host cannot witness a revocation - // (no policy log is wired), so a delegation recorded here could never be - // ended. It records none: fail closed. - ErrModuleDelegationUnrevocable = errors.New("this host cannot witness a revocation, so it records no delegation") -) - -// ModuleDelegationsRevocable reports whether this host can end a delegation it -// records: a revocation is a narrowing, and a narrowing needs the policy log. -func (s *Service) ModuleDelegationsRevocable() bool { - return s.policyLog != nil && s.policyLogStore != nil -} - -// RecordModuleDelegation records one active delegation in its organization, -// and its grant on the audit spine, in one transaction. The caller has held -// what it grants to what the grantor holds. A host that could not revoke it -// records nothing. -func (s *Service) RecordModuleDelegation(ctx context.Context, d *ModuleDelegation) error { - if d == nil || d.OrgID == "" || d.PrincipalID == "" || strings.TrimSpace(d.ModulePrefix) == "" || len(d.Scopes) == 0 { - return ErrModuleDelegationInvalid - } - if utf8.RuneCountInString(d.Purpose) > ModuleDelegationPurposeLimit { - return ErrModuleDelegationPurposeTooLong - } - if !s.ModuleDelegationsRevocable() { - return ErrModuleDelegationUnrevocable - } - d.ID = NewIDString() - return s.store.WithOrgTx(ctx, d.OrgID, func(ctx context.Context) error { - if err := s.store.InsertModuleDelegation(ctx, d); err != nil { - return err - } - return s.emitTx(ctx, d.PrincipalID, "user", EventModuleDelegationGranted, "module_delegation", d.ID, d.OrgID, moduleDelegationPayload(d)) - }) -} - -func moduleDelegationPayload(d *ModuleDelegation) map[string]any { - scopes := make([]string, 0, len(d.Scopes)) - for _, scope := range d.Scopes { - entry := scope.ResourceKind + ":" + strings.Join(scope.Actions, ",") - if len(scope.ResourceIDs) > 0 { - entry += "[" + strings.Join(scope.ResourceIDs, ",") + "]" - } - scopes = append(scopes, entry) - } - return map[string]any{ - "delegation_id": d.ID, - "grantor_id": d.PrincipalID, - "module": d.ModulePrefix, - "scopes": scopes, - } -} - -// ModuleDelegation reads one delegation by id in orgID, or by id alone under -// the control plane when orgID is empty: a module proving its workload names -// no organization. The caller compares the module or the grantor itself. Nil -// when there is none. -func (s *Service) ModuleDelegation(ctx context.Context, orgID, id string) (*ModuleDelegation, error) { - var out []*ModuleDelegation - read := func(ctx context.Context) error { - var err error - out, err = s.store.ModuleDelegations(ctx, orgID, []string{id}) - return err - } - var err error - if orgID == "" { - err = s.store.WithControlPlane(ctx, read) - } else { - err = s.store.WithOrgTx(ctx, orgID, read) - } - if err != nil || len(out) == 0 { - return nil, err - } - return out[0], nil -} - -// ModuleDelegations reads, in orgID, the delegations among ids: the ones an -// actor chain names on its hops. An id that names none is not an error, since -// most hops act under no stored delegation. -func (s *Service) ModuleDelegations(ctx context.Context, orgID string, ids []string) ([]*ModuleDelegation, error) { - if orgID == "" || len(ids) == 0 { - return nil, nil - } - var out []*ModuleDelegation - err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { - var err error - out, err = s.store.ModuleDelegations(ctx, orgID, ids) - return err - }) - return out, err -} - -// RevokeModuleDelegation ends a delegation in orgID and returns it as it now -// stands; revoking one already revoked changes nothing. Its grantor may, and so -// may an administrator of the organisation (asAdmin, which the caller has -// established): anyone else is told there is none (ErrModuleDelegationNotFound), -// as GetDelegation tells them. -// -// A narrowing, so it runs under the policy log like every other revocation: -// witnessed before the row is stamped, the stamp, its audit record and the -// receipt committed in one transaction. It ends this delegation alone: every -// capability issued under it is sealed to it (DelegatedOperationBindingID) and -// fails its next verification, and nothing else its grantor holds moves. -func (s *Service) RevokeModuleDelegation(ctx context.Context, orgID, actorID, id string, asAdmin bool) (*ModuleDelegation, error) { - if orgID == "" || actorID == "" || id == "" { - return nil, ErrModuleDelegationNotFound - } - grantor := actorID - if asAdmin { - grantor = "" - } - var out *ModuleDelegation - err := s.WithPolicyLoggedNarrowing(ctx, revokeModuleDelegationPolicyLogEntry(actorID, orgID, id), func(ctx context.Context) error { - found, revoked, err := s.store.RevokeModuleDelegation(ctx, orgID, grantor, id, actorID) - if err != nil { - return err - } - if found == nil { - return ErrModuleDelegationNotFound - } - out = found - if !revoked { - return nil - } - payload := moduleDelegationPayload(found) - payload["revoked_by"] = actorID - return s.emitTx(ctx, actorID, "user", EventModuleDelegationRevoked, "module_delegation", found.ID, orgID, payload) - }) - return out, err -} diff --git a/module/services/accounts/code/pkg/business/module_envelope.go b/module/services/accounts/code/pkg/business/module_envelope.go index 530bb245f..3abb641b5 100644 --- a/module/services/accounts/code/pkg/business/module_envelope.go +++ b/module/services/accounts/code/pkg/business/module_envelope.go @@ -415,30 +415,19 @@ type moduleEnvelopeWrite struct { envelope *ModuleEnvelope record *ModuleEnvelopeRecord expectedRevision uint64 - // actorID and actorType attribute the policy-log entry and every - // installation the write revokes. + // actorID and actorType attribute every installation the write revokes. actorID, actorType string // audit writes the writer's own event, inside the row's transaction. audit func(ctx context.Context) error } // writeModuleEnvelope is the ONE writer of the ceiling, whoever wrote the -// revision: it classifies the write, witnesses a narrowing in the policy log, -// appends the row over the expected revision, revokes the installations the -// new ceiling no longer allows, commits the writer's audit event with the row, -// fences this replica and asks for a pass. Its callers authorise the writer -// and validate the document; nothing here relaxes for either of them. +// revision: it appends the row over the expected revision, revokes the +// installations the new ceiling no longer allows, commits the writer's audit +// event with the row in the same transaction, fences this replica and asks for +// a pass. Its callers authorise the writer and validate the document; nothing +// here relaxes for either of them. func (s *Service) writeModuleEnvelope(ctx context.Context, w moduleEnvelopeWrite) error { - var policy map[string]any - if err := json.Unmarshal(w.record.Document, &policy); err != nil { - return err - } - entry := &PolicyLogEntry{OperationID: newPolicyLogOperationID("module-envelope"), - Decision: PolicyLogNarrowed, SubjectKind: PolicyLogModuleEnvelope, SubjectID: "module-envelope", - Policy: policy, EnvelopeRevision: w.envelope.Revision, Actor: w.actorID} - // witnessed is set before apply runs under the policy log; apply refuses - // to revoke anything while it is false. - witnessed := false apply := func(ctx context.Context) error { if err := s.moduleEnvelopes.AppendModuleEnvelopeRecord(ctx, w.record, w.expectedRevision); err != nil { return err @@ -451,44 +440,15 @@ func (s *Service) writeModuleEnvelope(ctx context.Context, w moduleEnvelopeWrite if installationTenancyAllowed(w.envelope, installation.PrincipalID, installation.OrgID) { continue } - // An installation acknowledged between the classification and - // this transaction is found here and nowhere else. Revoking it is - // a narrowing, so the write that was classified a pure grant - // stops and is taken again under the witness. - if !witnessed { - return errModuleEnvelopeNarrowsAfterAll - } if err := s.uninstallSolutionTx(ctx, w.actorID, w.actorType, installation.OrgID, installation.ID); err != nil { return err } } return w.audit(ctx) } - // The policy log witnesses NARROWINGS — "a narrowing nothing witnessed is - // one a restore undoes silently". A write that only grants (a first - // envelope, a principal added, nothing an installation loses) - // is not one: a restore that undoes it leaves less authority, which is the - // fail-closed direction, so it is applied and audited without a witness. - // Anything that removes or changes what was granted is witnessed as today. - narrows, err := s.moduleEnvelopeNarrows(ctx, w.envelope, w.record.Document) - if err != nil { - return err - } - underWitness := func(ctx context.Context) error { - witnessed = true - return s.WithPolicyLoggedNarrowing(ctx, entry, apply) - } - write := underWitness - if !narrows { - write = func(ctx context.Context) error { - err := s.store.WithControlPlane(ctx, apply) - if errors.Is(err, errModuleEnvelopeNarrowsAfterAll) { - return underWitness(ctx) - } - return err - } - } - if err := write(ctx); err != nil { + // A narrowing applies directly, its revocations and audit in one + // transaction (narrowing.go), like a write that only grants. + if err := s.narrow(ctx, apply); err != nil { return err } // This replica fences its registry at once — it serves nothing derived @@ -507,11 +467,6 @@ func (s *Service) writeModuleEnvelope(ctx context.Context, w moduleEnvelopeWrite // has. var ErrModuleRegistryStale = errors.New("the module registry predates the committed module envelope") -// errModuleEnvelopeNarrowsAfterAll stops an envelope write that was classified -// a pure grant and then found something to revoke. It never leaves this file: -// the write is retried under the witness. -var errModuleEnvelopeNarrowsAfterAll = errors.New("the envelope write revokes an installation and was not witnessed") - // moduleRegistrySnapshot is the installed registry and the envelope revision // it was derived from, loaded ONCE. A decision reads its grant from this value // and fences this value — never a second load — so a registry installed between @@ -694,130 +649,3 @@ func installationTenancyAllowed(envelope *ModuleEnvelope, principalID, orgID str } return false } - -// moduleEnvelopeNarrows reports whether writing next would take authority away -// from anyone, and it is the boundary of the one write that skips the -// policy-log witness, so it recognises a pure grant and calls everything else a -// narrowing. -// -// A pure grant is exactly this, against the envelope in force: every principal -// is still there and holds at least what it held (moduleEnvelopeOnlyAdds), and -// no installation loses its organization. What may differ is the revision, -// whole principals that were added, and authority added to a principal that -// was kept. -// -// The tenant is the one addition that is also a narrowing — a tenant set -// removes every other organization — which is why apply re-checks every active -// installation against the new ceiling (errModuleEnvelopeNarrowsAfterAll) -// rather than trusting this classification alone. -// -// A host with no envelope has granted each applied manifest what it asks for -// (the ceiling is the manifest), so a first envelope over any manifest that -// asks for authority is a narrowing; over none, it narrows nothing and only -// the installations are checked. -func (s *Service) moduleEnvelopeNarrows(ctx context.Context, next *ModuleEnvelope, nextDocument []byte) (bool, error) { - narrows := false - err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - current, err := s.moduleEnvelopes.LatestModuleEnvelopeRecord(ctx) - if err != nil { - return err - } - if current != nil && !moduleEnvelopeOnlyAdds(current.Document, nextDocument) { - narrows = true - return nil - } - if current == nil { - applied, err := s.store.ListSolutionHostBindings(ctx) - if err != nil { - return err - } - if len(AppliedAuthorityRequests(applied)) > 0 { - narrows = true - return nil - } - } - // Inside the control plane, like the apply's own read: outside it a - // row policy hides every installation, and the answer is "none lose - // anything" for the wrong reason. - installations, err := s.moduleEnvelopes.ModuleInstallations(ctx) - if err != nil { - return err - } - for _, installation := range installations { - if !installationTenancyAllowed(next, installation.PrincipalID, installation.OrgID) { - narrows = true - return nil - } - } - return nil - }) - return narrows, err -} - -// moduleEnvelopeOnlyAdds reports that after keeps every principal before holds, -// each with at least the authority it had (moduleGrantOnlyGains), and may add -// principals. A document it cannot decode as an envelope, a key the envelope -// does not have among them, is not an addition. -// -// The documents are decoded, not validated: the revision in force was -// validated when it was written, the next one before this is asked, and a rule -// that later tightened is the registry derivation's to apply, not this -// classification's to refuse. -func moduleEnvelopeOnlyAdds(before, after []byte) bool { - was, ok := decodeModuleEnvelopeWire(before) - if !ok { - return false - } - is, ok := decodeModuleEnvelopeWire(after) - if !ok { - return false - } - for prefix, principal := range was.Principals { - kept, exists := is.Principals[prefix] - if !exists || !moduleGrantOnlyGains(principal.Grant.grant(prefix), kept.Grant.grant(prefix)) { - return false - } - } - return true -} - -func decodeModuleEnvelopeWire(document []byte) (moduleEnvelopeWire, bool) { - decoder := json.NewDecoder(bytes.NewReader(document)) - decoder.DisallowUnknownFields() - var wire moduleEnvelopeWire - if err := decoder.Decode(&wire); err != nil || decoder.More() { - return moduleEnvelopeWire{}, false - } - return wire, true -} - -// moduleGrantOnlyGains reports that a kept principal loses nothing: what an -// installation may hold of it (its queues, namespaces, resources, read and -// operation audiences, artifact approvals) fits within what it holds next, by -// the one rule this host already holds an installation's consent to its -// ceiling with (InstallationScopeWithin); its tenant is unchanged and a -// cross-tenant grant is kept; and every binding it had keeps its delegation -// mode. -// -// Reusing that rule is what lets a gain be told from a change without a second -// copy of the envelope's semantics: a resource kind added to an allowlist, an -// action added to a binding's scope, a binding's resource ids widened or -// dropped (every resource) are gains there, so they are gains here; a kind -// removed, an audience retargeted, resource ids appearing on a scope that had -// none, or an artifact approval changed are not, there or here. -// -// The delegation mode is outside that rule because it is not authority an -// installation consents to but how the binding's credential is exchanged: -// content_read restricts ordinary owner-only exchanges, so any change to it is -// witnessed. -func moduleGrantOnlyGains(was, is ModulePrincipalGrant) bool { - if was.Tenant != is.Tenant || was.CrossTenant && !is.CrossTenant { - return false - } - for id, binding := range was.OperationAudiences { - if kept, ok := is.OperationAudiences[id]; !ok || kept.DelegationMode != binding.DelegationMode { - return false - } - } - return InstallationScopeWithin(FullInstallationScope(was), FullInstallationScope(is)) -} diff --git a/module/services/accounts/code/pkg/business/module_envelope_delivery.go b/module/services/accounts/code/pkg/business/module_envelope_delivery.go index 82058dfb8..9b73e9361 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_delivery.go +++ b/module/services/accounts/code/pkg/business/module_envelope_delivery.go @@ -24,7 +24,7 @@ import ( // // The interactive write stays (PutModuleEnvelope): a platform super_admin with // fresh MFA can still write a higher revision. The two share one writer, so a -// delivered revision is classified, witnessed, fenced and revoked exactly like +// delivered revision is written, fenced and revoked exactly like // an interactive one, and neither relaxes the other. // ErrSolutionDeliverySignerNotRelease: the envelope verifies under a key this @@ -156,10 +156,8 @@ func (s *Service) ReceiveModuleEnvelopeDelivery( Signer: signer, OwnershipDomain: candidate.Host.Domain, Payload: delivered.Payload(), Envelope: bytes.Clone(signed.Envelope), } - // A delivered NARROWING is witnessed like an interactive one: on a host - // with no policy log it is refused (ErrPolicyLogUnreachable) exactly as - // PutModuleEnvelope refuses it, and nothing is written. Delivery does not - // bypass the witness; the composition's envelope waits for the log. + // A delivered NARROWING applies like an interactive one, through the one + // writer PutModuleEnvelope also uses. err = s.writeModuleEnvelope(ctx, moduleEnvelopeWrite{ envelope: envelope, record: record, expectedRevision: committed, actorID: moduleEnvelopeDeliveryActor, actorType: ActorTypeSystem, diff --git a/module/services/accounts/code/pkg/business/module_envelope_delivery_test.go b/module/services/accounts/code/pkg/business/module_envelope_delivery_test.go index e7d8bca61..24d665311 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_delivery_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_delivery_test.go @@ -18,7 +18,7 @@ import ( // A delivered module envelope, end to end through the business layer with // in-memory stores and real signatures: verification, the carrier, the host's -// rules, admission, the shared writer, the witness and the audit record. +// rules, admission, the shared writer and the audit record. const ( testHostModule = "saas" @@ -368,36 +368,26 @@ func TestEnvelopeDeliveryFailsClosedUntilFullyWired(t *testing.T) { require.Empty(t, h.store.records) } -// A delivered narrowing goes through the witness exactly as an interactive one: -// refused with nothing written where no policy log is wired, appended to the -// log under the delivery's actor where one is. -func TestADeliveredNarrowingIsWitnessedLikeAnInteractiveOne(t *testing.T) { +// A delivered narrowing applies directly, exactly as an interactive one: the +// shared writer appends the narrower revision with no witness asked first. +func TestADeliveredNarrowingAppliesDirectly(t *testing.T) { h := newEnvelopeDeliveryHarness(t, fixtureSigner(t)) _, err := h.deliver(t, signedEnvelope(t, testDeliveredEnvelope(t, 1, testDeliveryDomain, deliveredGrant(t, nil)), testDeliveryKey)) require.NoError(t, err, "a first envelope narrows nothing") narrower := signedEnvelope(t, testDeliveredEnvelope(t, 2, testDeliveryDomain, deliveredGrant(t, func(g map[string]any) { g["queues"] = []any{"reports.render"} })), testDeliveryKey) - _, err = h.deliver(t, narrower) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.Len(t, h.store.records, 1, "an unwitnessed narrowing writes nothing") - - log := newFakePolicyLog() - h.service.SetPolicyLog(log, newFakePolicyLogStore()) receipt, err := h.deliver(t, narrower) require.NoError(t, err) require.Equal(t, SolutionDeliveryAccepted, receipt.Disposition) require.Len(t, h.store.records, 2) - require.Len(t, log.appends, 1) - require.Equal(t, moduleEnvelopeDeliveryActor, log.appends[0].Actor) - require.Equal(t, uint64(2), log.appends[0].EnvelopeRevision) require.True(t, bytes.Contains(h.store.records[1].Document, []byte(`"revision":2`))) } // With no envelope the manifests are the ceiling, so a first envelope over an -// applied manifest that asks for authority takes authority away: it is -// witnessed like any narrowing, never written as a pure grant. -func TestAFirstEnvelopeOverGrantedManifestsIsWitnessed(t *testing.T) { +// applied manifest that asks for authority takes authority away, and it +// applies directly like any narrowing. +func TestAFirstEnvelopeOverGrantedManifestsAppliesDirectly(t *testing.T) { h := newEnvelopeDeliveryHarness(t, fixtureSigner(t)) manifest := testManifest(t, map[string][]string{"invoke": {"profiles:read"}}) document, err := solutionhost.Marshal(manifest) @@ -410,16 +400,8 @@ func TestAFirstEnvelopeOverGrantedManifestsIsWitnessed(t *testing.T) { }} first := signedEnvelope(t, testDeliveredEnvelope(t, 1, testDeliveryDomain, deliveredGrant(t, nil)), testDeliveryKey) _, err = h.deliver(t, first) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.Empty(t, h.store.records, "an unwitnessed narrowing writes nothing") - - log := newFakePolicyLog() - h.service.SetPolicyLog(log, newFakePolicyLogStore()) - _, err = h.deliver(t, first) require.NoError(t, err) require.Len(t, h.store.records, 1) - require.Len(t, log.appends, 1) - require.Equal(t, uint64(1), log.appends[0].EnvelopeRevision) } // UNPINNING THE KEY WITHDRAWS THE CEILING IT STATED. diff --git a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go b/module/services/accounts/code/pkg/business/module_envelope_gain_test.go deleted file mode 100644 index 162734ea2..000000000 --- a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package business - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" -) - -// WHAT MAY BE WRITTEN WITHOUT A WITNESS. -// -// The policy log witnesses NARROWINGS, because a narrowing nothing witnessed is -// one a restore undoes silently. A write that only grants is the opposite: a -// restore that undid it leaves LESS authority, which is the fail-closed -// direction, so it is applied and audited without one. -// -// That distinction is load-bearing here and not theoretical. No deployment has -// a policy log transport — the warehouse cannot issue the receipt the protocol -// requires — so a write classified as a narrowing cannot be made at all. While -// a principal had to be byte-identical to count as unchanged, the only writable -// change was a whole new principal, and a module missing one arm of its ceiling -// could never be corrected; while an array had to be identical, a module that -// declared one more content kind could not be granted it. -func TestAPrincipalMayGainWithoutAWitness(t *testing.T) { - envelope := func(documentsModel string) []byte { - return []byte(`{"revision":1,"principals":{"documents":{"grant":{"tenant":"t",` + - `"operation_audiences":{"model":` + documentsModel + `}}}}}`) - } - headless := `{"audience":"model","headless_scopes":[{"actions":["read"],"resource_kind":"p"}]}` - bothArms := `{"audience":"model","headless_scopes":[{"actions":["read"],"resource_kind":"p"}],` + - `"invoke_scopes":[{"actions":["read"],"resource_kind":"p"}]}` - - t.Run("an arm added to a binding it already had", func(t *testing.T) { - require.True(t, moduleEnvelopeOnlyAdds(envelope(headless), envelope(bothArms)), - "the binding gains an arm and no installation loses anything") - }) - t.Run("the same document", func(t *testing.T) { - require.True(t, moduleEnvelopeOnlyAdds(envelope(headless), envelope(headless))) - }) - t.Run("a whole principal added", func(t *testing.T) { - before := []byte(`{"revision":1,"principals":{"a":{"grant":{}}}}`) - after := []byte(`{"revision":2,"principals":{"a":{"grant":{}},"b":{"grant":{}}}}`) - require.True(t, moduleEnvelopeOnlyAdds(before, after)) - }) - - t.Run("an arm REMOVED is a narrowing", func(t *testing.T) { - require.False(t, moduleEnvelopeOnlyAdds(envelope(bothArms), envelope(headless))) - }) - t.Run("a principal removed is a narrowing", func(t *testing.T) { - before := []byte(`{"revision":1,"principals":{"a":{"grant":{}},"b":{"grant":{}}}}`) - after := []byte(`{"revision":2,"principals":{"a":{"grant":{}}}}`) - require.False(t, moduleEnvelopeOnlyAdds(before, after)) - }) - t.Run("a value CHANGED is a narrowing, even to something longer", func(t *testing.T) { - require.False(t, moduleEnvelopeOnlyAdds(envelope(headless), - envelope(`{"audience":"other","headless_scopes":[{"actions":["read"],"resource_kind":"p"}]}`))) - }) - t.Run("an action added to a binding's scope is a gain", func(t *testing.T) { - require.True(t, moduleEnvelopeOnlyAdds(envelope(headless), - envelope(`{"audience":"model","headless_scopes":[{"actions":["read","write"],"resource_kind":"p"}]}`))) - require.False(t, moduleEnvelopeOnlyAdds( - envelope(`{"audience":"model","headless_scopes":[{"actions":["read","write"],"resource_kind":"p"}]}`), - envelope(headless)), "and the action removed is a narrowing") - }) - t.Run("resource ids appearing on a scope that had none is a narrowing", func(t *testing.T) { - bounded := `{"audience":"model","headless_scopes":[{"actions":["read"],"resource_kind":"p","resource_ids":["one"]}]}` - require.False(t, moduleEnvelopeOnlyAdds(envelope(headless), envelope(bounded)), - "no resource ids is every resource") - require.True(t, moduleEnvelopeOnlyAdds(envelope(bounded), envelope(headless))) - require.True(t, moduleEnvelopeOnlyAdds(envelope(bounded), - envelope(`{"audience":"model","headless_scopes":[{"actions":["read"],"resource_kind":"p","resource_ids":["one","two"]}]}`))) - }) - t.Run("an allowlist that gains a member is a gain", func(t *testing.T) { - // documents' resources gaining its collection and ingest kinds: what a - // composition's envelope moves by when a module declares more content. - before := []byte(`{"revision":1,"principals":{"documents":{"grant":{"tenant":"t","resources":["documents"],"namespaces":["document"]}}}}`) - after := []byte(`{"revision":2,"principals":{"documents":{"grant":{"tenant":"t","resources":["documents","documents.collection","documents.ingest"],"namespaces":["document"],"queues":["ingest"]}}}}`) - require.True(t, moduleEnvelopeOnlyAdds(before, after)) - require.False(t, moduleEnvelopeOnlyAdds(after, before), "and a kind removed is a narrowing") - }) - t.Run("a cross-tenant grant withdrawn is a narrowing", func(t *testing.T) { - shared := []byte(`{"revision":1,"principals":{"a":{"grant":{"tenant":"t","cross_tenant":true}}}}`) - pinned := []byte(`{"revision":2,"principals":{"a":{"grant":{"tenant":"t"}}}}`) - require.False(t, moduleEnvelopeOnlyAdds(shared, pinned)) - require.True(t, moduleEnvelopeOnlyAdds(pinned, shared)) - }) - t.Run("an artifact approval changed is a narrowing", func(t *testing.T) { - policy := func(digest string) []byte { - return []byte(`{"revision":1,"principals":{"a":{"grant":{"artifact_policies":{"p":{"schema":"s","contracts":[{"kind":"k","name":"n","digest":"` + digest + `"}]}}}}}}`) - } - require.False(t, moduleEnvelopeOnlyAdds(policy("one"), policy("two"))) - require.True(t, moduleEnvelopeOnlyAdds(policy("one"), policy("one"))) - }) - t.Run("a tenant retargeted is a narrowing", func(t *testing.T) { - before := []byte(`{"revision":1,"principals":{"a":{"grant":{"tenant":"one"}}}}`) - after := []byte(`{"revision":2,"principals":{"a":{"grant":{"tenant":"two"}}}}`) - require.False(t, moduleEnvelopeOnlyAdds(before, after), - "an installation in the old tenant loses everything") - }) - t.Run("a top-level key that is not principals must be unchanged", func(t *testing.T) { - before := []byte(`{"revision":1,"principals":{},"other":1}`) - require.False(t, moduleEnvelopeOnlyAdds(before, []byte(`{"revision":2,"principals":{},"other":2}`))) - require.False(t, moduleEnvelopeOnlyAdds(before, []byte(`{"revision":2,"principals":{}}`))) - }) - t.Run("a document that does not decode is never an addition", func(t *testing.T) { - require.False(t, moduleEnvelopeOnlyAdds([]byte(`{`), []byte(`{"revision":1,"principals":{}}`))) - require.False(t, moduleEnvelopeOnlyAdds([]byte(`{"revision":1,"principals":{}}`), []byte(`nope`))) - }) - t.Run("a principal that stops being an object is not a gain", func(t *testing.T) { - before := []byte(`{"revision":1,"principals":{"a":{"grant":{"tenant":"t"}}}}`) - after := []byte(`{"revision":2,"principals":{"a":"t"}}`) - require.False(t, moduleEnvelopeOnlyAdds(before, after)) - }) - - t.Run("the real correction this exists for", func(t *testing.T) { - // documents' model binding, missing the invoke arm its manifest asks - // for, which is what left every embedding refused on a cell. - var before, after json.RawMessage = envelope(headless), envelope(bothArms) - require.True(t, moduleEnvelopeOnlyAdds(before, after)) - require.False(t, moduleEnvelopeOnlyAdds(after, before), "and undoing it is a narrowing") - }) -} - -func TestExistingBindingDelegationModeChangeRequiresAWitness(t *testing.T) { - envelope := func(mode string) []byte { - return []byte(`{"revision":1,"principals":{"reader":{"grant":{"operation_audiences":{"evidence":{"audience":"documents","invoke_scopes":[{"resource_kind":"documents","actions":["read"]}]` + mode + `}}}}}}`) - } - ordinary := envelope("") - explicitOrdinary := envelope(`,"delegation_mode":""`) - content := envelope(`,"delegation_mode":"content_read"`) - require.False(t, moduleEnvelopeOnlyAdds(ordinary, content), "adding restrictive semantics is not adding authority") - require.False(t, moduleEnvelopeOnlyAdds(content, ordinary)) - require.False(t, moduleEnvelopeOnlyAdds(explicitOrdinary, content)) - require.True(t, moduleEnvelopeOnlyAdds(content, content)) - require.True(t, moduleEnvelopeOnlyAdds([]byte(`{"revision":1,"principals":{"reader":{"grant":{}}}}`), content), "a wholly new binding grants authority") -} diff --git a/module/services/accounts/code/pkg/business/module_identity.go b/module/services/accounts/code/pkg/business/module_identity.go index f1a861366..3a90f5edd 100644 --- a/module/services/accounts/code/pkg/business/module_identity.go +++ b/module/services/accounts/code/pkg/business/module_identity.go @@ -289,6 +289,20 @@ func (g ModulePrincipalGrant) IssuedIn(orgID string) bool { return orgID != "" && (g.Tenant == "" || g.Tenant == orgID) } +// ModuleTenant is the organization the module named prefix is pinned to by +// the installed registry (manifest within the module envelope), "" when it is +// shared or not in the registry. A workload of a pinned module acts there and +// nowhere else, whatever organization a request names. The registry is fenced +// against the committed envelope first, like every module authority decision, +// so a pin a newer envelope set is never read from an older registry. +func (s *Service) ModuleTenant(ctx context.Context, prefix string) (string, error) { + snapshot := s.moduleRegistrySnapshot() + if err := s.fenceModuleRegistrySnapshot(ctx, snapshot); err != nil { + return "", err + } + return snapshot.registry[ModulePrincipalID(prefix)].Tenant, nil +} + // A shared principal selects consent, not authority: execution still proves the // principal and the subsequent live read must find that org's installation. // A tenant-pinned principal cannot switch orgs even if it has cross_tenant. diff --git a/module/services/accounts/code/pkg/business/module_operation_binding_seal.go b/module/services/accounts/code/pkg/business/module_operation_binding_seal.go index fa54a7387..ac601dfbd 100644 --- a/module/services/accounts/code/pkg/business/module_operation_binding_seal.go +++ b/module/services/accounts/code/pkg/business/module_operation_binding_seal.go @@ -16,31 +16,6 @@ func ModuleOperationBindingID(orgID, prefix, bindingID string) string { return orgID + "/" + prefix + "/" + bindingID } -// DelegatedOperationBindingID names a module's installed operation binding as -// exercised under one stored delegation to a module (module_delegation.go): -// //@. Every hop of a chain that -// acts under a delegation is sealed to it, so every verifier, which reads the -// binding live, reads the delegation with it, and a revoked delegation -// revokes exactly the capabilities issued under it. -func DelegatedOperationBindingID(bindingID, delegationID string) string { - return bindingID + delegatedBindingSeparator + delegationID -} - -const delegatedBindingSeparator = "@" - -// splitDelegatedBinding is a binding id and the delegation it is exercised -// under, when it names one: a canonical uuid after the separator. -func splitDelegatedBinding(id string) (binding, delegationID string) { - head, tail, found := strings.Cut(id, delegatedBindingSeparator) - if !found { - return id, "" - } - if parsed, err := uuid.Parse(tail); err != nil || parsed.String() != tail { - return id, "" - } - return head, tail -} - // IsModuleOperationBindingOf reports whether id names one of principalID's own // installed operation bindings in orgID: the only operation binding a module // operation context (StartModuleOperationTask) is sealed to. @@ -63,8 +38,6 @@ func NamesModuleOperationBinding(id string) bool { // every other binding id this host issues (a source delegation's, an installed // binding's), each of which starts with its own word. func parseModuleOperationBindingID(id string) (orgID, prefix, bindingID string, ok bool) { - // A binding exercised under a delegation is the same binding. - id, _ = splitDelegatedBinding(id) parts := strings.Split(id, "/") if len(parts) != 3 || parts[1] == "" || parts[2] == "" { return "", "", "", false @@ -110,15 +83,5 @@ func (s *Service) moduleOperationBinding(ctx context.Context, orgID, id string) ID: id, PrincipalID: prefix, ModuleRegistryID: principalID, InstallationID: installed.InstallationID, Revision: ModuleOperationContextRevision(installed.Grant), Incarnation: 1, } - // Exercised under a delegation, the binding stands while the delegation - // does: one that is revoked, unknown here or another organisation's - // revokes the binding for every capability sealed to it. - if _, delegationID := splitDelegatedBinding(id); delegationID != "" { - delegation, err := s.ModuleDelegation(ctx, "", delegationID) - if err != nil { - return WorkContextOperationBinding{}, err - } - binding.Revoked = delegation == nil || delegation.OrgID != orgID || !delegation.Active() - } return binding, nil } diff --git a/module/services/accounts/code/pkg/business/module_principal_reconcile.go b/module/services/accounts/code/pkg/business/module_principal_reconcile.go index 27fb17e03..f9bac7ae8 100644 --- a/module/services/accounts/code/pkg/business/module_principal_reconcile.go +++ b/module/services/accounts/code/pkg/business/module_principal_reconcile.go @@ -37,8 +37,8 @@ import ( // principal is then in the registry only while the envelope also allows it, // and its entry is the envelope's grant intersected with what the manifest asks // for — never wider than either (deriveModulePrincipalGrant). Writing the first -// envelope over granted manifests is therefore a narrowing, witnessed like any -// other (moduleEnvelopeNarrows). +// envelope over granted manifests is therefore a narrowing, applied like any +// other. // // Either way an entry's workloads are the manifest's, spelled by the cell's // rule (namespace from the module, ServiceAccount from the service). diff --git a/module/services/accounts/code/pkg/business/narrowing.go b/module/services/accounts/code/pkg/business/narrowing.go new file mode 100644 index 000000000..295c94846 --- /dev/null +++ b/module/services/accounts/code/pkg/business/narrowing.go @@ -0,0 +1,18 @@ +package business + +import "context" + +// narrow applies a change that only reduces access — a team member or an +// organization member removed, a role, a scope grant, a share, an API key, a +// principal, a delegation or a platform role revoked, an installation narrowed +// or uninstalled, a solution target closed — directly, in one control-plane +// transaction that also writes its audit record: apply emits the record with +// emitTx, so the narrowing and its audit commit together or not at all. +// +// No witness is asked first (owner decision, 2026-10-27): a narrowing that +// waited on an external policy log could not be performed at all on a host +// with none, which left access that could never be removed. Widening paths are +// unchanged. +func (s *Service) narrow(ctx context.Context, apply func(ctx context.Context) error) error { + return s.store.WithControlPlane(ctx, apply) +} diff --git a/module/services/accounts/code/pkg/business/noop_control_plane_store_test.go b/module/services/accounts/code/pkg/business/noop_control_plane_store_test.go new file mode 100644 index 000000000..2eaa27e11 --- /dev/null +++ b/module/services/accounts/code/pkg/business/noop_control_plane_store_test.go @@ -0,0 +1,14 @@ +package business + +import "context" + +// noopControlPlaneStore satisfies the Store interface by embedding it, so every +// method exists and only the control-plane transaction is implemented: it runs +// fn directly. An embedded nil panics loudly if a test reaches a method it did +// not mean to, which is the behaviour worth having in a test about ordering +// rather than persistence. +type noopControlPlaneStore struct{ Store } + +func (noopControlPlaneStore) WithControlPlane(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} diff --git a/module/services/accounts/code/pkg/business/org_admin_continuity_test.go b/module/services/accounts/code/pkg/business/org_admin_continuity_test.go index f250e6edb..ddfeac57b 100644 --- a/module/services/accounts/code/pkg/business/org_admin_continuity_test.go +++ b/module/services/accounts/code/pkg/business/org_admin_continuity_test.go @@ -113,21 +113,6 @@ func continuityService(t *testing.T, store business.Store) *business.Service { service, err := business.NewService(store) require.NoError(t, err) service.SetEntitlementChecker(business.NewDefaultEntitlementChecker(store, business.PlansSold)) - // A POLICY LOG, because RemoveOrgMember is now a witnessed narrowing. - // - // Without one it refuses outright — which is the fail-closed design working, - // and it moves this file's subject. These tests are about the - // ORGANISATION-SCOPED LOCK: that two concurrent removals serialise and the - // second is refused for emptying the organisation. On a host with no log - // both contenders refuse before reaching the lock, so "exactly one may - // commit" became zero and the lock was no longer being tested at all. - // - // The REAL postgres store is the local half, so the receipt still has to - // commit in the same transaction as the removal; only the external log is a - // double. That is the same wiring every other narrowing test in this - // package uses, and for the reason its comment gives — a service without a - // log is a configuration production is not meant to have. - wirePolicyLogForTests(service, testStore) return service } diff --git a/module/services/accounts/code/pkg/business/organization_lifecycle.go b/module/services/accounts/code/pkg/business/organization_lifecycle.go index 431a60ff7..3f45aa32f 100644 --- a/module/services/accounts/code/pkg/business/organization_lifecycle.go +++ b/module/services/accounts/code/pkg/business/organization_lifecycle.go @@ -156,33 +156,14 @@ func (s *Service) LeaveOrganization(ctx context.Context, userID string, req *gen // Authorization (owner, or platform super administrator) is the handler's; the // typed slug confirmation is checked here, against the row under the lock. // -// A NARROWING, and the widest one an organization has, so it runs under the -// policy log: the entry is appended to the external record and receipted BEFORE -// anything is revoked, and every revocation commits in the same transaction as -// the receipt's commit. A host that cannot witness the append refuses the -// deletion rather than performing one a restore could silently undo. -// -// ONE WITNESSED OPERATION, NOT ONE PER INSTALLATION — the decision this path -// forces, and deletedOrganizationPolicyLogEntry carries the reasoning. The -// short form: everything revoked here is revoked BECAUSE the organization was -// archived, so the archive is the fact worth recording and the organization is -// the subject a replay can ask about; an entry per installation would record -// that same fact several times. The alternative is also not available — -// WithPolicyLoggedNarrowing cannot nest (each call opens its own -// WithControlPlane transaction, and tenant_tx.go's no-nesting rule is that the -// second checks out a second pool connection while the first still holds one), -// so the per-installation entries could not be appended from inside this -// transaction at all. That is why the loop below calls uninstallSolutionTx — -// the same function an operator's uninstall applies inside ITS witnessed -// operation — rather than UninstallSolution, which would try to witness each -// installation separately. -// -// The transaction is the policy log's control-plane one rather than this -// organization's, because the receipt lives in a control-plane relation -// app_tenant holds no grant on, and the receipt and the revocations have to be -// one transaction or "applied but unwitnessed" becomes reachable. The -// organization's own scoping is not lost: every statement below names req.OrgId -// explicitly, which is what the tenant policy would have checked. +// A NARROWING, and the widest one an organization has. It applies directly +// (narrow): every revocation and its audit event commit in one control-plane +// transaction. The loop below calls uninstallSolutionTx — the same function an +// operator's uninstall applies — inside this one transaction rather than +// UninstallSolution, which would open a transaction of its own and tenant_tx.go +// forbids nesting. The organization's own scoping is not lost: every statement +// below names req.OrgId explicitly, which is what the tenant policy would have +// checked. func (s *Service) DeleteOrganization(ctx context.Context, actorID string, req *gen.DeleteOrganizationRequest) error { orgID, actorID, err := canonicalSubjectTenancyIDs(req.OrgId, actorID) if err != nil { @@ -191,58 +172,56 @@ func (s *Service) DeleteOrganization(ctx context.Context, actorID string, req *g w := wool.Get(ctx).In("DeleteOrganization") actorType := s.actorTypeForCreator(ctx, actorID) var members []string - if err := s.WithPolicyLoggedNarrowing(ctx, - deletedOrganizationPolicyLogEntry(actorID, orgID), - func(ctx context.Context) error { - if err := s.store.LockOrgAdministration(ctx, orgID); err != nil { - return w.Wrapf(err, "cannot lock organization administration") - } - org, err := s.store.GetOrganization(ctx, orgID) - if err != nil { - return err - } - if org == nil || org.ArchivedAt != nil { - return NewStoreError(fmt.Errorf("organization %s not found", orgID), ErrTypeNotFound) - } - if !strings.EqualFold(strings.TrimSpace(req.ConfirmSlug), org.Slug) { - return ErrOrgDeleteConfirmation - } - // Acquire every membership lock before any revocation can take an - // authorization-revision or session lock through its triggers. Lock - // only: deleting now would invert standalone uninstall's order of - // installation -> standing grant -> authorization revision. - if members, err = s.store.LockAllOrgMembers(ctx, orgID); err != nil { - return err - } - // No trigger-producing write may precede the entire batch lock. - installations, err := s.store.ActiveInstallationIDs(ctx, orgID) - if err != nil { - return err - } - for _, installationID := range installations { - if err := s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID); err != nil { - return w.Wrapf(err, "cannot uninstall %s", installationID) - } - } - if _, err := s.store.RemoveLockedOrgMembers(ctx, orgID, members); err != nil { - return err - } - if _, err := s.store.RevokeOrgAPIKeys(ctx, orgID); err != nil { - return err - } - if _, err := s.store.RevokeOrgPendingInvitations(ctx, orgID); err != nil { - return err - } - if err := s.revokeSourceDelegationsTx(ctx, actorID, SourceDelegationFilter{OrgID: orgID}, SourceDelegationMemberRemoved); err != nil { - return err - } - if err := s.store.ArchiveOrganization(ctx, orgID, actorID); err != nil { - return err + if err := s.narrow(ctx, func(ctx context.Context) error { + if err := s.store.LockOrgAdministration(ctx, orgID); err != nil { + return w.Wrapf(err, "cannot lock organization administration") + } + org, err := s.store.GetOrganization(ctx, orgID) + if err != nil { + return err + } + if org == nil || org.ArchivedAt != nil { + return NewStoreError(fmt.Errorf("organization %s not found", orgID), ErrTypeNotFound) + } + if !strings.EqualFold(strings.TrimSpace(req.ConfirmSlug), org.Slug) { + return ErrOrgDeleteConfirmation + } + // Acquire every membership lock before any revocation can take an + // authorization-revision or session lock through its triggers. Lock + // only: deleting now would invert standalone uninstall's order of + // installation -> standing grant -> authorization revision. + if members, err = s.store.LockAllOrgMembers(ctx, orgID); err != nil { + return err + } + // No trigger-producing write may precede the entire batch lock. + installations, err := s.store.ActiveInstallationIDs(ctx, orgID) + if err != nil { + return err + } + for _, installationID := range installations { + if err := s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID); err != nil { + return w.Wrapf(err, "cannot uninstall %s", installationID) } - return s.emitTx(ctx, actorID, actorType, EventOrgDeleted, "organization", orgID, orgID, map[string]any{ - "slug": org.Slug, - }) - }); err != nil { + } + if _, err := s.store.RemoveLockedOrgMembers(ctx, orgID, members); err != nil { + return err + } + if _, err := s.store.RevokeOrgAPIKeys(ctx, orgID); err != nil { + return err + } + if _, err := s.store.RevokeOrgPendingInvitations(ctx, orgID); err != nil { + return err + } + if err := s.revokeSourceDelegationsTx(ctx, actorID, SourceDelegationFilter{OrgID: orgID}, SourceDelegationMemberRemoved); err != nil { + return err + } + if err := s.store.ArchiveOrganization(ctx, orgID, actorID); err != nil { + return err + } + return s.emitTx(ctx, actorID, actorType, EventOrgDeleted, "organization", orgID, orgID, map[string]any{ + "slug": org.Slug, + }) + }); err != nil { return w.Wrapf(err, "cannot delete organization") } for _, memberID := range members { diff --git a/module/services/accounts/code/pkg/business/organizations.go b/module/services/accounts/code/pkg/business/organizations.go index 3e4e4e2ae..657a2b743 100644 --- a/module/services/accounts/code/pkg/business/organizations.go +++ b/module/services/accounts/code/pkg/business/organizations.go @@ -395,22 +395,15 @@ func (s *Service) RemoveOrgMember(ctx context.Context, actorID string, req *gen. w := wool.Get(ctx).In("RemoveOrgMember") // A NARROWING — the member loses the standing every grant inside the - // organization hangs from — so it runs under the policy log: the entry is - // appended to the external record and receipted BEFORE the membership goes, - // and the removal commits in the same transaction as the receipt's commit. A - // host that cannot witness the append refuses rather than removing - // unwitnessed. + // organization hangs from — so it applies directly (narrow). // // Lock, last-admin guard, dependent-access delete, membership delete, and - // the audit event all run inside that ONE transaction, so the record cannot - // commit describing a removal whose dependent access is still standing. It - // is the policy log's control-plane transaction rather than this - // organization's, because the receipt relation is control-plane only and the - // receipt and the removal have to be atomic. What the tenant policy used to - // confine is confined by the statements instead: every call below names - // orgID — the continuity count, both locks, the dependent-membership - // delete, the membership delete and the delegation revoke — so none of them - // can reach another organization's rows. + // the audit event all run inside that ONE control-plane transaction, so the + // record cannot commit describing a removal whose dependent access is still + // standing. What the tenant policy would confine is confined by the + // statements instead: every call below names orgID — the continuity count, + // both locks, the dependent-membership delete, the membership delete and the + // delegation revoke — so none of them can reach another organization's rows. // // Two locks, in the order AUTHZ.md fixes. The organization-wide // administration lock is taken first, inside the continuity guard, because @@ -418,14 +411,13 @@ func (s *Service) RemoveOrgMember(ctx context.Context, actorID string, req *gen. // taken before the writes, so a concurrent team insert for the same pair // either commits before the dependent delete or waits behind this // transaction, and can neither be missed by it nor land after it. - if err := s.WithPolicyLoggedNarrowing(ctx, - removeOrgMemberPolicyLogEntry(actorID, orgID, memberID), - func(ctx context.Context) error { - if err := s.removeOrgMembershipTx(ctx, actorID, orgID, memberID); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventOrgMemberRemoved, "organization", orgID, orgID) - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + if err := s.removeOrgMembershipTx(ctx, actorID, orgID, memberID); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventOrgMemberRemoved, "organization", orgID, orgID, + map[string]any{"user_id": memberID}) + }); err != nil { return w.Wrapf(err, "cannot remove member") } diff --git a/module/services/accounts/code/pkg/business/permissions.go b/module/services/accounts/code/pkg/business/permissions.go index 733004d83..cc0646a39 100644 --- a/module/services/accounts/code/pkg/business/permissions.go +++ b/module/services/accounts/code/pkg/business/permissions.go @@ -117,32 +117,21 @@ func (s *Service) ListRoleAssignments(ctx context.Context, req *gen.ListRoleAssi // RevokeRole removes a role assignment. // -// A NARROWING, so it runs under the policy log: the entry is appended to the -// external record and receipted here BEFORE the assignment goes, and the delete -// commits in the same transaction as the receipt's commit. A host that cannot -// witness the append refuses rather than revoking unwitnessed — a revocation a -// restore could silently undo is worse than a refusal the caller can see. -// -// The transaction is the policy log's control-plane one for BOTH the -// organization-scoped and the platform-level revocation, where before this the -// first ran in its tenant transaction and the second under the control plane. -// The receipt relation is control-plane only and the two writes have to be -// atomic, so there is no choice of transaction left to make; what the tenant one -// used to check is checked by the statement instead, which names req.OrgId and -// matches it with IS NOT DISTINCT FROM — so a caller naming another -// organization's assignment deletes nothing, exactly as the tenant policy would -// have produced. +// A NARROWING, so it applies directly (narrow): the delete and its audit event +// commit in one control-plane transaction, for both the organization-scoped and +// the platform-level revocation. What the tenant transaction would check is +// checked by the statement, which names req.OrgId and matches it with IS NOT +// DISTINCT FROM — so a caller naming another organization's assignment deletes +// nothing. func (s *Service) RevokeRole(ctx context.Context, actorID string, req *gen.RevokeRoleRequest) error { w := wool.Get(ctx).In("RevokeRole") - if err := s.WithPolicyLoggedNarrowing(ctx, - revokeRolePolicyLogEntry(actorID, req.OrgId, req.SubjectId, req.RoleId, req.Scope), - func(ctx context.Context) error { - if err := s.store.RevokeRole(ctx, req.SubjectId, req.RoleId, req.OrgId, req.Scope); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventRoleRevoked, "role", req.RoleId, req.OrgId) - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + if err := s.store.RevokeRole(ctx, req.SubjectId, req.RoleId, req.OrgId, req.Scope); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventRoleRevoked, "role", req.RoleId, req.OrgId) + }); err != nil { return w.Wrapf(err, "cannot revoke role") } return nil diff --git a/module/services/accounts/code/pkg/business/platform_admin.go b/module/services/accounts/code/pkg/business/platform_admin.go index 0efbe3b2b..623ac393c 100644 --- a/module/services/accounts/code/pkg/business/platform_admin.go +++ b/module/services/accounts/code/pkg/business/platform_admin.go @@ -515,14 +515,12 @@ func (s *Service) RevokePlatformRole(ctx context.Context, actorID string, req *g return w.Wrapf(err, "permission denied") } - if err := s.WithPolicyLoggedNarrowing(ctx, - revokePlatformRolePolicyLogEntry(actorID, req.UserId), - func(ctx context.Context) error { - if err := s.store.RevokePlatformRole(ctx, req.UserId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventPlatformRoleRevoked, "user", req.UserId, "") - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + if err := s.store.RevokePlatformRole(ctx, req.UserId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventPlatformRoleRevoked, "user", req.UserId, "") + }); err != nil { return w.Wrapf(err, "cannot revoke platform role") } return nil diff --git a/module/services/accounts/code/pkg/business/policy_log.go b/module/services/accounts/code/pkg/business/policy_log.go deleted file mode 100644 index 574a41dce..000000000 --- a/module/services/accounts/code/pkg/business/policy_log.go +++ /dev/null @@ -1,481 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "time" -) - -// The policy log: append → receipt → commit-with-receipt, before anything -// narrows. -// -// WHY IT IS EXTERNAL. A transactional audit event plus an append-only table in -// the same database is not an external record of authority. Restoring that -// database restores revoked authority together with its own history, so the -// history cannot witness against the state — the two were backed up together. -// The log therefore lives elsewhere, and what this host keeps is the RECEIPT: -// proof that an entry reached the log BEFORE the narrowing it describes took -// effect here. -// -// THE DIRECTION OF THE ASYMMETRY IS THE DESIGN. Two failures are possible -// between the two writes, and they must not be treated alike: -// -// - append fails ⇒ nothing narrows. The caller is refused, and the state is -// exactly as before. Safe, because authority was not reduced and nobody was -// told it was. -// - append succeeds, commit fails ⇒ the LOG says the narrowing happened and -// this host has not applied it. That must fail CLOSED: every replica -// enforces the narrowing, or refuses to serve, until the gap is reconciled. -// The authority of record has spoken, and a host serving the wider authority -// it still holds locally is serving authority that was revoked. -// -// The reverse — commit without append — must be IMPOSSIBLE, which is why the -// append comes first and its receipt is required by the commit. -// -// WHAT COUNTS AS A NARROWING. Revocation, a tenancy change, a narrowed envelope -// — and, deliberately, AUTHORISED REGRANTS too. A log that recorded only -// reductions could not answer "was this principal's authority restored, or has -// it never been taken away?", and those have different answers to "may it act -// now". - -// PolicyLogSubjectKind is what a logged operation is about. The set is closed: -// an operation whose subject this host cannot name is one a reconciliation -// cannot act on, so there is no "other". -type PolicyLogSubjectKind string - -const ( - PolicyLogPrincipal PolicyLogSubjectKind = "principal" - PolicyLogBinding PolicyLogSubjectKind = "binding" - PolicyLogInstallation PolicyLogSubjectKind = "installation" - PolicyLogTeamMembership PolicyLogSubjectKind = "team_membership" - PolicyLogScopeGrant PolicyLogSubjectKind = "scope_grant" - PolicyLogModuleEnvelope PolicyLogSubjectKind = "module_envelope" - - // PolicyLogOrganization is the organization itself, for the one narrowing - // whose subject is not a grant hanging off it: archiving it, which ends - // every standing authority into it at once. - // - // It is its own kind because both alternatives were wrong in the same - // direction. Reporting the archive as a `binding` whose id is an - // organization's sends a reconciliation looking for a binding that does - // not exist; one entry per revoked grant records the same fact many times - // and leaves a reader to work out which archive they belonged to — which - // is exactly the argument closedSolutionTargetPolicyLogEntry already makes - // for naming the target rather than each installation it revokes. - // - // The set is closed, so adding a kind is how a new SHAPE of narrowing is - // admitted. That is what the closure is for: it forces "what can a replay - // act on" to be answered once, here, instead of per call site. - PolicyLogOrganization PolicyLogSubjectKind = "organization" -) - -// Valid reports whether a kind is one a reconciliation can act on. -func (kind PolicyLogSubjectKind) Valid() bool { - switch kind { - case PolicyLogPrincipal, PolicyLogBinding, PolicyLogInstallation, - PolicyLogTeamMembership, PolicyLogScopeGrant, PolicyLogOrganization, PolicyLogModuleEnvelope: - return true - } - return false -} - -// PolicyLogDecision is what the operation did to the subject's authority. -type PolicyLogDecision string - -const ( - // PolicyLogNarrowed reduced it — a revocation, a tenancy change, a tighter - // envelope. - PolicyLogNarrowed PolicyLogDecision = "narrowed" - // PolicyLogRegranted restored or widened it, by an authorised act. - // - // Logged for the same reason a narrowing is: without it the log cannot tell - // "this authority was restored" from "it was never taken away", and those - // have different answers to whether the subject may act now. - PolicyLogRegranted PolicyLogDecision = "regranted" -) - -// PolicyLogEntry is one operation offered to the log. -type PolicyLogEntry struct { - // OperationID is the idempotency key, and it is the CALLER'S. A retry of the - // same logical operation must append once however many times it is - // attempted, and only the caller knows that two attempts are the same - // operation. Generating it here would make every retry a new entry, and a - // log with two entries for one revocation cannot be replayed into a single - // answer. - OperationID string - - Decision PolicyLogDecision - SubjectKind PolicyLogSubjectKind - SubjectID string - - // Policy is the authority as it stands AFTER the operation. The log holds - // the resulting state rather than a diff, because a replay has to be able to - // answer "what is this subject's authority" from one entry rather than by - // folding every entry since the beginning. - Policy map[string]any - - // Supersedes is the operation this one replaces, when it replaces one. - Supersedes string - // EnvelopeRevision is the ceiling the operation was decided against, so a - // replay can tell an operation made under an older envelope from one made - // under the current. - EnvelopeRevision uint64 - - // Actor is who performed it. Required: an authority change with no actor is - // a change nobody can be asked about. - Actor string -} - -// Validate refuses an entry the log could not be replayed from. -func (entry *PolicyLogEntry) Validate() error { - switch { - case entry == nil: - return errors.New("policy log entry is absent") - case entry.OperationID == "": - return errors.New("policy log entry has no operation id, so a retry of it could not be recognised as the same operation") - case entry.Decision != PolicyLogNarrowed && entry.Decision != PolicyLogRegranted: - return fmt.Errorf("policy log entry has decision %q, want %q or %q", - entry.Decision, PolicyLogNarrowed, PolicyLogRegranted) - case !entry.SubjectKind.Valid(): - return fmt.Errorf("policy log entry has subject kind %q, which no reconciliation could act on", entry.SubjectKind) - case entry.SubjectID == "": - return errors.New("policy log entry names no subject") - case entry.Actor == "": - return errors.New("policy log entry names no actor, so the authority change could not be attributed") - } - return nil -} - -// PolicyLogReceipt is the log's acknowledgement of one appended entry. -type PolicyLogReceipt struct { - // Receipt is the log's own token for the entry. Opaque to this host, and - // stored verbatim: its presence is what separates "the log witnessed this" - // from "this host decided it". - Receipt string - // Sequence is the log's monotonic position for the entry, which the host's - // cursor is compared against. - Sequence uint64 - // AppendedAt is when the log recorded it, by the LOG'S clock rather than - // this host's. A host comparing its own clock against a log's ordering would - // be comparing two unrelated things. - AppendedAt time.Time -} - -// ErrPolicyLogUnreachable reports that the log could not be appended to or read. -// -// It is the reason the host refuses to serve. Distinct from a refusal by the log -// because the two have opposite meanings: a log that said no has made a -// decision, and a log that could not be reached has made none — and a host that -// treated the second as the first would serve while believing it had checked. -var ErrPolicyLogUnreachable = errors.New("policy log is unreachable") - -// ErrPolicyLogUnreconciled reports that the log holds an appended operation this -// host has not applied. -// -// This is the append-ok/commit-fail state, and it is a SERVING condition rather -// than an error on one request: the authority of record says a narrowing -// happened, so every replica must enforce it or stop serving until the gap is -// closed. -var ErrPolicyLogUnreconciled = errors.New("policy log holds an operation this host has not applied") - -// PolicyLog is the external append-only authority record. -type PolicyLog interface { - // Append records one entry and returns its receipt. - // - // It must be IDEMPOTENT on the entry's operation id: a retry returns the - // original receipt rather than appending again. That requirement is on the - // log rather than on this host because only the log can make it atomic — - // a host checking "did I already append?" before appending has a window - // between the two. - Append(ctx context.Context, entry *PolicyLogEntry) (*PolicyLogReceipt, error) - - // Entries returns every entry after a sequence, in order. - // - // Used by reconciliation, never on a request path. The reader deliberately - // holds no job-creation permission on the log's warehouse, so this is a - // bounded row read with a restriction rather than a query. - Entries(ctx context.Context, afterSequence uint64, limit int) ([]*PolicyLogRecord, error) -} - -// PolicyLogRecord is one entry as the log returns it. -type PolicyLogRecord struct { - PolicyLogEntry - Receipt string - Sequence uint64 - LoggedAt time.Time -} - -// PolicyLogStore is the host's local half: the receipts and the cursor. -type PolicyLogStore interface { - // RecordPolicyLogAppend writes the receipt for an appended operation, with - // no commit yet. Reports whether the row was new, so a retry can tell an - // append it has already recorded from one it has not. - RecordPolicyLogAppend(ctx context.Context, receipt *PolicyLogReceipt, entry *PolicyLogEntry) (bool, error) - - // CommitPolicyLogOperation marks an appended operation applied. It runs in - // the SAME transaction as the narrowing it describes, which is what makes - // "applied but not committed" impossible — the only reachable gap is - // "appended but not applied", which fails closed. - CommitPolicyLogOperation(ctx context.Context, operationID string, at time.Time) error - - // UncommittedPolicyLogOperations returns appended operations with no commit. - UncommittedPolicyLogOperations(ctx context.Context) ([]*PolicyLogGap, error) - - // PolicyLogCursorState reads how far this host has reconciled, and when it - // last reached the log. - PolicyLogCursorState(ctx context.Context) (sequence uint64, reachedAt *time.Time, err error) - - // AdvancePolicyLogCursor records that the log was reached and read to a - // sequence. - AdvancePolicyLogCursor(ctx context.Context, sequence uint64, at time.Time) error -} - -// PolicyLogGap is an appended operation this host has not applied. -type PolicyLogGap struct { - OperationID string - Sequence uint64 - SubjectKind PolicyLogSubjectKind - SubjectID string - AppendedAt time.Time -} - -// PolicyLogDeadline bounds how long an append may take. -// -// It exists because the alternative is lock accumulation, not slowness. The -// append happens before the narrowing's transaction opens — deliberately, so a -// slow log cannot hold database locks — but a caller blocked indefinitely on the -// log still holds its request, its connection and whatever the caller above it -// holds. A bounded wait converts that into a refusal the caller can act on. -// -// Five seconds: long enough that an ordinary warehouse append succeeds, short -// enough that an authority change fails visibly rather than hanging. A narrowing -// that cannot be logged must not proceed, so the refusal is the correct outcome -// and not a degraded one. -const PolicyLogDeadline = 5 * time.Second - -// PolicyLogStaleAfter is how long a host may serve without having reached the -// log. -// -// Not zero, deliberately: a log round trip on every request would make the log's -// availability the host's own, and a momentary blip would become an outage. Not -// unbounded either, because then "I cannot reach the log" would never become "I -// must stop serving". One minute is the window in which a narrowing that has -// been appended but not seen here could still be honoured by this host's local -// state; past it, the host no longer knows whether its authority is current. -const PolicyLogStaleAfter = time.Minute - -// WithPolicyLoggedNarrowing runs a narrowing under the protocol. -// -// The sequence, and why each step is where it is: -// -// 1. VALIDATE the entry. An entry the log could not be replayed from must not -// reach the log, because a bad entry that is accepted is worse than a -// refused operation: it is a gap nothing can close. -// 2. APPEND, under a bounded deadline, BEFORE any transaction opens. A slow log -// then costs a refusal rather than held locks. -// 3. RECORD the receipt locally. From this instant the operation is a GAP, and -// every replica will enforce it or refuse until it is committed. -// 4. APPLY the narrowing and COMMIT the receipt in ONE transaction. Either both -// land or neither does, so "applied but unwitnessed" cannot exist. -// -// A failure at (4) leaves the gap from (3), which is the fail-closed state: the -// log says the narrowing happened, so the host enforces it or stops serving. -func (s *Service) WithPolicyLoggedNarrowing( - ctx context.Context, entry *PolicyLogEntry, apply func(ctx context.Context) error, -) error { - if err := entry.Validate(); err != nil { - return err - } - if s.policyLog == nil || s.policyLogStore == nil { - // Fail closed. A host with no log cannot narrow authority, because the - // narrowing would be unwitnessed and a restore would undo it silently. - return fmt.Errorf("%w: this host has no policy log, so authority cannot be narrowed", ErrPolicyLogUnreachable) - } - - // The deadline wraps the append alone. Putting it around the whole operation - // would make a slow DATABASE look like an unreachable log, and those have - // different answers. - appendCtx, cancel := context.WithTimeout(ctx, PolicyLogDeadline) - defer cancel() - - receipt, err := s.policyLog.Append(appendCtx, entry) - if err != nil { - return fmt.Errorf("%w: %w", ErrPolicyLogUnreachable, err) - } - if receipt == nil || receipt.Receipt == "" || receipt.Sequence == 0 { - // A log that returns no receipt has not witnessed anything, whatever it - // returned. Refusing here rather than storing an empty receipt keeps - // "witnessed" and "attempted" distinguishable. - return fmt.Errorf("%w: the log returned no usable receipt for operation %q", - ErrPolicyLogUnreachable, entry.OperationID) - } - - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - _, recordErr := s.policyLogStore.RecordPolicyLogAppend(ctx, receipt, entry) - return recordErr - }); err != nil { - // The gate's cached answer is dropped either way: the append landed, - // so a gap exists whether or not this host recorded it. - s.policyServing.invalidate() - // The append landed and this host could not even record that it did. - // The gap exists in the LOG, which reconciliation reads — so this is - // reported rather than swallowed, and the next reconciliation pass finds - // it there. - return fmt.Errorf("record policy log append for operation %q (the append LANDED; reconciliation will find it): %w", - entry.OperationID, err) - } - - // From here the operation is a GAP, and every replica reading this - // host's commit relation sees it. Drop the gate's cached answer so this - // host's own next request re-reads rather than serving the decision it - // made before the gap existed — and drop it again after the commit, so a - // gap that just closed does not keep the host shut for the TTL. - s.policyServing.invalidate() - defer s.policyServing.invalidate() - - // The narrowing and its receipt in one transaction. - return s.store.WithControlPlane(ctx, func(ctx context.Context) error { - if err := apply(ctx); err != nil { - return err - } - return s.policyLogStore.CommitPolicyLogOperation(ctx, entry.OperationID, s.policyNow()) - }) -} - -// PolicyLogServingState is why a host may not be serving. -type PolicyLogServingState struct { - // Unreachable is set when the log has not been reached within - // PolicyLogStaleAfter. - Unreachable bool - // Gaps are appended operations this host has not applied. - Gaps []*PolicyLogGap - // LastReachedAt is when the log was last read, nil if never. - LastReachedAt *time.Time -} - -// MayServe reports whether the host may serve, and why not when it may not. -// -// Two conditions, and both are refusals rather than degradations: -// -// - the log has not been reached within the staleness window. The host does -// not know whether its authority is current, and serving what it last -// believed is serving authority that may have been revoked. -// - there is an appended operation this host has not applied. The authority of -// record says a narrowing happened; serving the wider authority still held -// locally is serving authority that was revoked. -// -// Checked on the serving path and NOT only at startup, which is the requirement -// that makes it useful: a gap opens at runtime, when a commit fails after an -// append landed, and a startup-only check would serve through exactly the window -// the protocol exists for. -func (s *Service) MayServe(ctx context.Context) (bool, *PolicyLogServingState, error) { - if s.policyLogStore == nil { - // No log configured: nothing has been narrowed through the protocol, so - // there is nothing unreconciled. Serving is allowed, and the host's - // inability to narrow authority is reported by - // WithPolicyLoggedNarrowing rather than by refusing every request. - return true, &PolicyLogServingState{}, nil - } - state := &PolicyLogServingState{} - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - _, reachedAt, err := s.policyLogStore.PolicyLogCursorState(ctx) - if err != nil { - return err - } - state.LastReachedAt = reachedAt - gaps, err := s.policyLogStore.UncommittedPolicyLogOperations(ctx) - if err != nil { - return err - } - state.Gaps = gaps - return nil - }); err != nil { - return false, nil, err - } - if state.LastReachedAt == nil || s.policyNow().Sub(*state.LastReachedAt) > PolicyLogStaleAfter { - state.Unreachable = true - } - return !state.Unreachable && len(state.Gaps) == 0, state, nil -} - -// ReconcilePolicyLog reads the log forward and closes what it can. -// -// It runs on an interval, not only at startup. A gap opens at RUNTIME — a commit -// that failed after its append landed — and the requirement is that every -// replica enforces or refuses until reconciled, which a startup-only pass cannot -// provide. -// -// What it does NOT do is decide authority from the log. The log is the record; -// applying an entry means re-running the narrowing it describes, which is the -// caller's domain logic and not this function's. So reconciliation reports the -// gaps and advances the cursor, and the serving gate keeps the host closed until -// something has closed them. That division is deliberate: a reconciler that -// re-derived authority from log payloads would be a second implementation of -// every narrowing, and the copy is what drifts. -func (s *Service) ReconcilePolicyLog(ctx context.Context) (*PolicyLogServingState, error) { - if s.policyLog == nil || s.policyLogStore == nil { - return &PolicyLogServingState{}, nil - } - readCtx, cancel := context.WithTimeout(ctx, PolicyLogDeadline) - defer cancel() - - var cursor uint64 - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - var err error - cursor, _, err = s.policyLogStore.PolicyLogCursorState(ctx) - return err - }); err != nil { - return nil, err - } - - records, err := s.policyLog.Entries(readCtx, cursor, policyLogReadBatch) - if err != nil { - // Unreachable. The cursor is NOT advanced, so the staleness window - // closes and the host stops serving — which is the point: a host that - // cannot read the log does not know whether its authority is current. - return nil, fmt.Errorf("%w: %w", ErrPolicyLogUnreachable, err) - } - - now := s.policyNow() - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - highest := cursor - for _, record := range records { - // An entry this host has never seen is recorded as a GAP before the - // cursor passes it. Recording first is what makes the cursor safe to - // advance: if the pass dies here, the entries already written are - // gaps the serving gate sees, and the cursor still points below them. - if _, err := s.policyLogStore.RecordPolicyLogAppend(ctx, &PolicyLogReceipt{ - Receipt: record.Receipt, - Sequence: record.Sequence, - AppendedAt: record.LoggedAt, - }, &record.PolicyLogEntry); err != nil { - return err - } - if record.Sequence > highest { - highest = record.Sequence - } - } - return s.policyLogStore.AdvancePolicyLogCursor(ctx, highest, now) - }); err != nil { - return nil, err - } - - _, state, err := s.MayServe(ctx) - return state, err -} - -// policyLogReadBatch bounds one reconciliation read. Bounded because the reader -// holds no job-creation permission on the log's warehouse — this is a row read -// with a restriction, and an unbounded one would be a scan. -const policyLogReadBatch = 500 - -// policyNow is the host's clock, overridable in tests. Separate from the log's -// clock on purpose: receipts carry the LOG'S time, and comparing a host's clock -// against a log's ordering would compare two unrelated things. -func (s *Service) policyNow() time.Time { - if s.policyClock != nil { - return s.policyClock() - } - return time.Now().UTC() -} diff --git a/module/services/accounts/code/pkg/business/policy_log_gate.go b/module/services/accounts/code/pkg/business/policy_log_gate.go deleted file mode 100644 index ad1f4a406..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_gate.go +++ /dev/null @@ -1,208 +0,0 @@ -package business - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// The serving gate: MayServe, on the request path. -// -// WHY NOT AT STARTUP. The state the gate refuses on OPENS AT RUNTIME. A commit -// that fails after its append landed leaves the log saying a narrowing happened -// and this host not having applied it, and a host that only checked at boot -// would serve the wider authority it still holds locally for exactly as long as -// that gap stays open — which is the window the whole protocol exists for. The -// same is true of the staleness condition: a log reachable at boot and -// unreachable an hour later must stop the host, and only a check on the request -// path can notice. -// -// WHY A REFUSAL AND NOT A DEGRADATION. Both conditions mean the same thing: this -// host does not know that the authority it is about to apply is current. Serving -// a narrower answer is not available — the host cannot compute one without the -// log — so the honest answer is to refuse the request. It is an OUTAGE, not a -// denial: the caller is not unauthorized, the host is unable, so the code is -// Unavailable and never PermissionDenied. A host that answered "forbidden" here -// would tell every caller their authority had been revoked when nothing had been -// revoked at all. - -// policyLogServingCacheFor is how long one serving answer is reused. -// -// Not zero: MayServe is a control-plane transaction, and one per request would -// put a cross-tenant round trip in front of every call the host serves — a cost -// the gate cannot justify when the answer changes only when a gap opens or the -// log is reached. -// -// Not longer either, and the bound is about what the cache can HIDE. Two things -// change the answer. A gap this host opens itself does not wait for the TTL at -// all: WithPolicyLoggedNarrowing invalidates the cache the instant it records an -// append, so the very next request re-reads. A gap another replica opened -// arrives here through reconciliation, whose interval is longer than this -// window, so the TTL adds nothing to that delay. One second is therefore the -// window in which the cache can hide nothing that is not already pending. -const policyLogServingCacheFor = time.Second - -// policyLogServingCache is the serving gate's memory of its last answer. -// -// ONLY A PERMISSION IS REMEMBERED, never a refusal, and the asymmetry is the -// point. A remembered permission can be wrong for at most the window below, and -// the two things that could make it wrong are both accounted for: a gap this -// host opens itself raises the dirty flag at the instant the append is recorded, -// and a gap another replica opened arrives through reconciliation, whose -// interval is longer than the window. A remembered REFUSAL has no such bound — -// a gap closed by an operator, or by whatever re-ran the narrowing, is closed in -// the database and nothing tells this process — so a cached refusal would keep -// the host shut after the reason had gone. Re-reading while refusing costs -// nothing that matters: a host in that state is serving no traffic to spend the -// read on. -type policyLogServingCache struct { - mu sync.Mutex - at time.Time - state *PolicyLogServingState - dirty atomic.Bool -} - -// invalidate discards the cached answer. Called by WithPolicyLoggedNarrowing -// around the commit, so the gap it may have just opened — and the gap it may -// have just closed — are both read fresh. -func (c *policyLogServingCache) invalidate() { - if c == nil { - return - } - c.dirty.Store(true) -} - -// ErrNotServing reports that the policy log forbids this host from serving. -// -// It wraps the specific condition, so a caller that cares can tell an -// unreconciled gap from an unreachable log; a caller that does not gets one -// sentinel to test. -var ErrNotServing = errors.New("this host is not serving") - -// RequireServing refuses when the policy log says this host must not serve. -// -// Called on the REQUEST PATH, by every transport's authorization interceptor, -// and deliberately before the handler rather than inside it: a request that -// reaches a handler has already been authorized against local state the host -// may no longer be entitled to use. -// -// A failure to EVALUATE the gate is also a refusal. If the control-plane read -// errors, the host does not know whether it holds a gap, and "I could not check" -// must not be served as "there is nothing to find" — that is the one substitution -// that turns a fail-closed protocol into a fail-open one. -func (s *Service) RequireServing(ctx context.Context) error { - ok, state, err := s.mayServeCached(ctx) - if err != nil { - return status.Error(codes.Unavailable, fmt.Sprintf( - "this host cannot determine whether its authority is current: %v", err)) - } - if ok { - return nil - } - return status.Error(codes.Unavailable, notServingReason(state)) -} - -// notServingReason says which condition closed the gate, without naming the -// subjects of the gaps: a refusal is served to an unauthenticated caller as -// readily as to an administrator, and the identities whose authority was -// narrowed are not a detail that belongs in it. The operation ids are in the -// local relation for an operator who can read it. -func notServingReason(state *PolicyLogServingState) string { - switch { - case state == nil: - return "this host is not serving: the policy log state is unknown" - case state.Unreachable && len(state.Gaps) > 0: - return fmt.Sprintf("this host is not serving: the policy log has not been reached, "+ - "and %d logged narrowing(s) are not applied here", len(state.Gaps)) - case state.Unreachable: - return "this host is not serving: the policy log has not been reached within the staleness " + - "window, so this host does not know whether its authority is current" - case len(state.Gaps) > 0: - return fmt.Sprintf("this host is not serving: %d narrowing(s) the policy log has witnessed "+ - "are not applied here", len(state.Gaps)) - default: - return "this host is not serving" - } -} - -// mayServeCached is MayServe with the bounded reuse described above. The cached -// answer is dropped when this host's own append raised the dirty flag, and the -// flag is cleared only after a fresh evaluation has replaced the answer — so a -// concurrent request cannot clear it and then serve the stale decision. -func (s *Service) mayServeCached(ctx context.Context) (bool, *PolicyLogServingState, error) { - cache := &s.policyServing - now := s.policyNow() - - cache.mu.Lock() - // cache.state is set only when the answer was "may serve", so a hit is a - // remembered permission by construction and a refusal is never reused. - fresh := cache.state != nil && !cache.dirty.Load() && - now.Sub(cache.at) >= 0 && now.Sub(cache.at) < policyLogServingCacheFor - if fresh { - state := cache.state - cache.mu.Unlock() - return true, state, nil - } - cache.mu.Unlock() - - ok, state, err := s.MayServe(ctx) - if err != nil { - return false, nil, err - } - - cache.mu.Lock() - if ok { - cache.at, cache.state = now, state - cache.dirty.Store(false) - } else { - cache.state = nil - } - cache.mu.Unlock() - return ok, state, nil -} - -// ReconcilePolicyLogEvery runs reconciliation on an interval until ctx is done. -// -// On an interval and not only at startup, for the same reason the gate is on the -// request path: the two facts it maintains both change while the host runs. It -// refreshes `reached_at`, which is what keeps the staleness window open — so a -// host whose reconciliation loop dies stops serving, which is the correct -// consequence of no longer knowing the log's state. And it records entries other -// replicas appended, so a narrowing one replica logged and failed to apply -// closes every replica's gate rather than only its own. -// -// A failed pass is not fatal and is not retried faster. The cursor is not -// advanced on failure, so the staleness window closes on its own and the gate -// does the refusing; a tighter retry loop would only hammer an unreachable log. -func (s *Service) ReconcilePolicyLogEvery(ctx context.Context, every time.Duration, onError func(error)) { - if s.policyLog == nil || s.policyLogStore == nil { - // Nothing is reconcilable, and the loop would be a ticker that reports - // an empty state forever. - return - } - ticker := time.NewTicker(every) - defer ticker.Stop() - for { - if _, err := s.ReconcilePolicyLog(ctx); err != nil && onError != nil { - onError(err) - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -// PolicyLogReconcileInterval is how often the loop above runs. -// -// Shorter than PolicyLogStaleAfter, and that relationship is the point rather -// than the number: a host must get several chances to reach the log before the -// staleness window closes, or a single slow pass would take it out of service. -const PolicyLogReconcileInterval = 15 * time.Second diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings.go b/module/services/accounts/code/pkg/business/policy_log_narrowings.go deleted file mode 100644 index cc8a434e9..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_narrowings.go +++ /dev/null @@ -1,409 +0,0 @@ -package business - -import "fmt" - -// The narrowings that go through the policy log, and how each one names itself -// to it. -// -// Every function here builds one PolicyLogEntry. None of them performs the -// narrowing: the call sites wrap their own domain logic in -// WithPolicyLoggedNarrowing with the entry built here, so there is exactly one -// description of each operation and it is the one the log receives. -// -// WHY THE OPERATION ID IS FRESH ON EVERY ATTEMPT, and why that is the safe -// direction. The protocol wants the CALLER'S idempotency key, because only the -// caller knows two attempts are the same revocation. No RPC behind these -// entries carries one — there is no such field on the request messages and no -// ambient request key to derive one from — so this host genuinely cannot -// recognise a retry. The two ways to cope are not symmetric: -// -// - derive the id from the operation's inputs. A retry then collides and -// appends once, which is what the protocol asks for. But a narrowing that -// is reachable AGAIN after an authorised regrant — revoke, re-grant, revoke -// — derives the SAME id the second time, the log returns the first -// receipt, the local commit row is already committed, and the second -// revocation applies with nothing having witnessed it. That is an -// unwitnessed narrowing, the one outcome the protocol forbids outright. -// - a fresh id per attempt. A retry appends twice, so the log over-records. -// Harmless: an entry carries the authority as it stands AFTER the operation -// rather than a diff, so two entries for one revocation replay to the same -// answer, and the warehouse's replay view collapses per operation id. -// -// Over-recording is noise; under-recording is a restore that silently undoes a -// revocation. So the id is fresh, and the real fix — an idempotency key on the -// request messages, carried from the caller to here — is a contract change -// these entries will take the moment it exists. -func newPolicyLogOperationID(operation string) string { - return operation + "/" + NewIDString() -} - -// uninstallPolicyLogEntry describes revoking an installation: the agent -// principal loses its standing grant and the installation becomes revoked. -func uninstallPolicyLogEntry(actorID, orgID, installationID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("uninstall-solution"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogInstallation, - SubjectID: installationID, - Policy: map[string]any{ - "org_id": orgID, - "status": "revoked", - }, - Actor: actorID, - } -} - -// closedSolutionTargetPolicyLogEntry describes a withdrawn presence: the target -// is closed and every active installation naming it is revoked with it. -// -// The subject is the TARGET rather than each installation it revokes. The target -// is the identity whose consent period ended, and it is the one a replay can ask -// about: an installation revoked by a close is revoked because the target -// closed, so an entry per installation would record the same fact several times -// and leave a reader to work out which close they belonged to. -// -// The operation id is passed IN rather than generated here, because the apply -// transaction has to recognise the close it was appended for: the id is the -// witness reconcileSolutionTarget requires, and a second id generated here -// would leave the two halves naming different operations. -func closedSolutionTargetPolicyLogEntry( - operationID, targetID, bindingID, solutionID string, generation uint64, -) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: operationID, - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogInstallation, - SubjectID: targetID, - Policy: map[string]any{ - "binding_id": bindingID, - "solution_id": solutionID, - "closed_generation": generation, - "status": "closed", - "installations": "revoked", - }, - // The generation the withdrawal was decided against, so a replay can - // tell a close made under an older delivery from one under the current. - EnvelopeRevision: generation, - Actor: "solution:" + solutionID, - } -} - -// revokeScopePolicyLogEntry describes removing one hierarchical scope grant. -// -// The subject id spells the grant out rather than naming a row id, because the -// revocation itself is addressed that way: scope_grants is deleted by -// (org, subject, kind, path, role) and has no id the caller holds. A replay -// therefore reads the same key the narrowing used. -func revokeScopePolicyLogEntry(actorID, orgID, subjectID, subjectKind, scopePath, roleID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-scope"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogScopeGrant, - SubjectID: fmt.Sprintf("%s/%s/%s/%s/%s", - orgID, subjectKind, subjectID, scopePath, roleID), - Policy: map[string]any{ - "org_id": orgID, - "subject_id": subjectID, - "subject_kind": subjectKind, - "scope_path": scopePath, - "role_id": roleID, - "status": "revoked", - }, - Actor: actorID, - } -} - -// removeTeamMemberPolicyLogEntry describes removing a membership. A team is a -// subject permissions are granted to, so losing a membership narrows what the -// removed user may do through it. -func removeTeamMemberPolicyLogEntry(actorID, orgID, teamID, userID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("remove-team-member"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogTeamMembership, - SubjectID: teamID + "/" + userID, - Policy: map[string]any{ - "org_id": orgID, - "team_id": teamID, - "user_id": userID, - "status": "removed", - }, - Actor: actorID, - } -} - -// --------------------------------------------------------------------------- -// How a narrowing names its subject -// -// PolicyLogSubjectKind is a closed set — an operation whose subject this host -// cannot name is one a reconciliation cannot act on — so every entry below -// reports itself as one of the kinds that set admits. Which one is a uniform -// rule rather than per-call-site taste, because a reader comparing two entries -// has to be able to assume the kinds mean the same thing in both: -// -// - PolicyLogScopeGrant — a GRANT ROW addressed by its tuple: a hierarchical -// scope grant, a role assignment, a per-record share. Each one is "this -// role, to this subject, over this thing", and each is revoked by naming -// the tuple rather than a row id the caller holds, so the subject id -// spells the tuple out and a replay reads the same key the revocation used. -// - PolicyLogBinding — the STANDING AUTHORITY a subject holds in an estate, -// which is what grants hang from rather than a grant itself: an -// organization membership, a platform role, a source delegation. -// - PolicyLogPrincipal — the ACTING IDENTITY itself. Nothing about what some -// subject may do is narrowed; an actor stops being able to act at all. -// - PolicyLogInstallation, PolicyLogTeamMembership — as the entries above -// this block already use them. -// -// LABELLED STOPGAP, and the fix belongs in policy_log.go. An organization is a -// subject this host narrows — archiving one revokes every authority inside it -// — and the closed set has no kind that names it. deletedOrganizationPolicyLog -// Entry below therefore reports PolicyLogBinding, which is the nearest true -// statement (the archive's whole effect is that every standing binding into the -// organization is gone) but not the exact one: a reconciliation reading that -// entry looks for a binding whose id is an organization's. The fix is a -// PolicyLogOrganization constant in the closed set, and this entry takes it the -// moment it exists. - -// deletedOrganizationPolicyLogEntry describes archiving an organization: its -// API keys, pending invitations, installations, source delegations and every -// membership are revoked together, and with no membership left every -// request-path authorization refuses the organization. -// -// ONE ENTRY, NOT ONE PER REVOCATION, and the reason is the one -// closedSolutionTargetPolicyLogEntry gives: everything this operation revokes -// is revoked BECAUSE the organization was archived, so an entry per -// installation — or per membership, or per key — would record the same fact -// several times and leave a reader to work out which archive they belonged to. -// The organization is the identity whose authority ended, and it is the one a -// replay can ask about. -// -// It also could not be done the other way round. The per-installation entries -// would have to be appended from inside the archive's own transaction, and -// WithPolicyLoggedNarrowing cannot nest: each call opens its own -// WithControlPlane transaction, and tenant_tx.go's no-nesting rule is that a -// second transaction checks out a second pool connection while the first still -// holds one. So the archive revokes its installations through -// uninstallSolutionTx — the same function an operator's uninstall applies — -// under this one witnessed operation, rather than through UninstallSolution, -// which would try to witness each of them separately. -// -// Policy names what the archive revoked rather than listing the rows it -// touched: the ids would have to be read before the append, outside the -// transaction that does the work, and a list read then is a list that may have -// changed by the time the archive runs. What is true after the operation is -// that none of them is live, which is what an entry is for. -func deletedOrganizationPolicyLogEntry(actorID, orgID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("delete-organization"), - Decision: PolicyLogNarrowed, - // The organization itself. This reported PolicyLogBinding while the - // closed set had no kind that named an organization — a labelled - // stopgap, because an archive is not a binding and a reconciliation - // reading it would look for a binding whose id is an organization's. - // PolicyLogOrganization now exists, so the entry says what it means. - SubjectKind: PolicyLogOrganization, - SubjectID: orgID, - Policy: map[string]any{ - "org_id": orgID, - "status": "archived", - "memberships": "removed", - "api_keys": "revoked", - "pending_invitations": "revoked", - "installations": "revoked", - "source_delegations": "revoked", - }, - Actor: actorID, - } -} - -// revokeRolePolicyLogEntry describes removing one role assignment: the subject -// stops holding that role, in that organization, at that scope. -// -// The subject id spells the assignment out rather than naming a row id, for the -// reason revokeScopePolicyLogEntry does: role_assignments is revoked by -// (subject, role, org, scope) and the caller holds no id for the row. An empty -// org is a PLATFORM-level assignment and an empty scope is the organization -// whole, and both are spelled as the empty segment they are — the store matches -// them with IS NOT DISTINCT FROM, so "" and "no org" are the same key there and -// must stay the same key here. -func revokeRolePolicyLogEntry(actorID, orgID, subjectID, roleID, scope string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-role"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogScopeGrant, - SubjectID: fmt.Sprintf("%s/%s/%s/%s", orgID, subjectID, roleID, scope), - Policy: map[string]any{ - "org_id": orgID, - "subject_id": subjectID, - "role_id": roleID, - "scope": scope, - "status": "revoked", - }, - Actor: actorID, - } -} - -// revokeSharePolicyLogEntry describes removing one per-record share: the -// subject stops holding that role on that one record. -// -// The subject id spells the share out for the same reason the two entries above -// do — record_shares is deleted by (org, resource type, resource, subject, -// subject kind, role) and has no id the caller holds. -func revokeSharePolicyLogEntry( - actorID, orgID, resourceType, resourceID, subjectID, subjectKind, roleID string, -) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-share"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogScopeGrant, - SubjectID: fmt.Sprintf("%s/%s/%s/%s/%s/%s", - orgID, resourceType, resourceID, subjectKind, subjectID, roleID), - Policy: map[string]any{ - "org_id": orgID, - "resource_type": resourceType, - "resource_id": resourceID, - "subject_id": subjectID, - "subject_kind": subjectKind, - "role_id": roleID, - "status": "revoked", - }, - Actor: actorID, - } -} - -// revokePrincipalPolicyLogEntry describes revoking a principal: the identity -// itself stops being able to act, so nothing it was granted matters any more. -// -// The actor is passed in rather than taken from the request because -// RevokePrincipal has none — it is reached by operators and by internal -// incident paths, and it records its own audit event as the system. An entry -// with no actor is refused by Validate, so the caller states who it is acting -// as and the log carries that rather than an empty attribution. -// -// The organization is in Policy and not in the subject id: a human principal is -// org-less by schema (principals_org_scope), so the organization is a fact about -// this principal rather than part of its name. -func revokePrincipalPolicyLogEntry(actorID, principalID, orgID, reason string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-principal"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogPrincipal, - SubjectID: principalID, - Policy: map[string]any{ - "org_id": orgID, - "principal_id": principalID, - "status": "revoked", - "reason": reason, - }, - Actor: actorID, - } -} - -// revokeSourceDelegationPolicyLogEntry describes revoking one source -// delegation: the module binding stops holding the owner's authority over that -// source, and every later mint from it is refused. -func revokeSourceDelegationPolicyLogEntry(actorID, orgID, delegationID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-source-delegation"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogBinding, - SubjectID: delegationID, - Policy: map[string]any{ - "org_id": orgID, - "delegation_id": delegationID, - "status": "revoked", - "reason": SourceDelegationRevokedByAdmin, - }, - Actor: actorID, - } -} - -// revokeModuleDelegationPolicyLogEntry describes a person revoking their -// delegation to a module: the module stops acting for them, and every -// capability already issued from the delegation is refused. -func revokeModuleDelegationPolicyLogEntry(actorID, orgID, delegationID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-module-delegation"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogBinding, - SubjectID: delegationID, - Policy: map[string]any{ - "org_id": orgID, - "delegation_id": delegationID, - "status": "revoked", - }, - Actor: actorID, - } -} - -// removeOrgMemberPolicyLogEntry describes removing an organization membership: -// the member loses the standing every grant inside that organization hangs -// from, and the team memberships that outlive it go in the same transaction. -// -// The subject is the membership — the (organization, user) pair the removal -// addresses — rather than the user, because the user may still be a member of -// other organizations and a replay asking "what is this subject's authority" -// must not read the entry as "this person holds nothing anywhere". -func removeOrgMemberPolicyLogEntry(actorID, orgID, userID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("remove-org-member"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogBinding, - SubjectID: orgID + "/" + userID, - Policy: map[string]any{ - "org_id": orgID, - "user_id": userID, - "status": "removed", - "team_memberships": "removed", - "source_delegations": "revoked", - }, - Actor: actorID, - } -} - -// revokeAPIKeyPolicyLogEntry describes revoking an API key: the credential -// stops authenticating, so the identity it presented stops being able to act -// through it. -// -// The subject kind is the principal rather than a grant, because a key is not a -// role granted to somebody — it is an actor. Revoking it takes nothing away -// from the user who created it; it removes one of the ways that authority could -// be presented. -func revokeAPIKeyPolicyLogEntry(actorID, orgID, keyID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-api-key"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogPrincipal, - SubjectID: keyID, - Policy: map[string]any{ - "org_id": orgID, - "api_key_id": keyID, - "status": "revoked", - }, - Actor: actorID, - } -} - -// revokePlatformRolePolicyLogEntry describes removing a user's platform role: -// the widest authority this host grants, held across every organization rather -// than inside one. -// -// The subject is the user and the subject id carries no role, because the -// revocation does not name one either — platform_admins holds at most one row -// per user and the delete names the user alone. Policy says the authority that -// remains, which is none. -func revokePlatformRolePolicyLogEntry(actorID, userID string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: newPolicyLogOperationID("revoke-platform-role"), - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogBinding, - SubjectID: userID, - Policy: map[string]any{ - "user_id": userID, - "scope": "platform", - "platform_role": "none", - "status": "revoked", - }, - Actor: actorID, - } -} diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go b/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go deleted file mode 100644 index ad74b5b2a..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go +++ /dev/null @@ -1,461 +0,0 @@ -package business - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "sort" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -// The narrowing gate. It reads this package's own source, derives every -// *Service method that performs a narrowing of authority, and requires each one -// to go through WithPolicyLoggedNarrowing — or to be classified, by name, with a -// reason a reader can argue with and a PREMISE this test asserts. -// -// WHY IT DERIVES THE SET RATHER THAN PINNING IT. A gate that counted wrapped -// call sites against a number would be worthless here, and not subtly: two -// branches that each add one narrowing and each bump the same number merge -// cleanly into a total that is silently wrong, and nothing in the merge looks -// like a conflict. The same is true of a pinned list of method names — the list -// is the thing a person forgets to touch. So the inventory comes out of the AST -// on every run: a method added tomorrow is in it the moment it is written, and -// a method that is neither wrapped nor classified fails this test by name. -// -// THE THREE CLASSIFICATIONS, and why there are three rather than two: -// -// - WITNESSED: the method's body calls WithPolicyLoggedNarrowing. Nothing to -// declare. -// - narrowsNoAuthority: the method's verb is a narrowing verb but what it -// narrows is not authority — a dashboard, a notification, a sign-in path, -// the caller's own second factor. Each entry carries the reason and a -// premise this test checks. -// - unwitnessedNarrowings: the method DOES narrow authority and is NOT -// witnessed. A declared debt, not a waiver. Pretending these are exempt -// would be the lie this gate exists to prevent, and leaving them -// unclassified would make the gate red for everyone and get it deleted. So -// each one is named, with what it narrows and why it is not witnessed yet, -// and the premise asserted is that it is still unwitnessed — the day -// somebody wires it, this test fails until the entry is deleted. -// -// WHAT THE PREMISES CAN AND CANNOT DO. A premise is a TRIPWIRE, not a proof: it -// fails when a classified method starts doing something its reason says it does -// not do. It cannot establish that the reason was right in the first place — -// that is a judgement, which is why every entry is a sentence rather than a -// name. Two further limits, named rather than implied away: the inventory is -// keyed on exported *Service methods whose name begins with a narrowing verb, so -// a narrowing added as an unexported helper of a method named something else is -// outside its reach, and so is one whose method name begins with a verb nobody -// thought of. Both are failures of the naming convention, and the convention is -// what the gate reads. - -// narrowingVerbs are the method-name prefixes that make a method a candidate. -// A verb added here can only ever widen the gate's reach. -var narrowingVerbs = []string{"Revoke", "Remove", "Delete", "Uninstall", "Close", "Disable"} - -// premise is what this test checks about a classified method, so the -// classification cannot quietly stop being true. -type premise int - -const ( - // premiseNoAuthorityWrite: the method's body makes no call that both names - // an authority relation and writes to it. It fails the day the method starts - // revoking a grant, a membership, a principal or a credential. - premiseNoAuthorityWrite premise = iota - // premiseOwnUserOnly: the method writes only inside a user-scoped - // transaction, so the only authority it can reach is the acting user's own. - // It fails the day the write moves to a tenant or control-plane transaction, - // where it could reach somebody else's. - premiseOwnUserOnly - // premiseStillUnwitnessed: the method does not call - // WithPolicyLoggedNarrowing. The premise of a DEBT entry, so the entry - // cannot outlive the debt. - premiseStillUnwitnessed -) - -// classification is one declared answer about one method. -type classification struct { - why string - premise premise - // absentSubjectKind, when set, is a PolicyLogSubjectKind value whose - // ABSENCE from the closed set is part of this entry's reason. Adding that - // kind fails the entry, which is the point: the reason was "the log cannot - // name this subject", and once it can, the entry has to be re-argued. - absentSubjectKind PolicyLogSubjectKind -} - -// narrowsNoAuthority are the methods whose narrowing verb is about something -// other than authority. Each reason is a claim about what the method does, and -// the premise beside it is what this test checks so the claim cannot rot. -var narrowsNoAuthority = map[string]classification{ - "DeleteDashboard": { - why: "a dashboard is a saved view. Deleting one removes content; nobody's grant, membership or credential changes, and no reader loses access to anything but the view itself.", - premise: premiseNoAuthorityWrite, - }, - "DeleteNotification": { - why: "a notification is a message to one person, deleted by that person. It confers nothing, so its removal reduces nobody's authority.", - premise: premiseOwnUserOnly, - }, - "RevokeMFADevice": { - why: "the caller removes their OWN second factor. It narrows how that person may authenticate, not what anyone may do once authenticated, and the person doing it is the person it affects.", - premise: premiseOwnUserOnly, - }, - "DeleteSubscription": { - why: "a webhook subscription is a delivery destination. Deleting one stops events being sent to an endpoint; it grants and revokes nothing, and the endpoint never held authority here.", - premise: premiseNoAuthorityWrite, - }, - "DeleteSourceCredential": { - why: "the credential is this host's own means of reaching a third-party source. Deleting it narrows what THIS host can do outward, not what any principal may do here.", - premise: premiseNoAuthorityWrite, - }, - "DeleteDatasourceAccountLink": { - why: "an account link records that a person's third-party account is theirs. It is evidence, not authority: the membership read in this method is the authorisation for the removal, never its subject.", - premise: premiseNoAuthorityWrite, - }, - "DeleteDatasourceDomain": { - why: "a claimed domain is a verified fact about an organisation. Removing the claim grants nothing and revokes nothing a principal holds.", - premise: premiseNoAuthorityWrite, - }, - "DisableOrgIdentityProvider": { - why: "disabling a provider closes a SIGN-IN path and nothing else — the method's own documentation says sessions already minted stay valid until they expire. Nobody loses a role, a membership or a scope.", - premise: premiseNoAuthorityWrite, - }, - "DisableSSO": { - why: "the same as disabling an identity provider: the organisation stops authenticating that way, while every grant inside it stands untouched.", - premise: premiseNoAuthorityWrite, - }, - "RevokeInvitation": { - why: "an invitation is an OFFER of authority that has not been taken. Until it is accepted the invitee holds nothing, so revoking it reduces nothing — and accepting one is the grant, which is a widening the log records on its own path.", - premise: premiseNoAuthorityWrite, - }, -} - -// unwitnessedNarrowings are narrowings of authority that do NOT go through the -// policy log. Each is a known hole, named with what it narrows and why it is -// still open — not a waiver of the requirement, and not a place to park a new -// one: a method added here needs a sentence somebody has to write and a reader -// can refuse. -var unwitnessedNarrowings = map[string]classification{ - "RevokeApprovedExecutableArtifact": { - why: "withdrawing consent to run an approved executable artifact narrows authority by its own name: the artifact was runnable under that installation and is not any more, so a restore that lost the revocation hands the authority back silently. It is unwitnessed for a GRANT reason and not a design one, which is why this entry can say exactly what closes it. The receipt and the narrowing have to be one transaction and that transaction is the policy log's CONTROL-PLANE one, but `19_executable_artifact_approvals.up.sql:23-24` grants SELECT/INSERT and UPDATE (revoked_at, revoked_by) to app_tenant ALONE — app_control_plane holds nothing on the table, and the tenant RLS policy is written for app_tenant. So wrapping the existing body in WithPolicyLoggedNarrowing today fails with permission denied on the revoke, which is fail-closed but broken. Closing it is a migration granting app_control_plane the same narrow UPDATE plus SELECT, a policy that admits it, and splitting the method into a read-and-authorize phase in the caller's organisation transaction followed by an append-and-revoke phase on the control plane — the shape UninstallSolution already uses, whose own comment explains why every statement then names the organisation explicitly. The split opens a check-to-revoke window, and its direction is safe: it can only revoke an approval the actor was authorized for a moment earlier, which is a narrowing.", - premise: premiseStillUnwitnessed, - }, - "DeleteRole": { - why: "deleting a custom role narrows every subject that holds it, all at once. Witnessing it needs the entry to name a subject the closed set has no kind for — the ROLE, not an assignment of it — so it waits on that kind the same way the organisation archive does.", - premise: premiseStillUnwitnessed, - }, - "DeleteTeam": { - why: "a team is a subject roles are granted to, so deleting one narrows every member through it — the same fact RemoveTeamMember witnesses one member at a time. It needs one entry for the team, which is a subject the closed set does not name either.", - premise: premiseStillUnwitnessed, - }, - "DisableAgentPrincipal": { - why: "a reversible suspension of an agent is a narrowing while it stands, and lifting it is the authorised regrant the log exists to distinguish from 'never taken away'. Both halves belong in the log, and the pair is a decision about regrants rather than a second copy of the revoke path.", - premise: premiseStillUnwitnessed, - }, - "DeleteUser": { - why: "soft-deleting an identity makes it unusable everywhere at once, which is the broadest narrowing this host performs. It is not witnessed because the subject is a user rather than a principal, and the two are deliberately separate lifecycles here.", - premise: premiseStillUnwitnessed, - }, - "DeleteDatasourceSource": { - why: "removing a source revokes the delegations that named it, in the same transaction. The delegation revocations are the narrowing, and witnessing them needs one entry for the source — the fact they all share — rather than the per-delegation entries this path would otherwise append.", - premise: premiseStillUnwitnessed, - }, - "RevokeSession": { - why: "ending a session ends live minted authority, so it is a narrowing. The closed subject-kind set names no session, and a session is the one subject whose authority expires on its own — which is the argument for a kind of its own rather than for borrowing another.", - premise: premiseStillUnwitnessed, - absentSubjectKind: "session", - }, -} - -// narrowingMethod is one candidate the AST walk found. -type narrowingMethod struct { - name string - file string - line int - witnessed bool - // authorityWrites are calls in the body that both name an authority - // relation and write to it, as the premise check reads them. - authorityWrites []string - // transactions are the transaction openers the body uses. - transactions []string -} - -func TestPolicyLogNarrowings_EveryNarrowingPathIsWitnessed(t *testing.T) { - methods := collectNarrowingMethods(t) - require.NotEmpty(t, methods, "the gate found no narrowing methods at all; the AST walk is broken") - require.True(t, len(methods) > len(narrowsNoAuthority)+len(unwitnessedNarrowings), - "every candidate the walk found is classified away and none is witnessed; that cannot be right, so the walk or the classifications are wrong") - - usedExempt := map[string]bool{} - usedDebt := map[string]bool{} - var violations []string - - for _, method := range methods { - exempt, isExempt := narrowsNoAuthority[method.name] - debt, isDebt := unwitnessedNarrowings[method.name] - - require.False(t, isExempt && isDebt, - "%s is classified both as narrowing no authority and as an unwitnessed narrowing; it cannot be both", method.name) - - switch { - case method.witnessed: - if isExempt { - violations = append(violations, fmt.Sprintf( - "%s:%d %s goes through WithPolicyLoggedNarrowing but is listed in narrowsNoAuthority; delete the entry", - method.file, method.line, method.name)) - } - if isDebt { - violations = append(violations, fmt.Sprintf( - "%s:%d %s is witnessed now — delete its unwitnessedNarrowings entry, the debt is paid", - method.file, method.line, method.name)) - } - case isExempt: - usedExempt[method.name] = true - _ = exempt - case isDebt: - usedDebt[method.name] = true - _ = debt - default: - violations = append(violations, fmt.Sprintf( - "%s:%d %s narrows authority by its name and does not go through WithPolicyLoggedNarrowing. "+ - "Wrap it with an entry builder in policy_log_narrowings.go — or, if it narrows something other "+ - "than authority, add it to narrowsNoAuthority with the sentence that says why; if it does narrow "+ - "authority and cannot be witnessed yet, declare it in unwitnessedNarrowings with what it narrows "+ - "and what is blocking.", - method.file, method.line, method.name)) - } - } - - sort.Strings(violations) - require.Empty(t, violations, "narrowing coverage violations:\n%s", strings.Join(violations, "\n")) - - for name := range narrowsNoAuthority { - require.True(t, usedExempt[name], - "stale narrowsNoAuthority entry %q: no such unwitnessed narrowing-verb method any more — delete the entry", name) - } - for name := range unwitnessedNarrowings { - require.True(t, usedDebt[name], - "stale unwitnessedNarrowings entry %q: no such unwitnessed narrowing-verb method any more — delete the entry", name) - } -} - -// TestPolicyLogNarrowings_ClassificationPremisesHold is the half that makes the -// classifications worth having. Each entry says what its method does; this -// asserts the thing that would have to change for the sentence to be false. -func TestPolicyLogNarrowings_ClassificationPremisesHold(t *testing.T) { - byName := map[string]narrowingMethod{} - for _, method := range collectNarrowingMethods(t) { - byName[method.name] = method - } - - kinds := map[PolicyLogSubjectKind]bool{} - for _, kind := range []PolicyLogSubjectKind{ - PolicyLogPrincipal, PolicyLogBinding, PolicyLogInstallation, - PolicyLogTeamMembership, PolicyLogScopeGrant, - } { - kinds[kind] = true - } - - check := func(t *testing.T, name string, declared classification) { - t.Helper() - method, found := byName[name] - require.True(t, found, "%s is classified but no longer exists; the stale-entry check in the test above covers this", name) - - switch declared.premise { - case premiseNoAuthorityWrite: - require.Empty(t, method.authorityWrites, - "%s is classified as narrowing no authority (%s) but its body writes to an authority relation: %s. "+ - "Either the classification is wrong and this is a narrowing to witness, or the call is a read "+ - "the premise check cannot tell from a write.", - name, declared.why, strings.Join(method.authorityWrites, ", ")) - case premiseOwnUserOnly: - require.NotEmpty(t, method.transactions, - "%s is classified as reaching only the acting user's own rows, but opens no transaction at all; the premise cannot be checked", name) - for _, opener := range method.transactions { - require.Equal(t, "WithUserTx", opener, - "%s is classified as reaching only the acting user's own rows (%s) but writes inside %s, which can reach somebody else's", - name, declared.why, opener) - } - case premiseStillUnwitnessed: - require.False(t, method.witnessed, - "%s is declared an unwitnessed narrowing but is witnessed now; delete its entry", name) - } - - if declared.absentSubjectKind != "" { - require.False(t, kinds[declared.absentSubjectKind], - "%s is classified on the grounds that the closed subject-kind set cannot name a %q, and now it can. "+ - "Re-argue the entry: the reason it rested on is gone.", - name, declared.absentSubjectKind) - require.False(t, declared.absentSubjectKind.Valid(), - "%q is accepted by PolicyLogSubjectKind.Valid but is not in the set this test enumerates; one of the two is out of date", - declared.absentSubjectKind) - } - } - - for name, declared := range narrowsNoAuthority { - t.Run("narrows_no_authority/"+name, func(t *testing.T) { check(t, name, declared) }) - } - for name, declared := range unwitnessedNarrowings { - t.Run("unwitnessed/"+name, func(t *testing.T) { check(t, name, declared) }) - } -} - -// authorityRelations are the words that make a call's target an authority -// relation. A method that WRITES to one of these is narrowing or widening what -// somebody may do, whatever its own name says. -var authorityRelations = []string{ - "Role", "Permission", "Grant", "Share", "Scope", "Member", "Membership", - "Principal", "APIKey", "Delegation", "Installation", "Team", "Session", - "PlatformAdmin", -} - -// readPrefixes are the call-name prefixes that make a call a read. Everything -// else that names an authority relation is treated as a write, which is the -// conservative direction: a misread read shows up as a classification to -// justify, a misread write shows up as nothing at all. -var readPrefixes = []string{ - "Get", "List", "Count", "Exists", "Resolve", "Lookup", "Load", "Read", - "Find", "Is", "Has", "Can", "Require", "Verify", "Check", "Declared", -} - -// transactionOpeners are the calls that open a store transaction, so a premise -// about which rows a method can reach has something to read. -var transactionOpeners = []string{"WithOrgTx", "WithUserTx", "WithControlPlane", "Within"} - -// collectNarrowingMethods parses the package's own non-test sources and returns -// every exported *Service method whose name begins with a narrowing verb, with -// what the gate needs to know about its body. -func collectNarrowingMethods(t *testing.T) []narrowingMethod { - t.Helper() - - fset := token.NewFileSet() - entries, err := os.ReadDir(".") - require.NoError(t, err) - - var out []narrowingMethod - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - parsed, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) - require.NoError(t, err, "parse %s", name) - - for _, decl := range parsed.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Recv == nil || fn.Body == nil { - continue - } - if !isServiceReceiver(fn.Recv) || !ast.IsExported(fn.Name.Name) || !hasNarrowingVerb(fn.Name.Name) { - continue - } - method := narrowingMethod{ - name: fn.Name.Name, - file: name, - line: fset.Position(fn.Pos()).Line, - } - ast.Inspect(fn.Body, func(n ast.Node) bool { - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - called := calledName(call) - if called == "" { - return true - } - switch { - case called == "WithPolicyLoggedNarrowing": - method.witnessed = true - case contains(transactionOpeners, called): - method.transactions = append(method.transactions, called) - case namesAuthorityRelation(called) && !hasReadPrefix(called) && !isValueProjection(called): - method.authorityWrites = append(method.authorityWrites, called) - } - return true - }) - out = append(out, method) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) - return out -} - -// isServiceReceiver reports whether a method is on *Service, whatever the -// receiver is named. Keying on a receiver named "s" would let a method that -// names its receiver anything else narrow authority unseen. -func isServiceReceiver(recv *ast.FieldList) bool { - if recv == nil || len(recv.List) != 1 { - return false - } - star, ok := recv.List[0].Type.(*ast.StarExpr) - if !ok { - return false - } - ident, ok := star.X.(*ast.Ident) - return ok && ident.Name == "Service" -} - -func hasNarrowingVerb(name string) bool { - for _, verb := range narrowingVerbs { - if strings.HasPrefix(name, verb) { - return true - } - } - return false -} - -// calledName is the name a call expression invokes — the selector for a method -// call, the identifier for a plain function call. -func calledName(call *ast.CallExpr) string { - switch fun := call.Fun.(type) { - case *ast.SelectorExpr: - return fun.Sel.Name - case *ast.Ident: - return fun.Name - } - return "" -} - -func namesAuthorityRelation(called string) bool { - for _, relation := range authorityRelations { - if strings.Contains(called, relation) { - return true - } - } - return false -} - -// isValueProjection reports whether a call is a pure conversion of a value -// already in hand — an enum to the spelling a column uses, a row to its wire -// form. It names an authority relation and touches none: there is no relation -// on the other side of it to write to. -func isValueProjection(called string) bool { - return strings.Contains(called, "ToString") || - strings.Contains(called, "ToProto") || - strings.Contains(called, "Payload") || - strings.Contains(called, "Labels") -} - -func hasReadPrefix(called string) bool { - for _, prefix := range readPrefixes { - if strings.HasPrefix(called, prefix) || strings.HasPrefix(called, strings.ToLower(prefix[:1])+prefix[1:]) { - return true - } - } - return false -} - -func contains(haystack []string, needle string) bool { - for _, candidate := range haystack { - if candidate == needle { - return true - } - } - return false -} diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go b/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go deleted file mode 100644 index 45c08e9f3..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go +++ /dev/null @@ -1,273 +0,0 @@ -//go:build pure - -package business - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - gen "accounts/pkg/gen/saas/accounts/v1" -) - -// What each narrowing tells the log, and that the narrowing paths genuinely run -// under the protocol rather than merely having an entry builder nearby. -// -// The coverage gate beside this file reads the source and answers "is every -// narrowing wrapped". That is a structural question, and it cannot answer the -// two that matter next: whether the entry a wrapped path offers the log is one -// the log would accept, and whether a host with no log actually REFUSES the -// operation. Both are behavioural, so both are asserted here by calling the -// real methods. - -// everyNarrowingEntry is each builder with the inputs one call site passes it. -// Table-driven so a builder added without a case here shows up in the count -// assertion below rather than silently going unchecked. -func everyNarrowingEntry() []struct { - name string - entry *PolicyLogEntry - wantKind PolicyLogSubjectKind - wantSubject string - wantActor string -} { - return []struct { - name string - entry *PolicyLogEntry - wantKind PolicyLogSubjectKind - wantSubject string - wantActor string - }{ - { - name: "uninstall-solution", - entry: uninstallPolicyLogEntry("admin-1", "org-1", "install-1"), - wantKind: PolicyLogInstallation, - wantSubject: "install-1", - wantActor: "admin-1", - }, - { - name: "revoke-scope", - entry: revokeScopePolicyLogEntry("admin-1", "org-1", "user-1", "SUBJECT_KIND_USER", "/acme/eu", "role-1"), - wantKind: PolicyLogScopeGrant, - wantSubject: "org-1/SUBJECT_KIND_USER/user-1//acme/eu/role-1", - wantActor: "admin-1", - }, - { - name: "remove-team-member", - entry: removeTeamMemberPolicyLogEntry("admin-1", "org-1", "team-1", "user-1"), - wantKind: PolicyLogTeamMembership, - wantSubject: "team-1/user-1", - wantActor: "admin-1", - }, - { - name: "delete-organization", - entry: deletedOrganizationPolicyLogEntry("admin-1", "org-1"), - wantKind: PolicyLogOrganization, - wantSubject: "org-1", - wantActor: "admin-1", - }, - { - name: "revoke-role", - entry: revokeRolePolicyLogEntry("admin-1", "org-1", "user-1", "role-1", "/acme"), - wantKind: PolicyLogScopeGrant, - wantSubject: "org-1/user-1/role-1//acme", - wantActor: "admin-1", - }, - { - name: "revoke-share", - entry: revokeSharePolicyLogEntry("admin-1", "org-1", "document", "doc-1", "user-1", "SUBJECT_KIND_USER", "role-1"), - wantKind: PolicyLogScopeGrant, - wantSubject: "org-1/document/doc-1/SUBJECT_KIND_USER/user-1/role-1", - wantActor: "admin-1", - }, - { - name: "revoke-principal", - entry: revokePrincipalPolicyLogEntry("system", "principal-1", "org-1", "credential compromise"), - wantKind: PolicyLogPrincipal, - wantSubject: "principal-1", - wantActor: "system", - }, - { - name: "revoke-source-delegation", - entry: revokeSourceDelegationPolicyLogEntry("admin-1", "org-1", "delegation-1"), - wantKind: PolicyLogBinding, - wantSubject: "delegation-1", - wantActor: "admin-1", - }, - { - name: "remove-org-member", - entry: removeOrgMemberPolicyLogEntry("admin-1", "org-1", "user-1"), - wantKind: PolicyLogBinding, - wantSubject: "org-1/user-1", - wantActor: "admin-1", - }, - { - name: "revoke-api-key", - entry: revokeAPIKeyPolicyLogEntry("admin-1", "org-1", "key-1"), - wantKind: PolicyLogPrincipal, - wantSubject: "key-1", - wantActor: "admin-1", - }, - { - name: "revoke-platform-role", - entry: revokePlatformRolePolicyLogEntry("admin-1", "user-1"), - wantKind: PolicyLogBinding, - wantSubject: "user-1", - wantActor: "admin-1", - }, - { - name: "close-solution-target", - entry: closedSolutionTargetPolicyLogEntry("op-1", "target-1", "binding-1", "solution-1", 4), - wantKind: PolicyLogInstallation, - wantSubject: "target-1", - wantActor: "solution:solution-1", - }, - } -} - -// Every entry a narrowing offers the log has to be one the log would accept and -// a replay could read: a valid subject kind, a subject it names, an actor, and -// the resulting authority rather than a diff. -func TestNarrowingEntries_AreReplayable(t *testing.T) { - for _, tc := range everyNarrowingEntry() { - t.Run(tc.name, func(t *testing.T) { - require.NoError(t, tc.entry.Validate(), - "the log refuses this entry, so the narrowing it describes would be refused too") - require.Equal(t, PolicyLogNarrowed, tc.entry.Decision) - require.Equal(t, tc.wantKind, tc.entry.SubjectKind) - require.True(t, tc.entry.SubjectKind.Valid(), - "a subject kind outside the closed set is one no reconciliation could act on") - require.Equal(t, tc.wantSubject, tc.entry.SubjectID) - require.Equal(t, tc.wantActor, tc.entry.Actor) - require.NotEmpty(t, tc.entry.Policy, - "an entry with no policy says nothing about the authority that remains") - require.Contains(t, tc.entry.Policy, "status", - "Policy carries the authority as it stands AFTER the operation, so each one states the resulting status") - }) - } -} - -// The operation id is FRESH on every attempt, deliberately: an id derived from -// the operation's inputs collides with a REVOKE → REGRANT → REVOKE sequence and -// lets the second revocation apply against the first's receipt, unwitnessed. -// Over-recording is the safe direction (policy_log_narrowings.go says why), and -// this holds the builders to it. -// -// The one exception is the solution-target close, whose id is passed in because -// the apply transaction has to recognise the close it was appended for. -func TestNarrowingEntries_OperationIDsAreFreshPerAttempt(t *testing.T) { - first := everyNarrowingEntry() - second := everyNarrowingEntry() - for i, tc := range first { - if tc.name == "close-solution-target" { - require.Equal(t, tc.entry.OperationID, second[i].entry.OperationID, - "the close's id is the caller's, so two builds of it must agree") - continue - } - t.Run(tc.name, func(t *testing.T) { - require.NotEmpty(t, tc.entry.OperationID) - require.NotEqual(t, tc.entry.OperationID, second[i].entry.OperationID, - "two attempts at the same narrowing derived the same operation id; a retry would reuse the first receipt and the second narrowing would apply unwitnessed") - }) - } -} - -// A host with NO policy log refuses to narrow, at the real entry points. This is -// the wiring assertion: the refusal can only come from -// WithPolicyLoggedNarrowing, so a call site that stopped going through it — or -// never did — fails here with its own name. -// -// The five below are the narrowings reachable with no database: the rest read -// the store before they append (an actor type, a principal's organisation, the -// caller's platform role), and the production path covers those against real -// PostgreSQL in policy_log_production_path_test.go. -func TestWitnessedNarrowings_AHostWithNoLogRefuses(t *testing.T) { - ctx := context.Background() - // Canonical ids, where the entry fixtures above are content with "org-1". - // - // An entry point that canonicalises its arguments refuses a malformed id - // BEFORE it reaches the narrowing, which is right — nothing has been - // narrowed, so there is nothing to witness — but the refusal it returns is - // InvalidArgument, not ErrPolicyLogUnreachable, and this test reads that as - // "the call site is not going through WithPolicyLoggedNarrowing". The - // diagnosis would be the opposite of the truth. - // - // So every id here is one that passes validation, and a path that gains a - // canonicalising guard tomorrow still arrives at the question this test - // asks. Each is distinct, so a mix-up in a request literal cannot read as - // a pass. - const ( - actorID = "57313bcf-d7be-44cc-95cd-2d3080325257" - orgID = "5fc85e13-db69-42ba-ba18-328b29c61819" - userID = "68b1505b-0c31-40cc-a871-ae6bff2d4473" - roleID = "ede17c79-091a-4dc9-a24a-60569d3a8fe6" - apiKeyID = "ef0c9ce1-46aa-4d8a-9ec6-395445123229" - delegationID = "054e2d3a-9144-4b8d-87a9-64f14c0c7394" - documentID = "60d33f79-183a-4b9a-8091-f0b388c32eb4" - ) - for _, tc := range []struct { - name string - call func(*Service) error - }{ - {"RevokeRole", func(s *Service) error { - return s.RevokeRole(ctx, actorID, &gen.RevokeRoleRequest{ - OrgId: orgID, SubjectId: userID, RoleId: roleID, Scope: "/acme", - }) - }}, - {"RevokeShare", func(s *Service) error { - return s.RevokeShare(ctx, actorID, &gen.RevokeShareRequest{ - OrgId: orgID, ResourceType: "document", ResourceId: documentID, - SubjectId: userID, RoleId: roleID, - }) - }}, - {"RevokeAPIKey", func(s *Service) error { - return s.RevokeAPIKey(ctx, actorID, &gen.RevokeAPIKeyRequest{ - Id: apiKeyID, OrganizationId: orgID, - }) - }}, - {"RemoveOrgMember", func(s *Service) error { - return s.RemoveOrgMember(ctx, actorID, &gen.RemoveOrgMemberRequest{ - OrgId: orgID, UserId: userID, - }) - }}, - {"RevokeSourceDelegation", func(s *Service) error { - _, err := s.RevokeSourceDelegation(ctx, actorID, orgID, delegationID) - return err - }}, - } { - t.Run(tc.name, func(t *testing.T) { - // No SetPolicyLog: this host cannot witness anything. The store - // panics on any method these paths were not meant to reach, so a - // call that got as far as writing would say so loudly rather than - // passing. - service := &Service{store: noopControlPlaneStore{}} - require.ErrorIs(t, tc.call(service), ErrPolicyLogUnreachable, - "%s narrowed (or tried to) on a host with no policy log; it is not going through WithPolicyLoggedNarrowing", tc.name) - }) - } -} - -// The organisation archive names the ORGANISATION, and this is what that -// stopgap became. -// -// It was written the other way round first: deletedOrganizationPolicyLogEntry -// reported PolicyLogBinding because the closed set had no kind for an -// organisation, and this test was the tripwire on that label — it required the -// stopgap to still be the nearest available statement, and failed the day a -// real kind appeared. The kind now exists, so the assertion inverts: the entry -// must use it, and must NOT be a binding. -// -// PolicyLogBinding is asserted against explicitly rather than just asserting -// the new value, because reverting to it is the specific regression this -// covers: an archive reported as a binding sends a reconciliation looking for a -// binding whose id is an organisation's, which it will never find. -func TestDeletedOrganizationEntryNamesTheOrganisationItArchives(t *testing.T) { - entry := deletedOrganizationPolicyLogEntry("admin-1", "org-1") - require.Equal(t, PolicyLogOrganization, entry.SubjectKind) - require.NotEqual(t, PolicyLogBinding, entry.SubjectKind, - "an archive is not a binding; a replay reading it would look for a binding whose id is an organisation's") - require.True(t, entry.SubjectKind.Valid(), - "the kind must be in the closed set, or a reconciliation cannot act on the entry at all") - require.Equal(t, "org-1", entry.SubjectID) - require.NoError(t, entry.Validate()) -} diff --git a/module/services/accounts/code/pkg/business/policy_log_production_path_test.go b/module/services/accounts/code/pkg/business/policy_log_production_path_test.go deleted file mode 100644 index b4bfff0ff..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_production_path_test.go +++ /dev/null @@ -1,561 +0,0 @@ -//go:build !pure - -package business_test - -import ( - "context" - "strings" - "sync" - "testing" - "time" - - "accounts/pkg/business" - gen "accounts/pkg/gen/saas/accounts/v1" - - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// The policy log on the PRODUCTION PATH: real narrowings, through the real -// service, against real PostgreSQL. -// -// WHAT IS REAL HERE AND WHAT IS NOT, because the distinction is the whole value -// of this file. The thing under test is the HOST'S half of the protocol — that a -// real uninstall appends before it applies, that the receipt and the revocation -// are one transaction, and that a commit which fails after an append landed -// stops this host serving. Every part of that half is the production one: -// `Service.UninstallSolution`, `WithPolicyLoggedNarrowing`, the receipts in -// `policy_log_commits` through `infra.PostgresStore`, and `RequireServing`. -// -// The external log is the one collaborator that is a double, and it has to be: -// it is external by design, it lives in a warehouse, and no deployment of it -// exists (pkg/infra/warehouse_policy_log.go says why). A double for a -// collaborator is not a double for the thing under test — the mistake this PR -// has already paid for twice is faking the PolicyLogStore, which IS the thing -// under test, and that one is real postgres here. -// -// The commit failures below are not injected either. They are real database -// errors from real production statements given input they cannot accept, which -// is why the gap they leave is a gap the real store wrote. - -// recordingPolicyLog stands in for the external warehouse log: it issues real -// monotonic sequences and remembers every entry, so a test can assert what the -// host offered it and in what order. -type recordingPolicyLog struct { - mu sync.Mutex - entries []*business.PolicyLogEntry - records []*business.PolicyLogRecord - sequence uint64 - failWith error -} - -func (l *recordingPolicyLog) Append( - _ context.Context, entry *business.PolicyLogEntry, -) (*business.PolicyLogReceipt, error) { - l.mu.Lock() - defer l.mu.Unlock() - if l.failWith != nil { - return nil, l.failWith - } - l.sequence++ - receipt := &business.PolicyLogReceipt{ - Receipt: "warehouse-receipt-" + entry.OperationID, - Sequence: l.sequence, - AppendedAt: time.Now().UTC(), - } - copied := *entry - l.entries = append(l.entries, &copied) - l.records = append(l.records, &business.PolicyLogRecord{ - PolicyLogEntry: copied, - Receipt: receipt.Receipt, - Sequence: receipt.Sequence, - LoggedAt: receipt.AppendedAt, - }) - return receipt, nil -} - -func (l *recordingPolicyLog) Entries( - _ context.Context, after uint64, _ int, -) ([]*business.PolicyLogRecord, error) { - l.mu.Lock() - defer l.mu.Unlock() - if l.failWith != nil { - return nil, l.failWith - } - var out []*business.PolicyLogRecord - for _, record := range l.records { - if record.Sequence > after { - out = append(out, record) - } - } - return out, nil -} - -// appended returns the entries offered since a mark, so a test reads only its -// own narrowings out of a log the whole package shares. -func (l *recordingPolicyLog) appended(from int) []*business.PolicyLogEntry { - l.mu.Lock() - defer l.mu.Unlock() - return append([]*business.PolicyLogEntry(nil), l.entries[from:]...) -} - -func (l *recordingPolicyLog) mark() int { - l.mu.Lock() - defer l.mu.Unlock() - return len(l.entries) -} - -// testPolicyLog is the log every business DB test's narrowings go through. -// -// Wired for the WHOLE package rather than per test, on purpose: once a narrowing -// requires a witness, a service without a log cannot uninstall, revoke a scope -// or remove a team member at all, so every test that performs one would be -// testing a host in a configuration production is not meant to have. -var testPolicyLog = &recordingPolicyLog{} - -// wirePolicyLogForTests gives the package's service the protocol's two halves: -// the double for the external log, and the REAL postgres store for the local -// receipts and cursor. -func wirePolicyLogForTests(service *business.Service, store business.PolicyLogStore) { - service.SetPolicyLog(testPolicyLog, store) -} - -// reachTheLog runs a real reconciliation pass so `reached_at` is fresh. -// -// The serving gate refuses a host that has not reached the log inside the -// staleness window, and a host that has never reached it at all has a NULL -// `reached_at` — so without this a serving assertion would pass for the wrong -// reason, reporting the gap it was looking for as staleness. -func reachTheLog(t *testing.T, ctx context.Context) *business.PolicyLogServingState { - t.Helper() - state, err := testService.ReconcilePolicyLog(ctx) - require.NoError(t, err) - return state -} - -// closeGap commits an operation whose narrowing never applied, so one test's -// deliberate gap does not keep the host shut for every test after it. -// ClearAll does not touch the policy log relations — a receipt is evidence an -// append happened and the schema makes neither deletable — so the cleanup is a -// commit, which is also what an operator closing a real gap does. -func closeGap(t *testing.T, ctx context.Context, operationID string) { - t.Helper() - require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { - return testStore.CommitPolicyLogOperation(ctx, operationID, time.Now().UTC()) - })) -} - -// closeEveryGap is the cleanup a test that deliberately opens a gap owes the -// tests after it. -// -// It runs even when the test FAILS before reaching its own close, which is the -// case that matters: a gap left behind keeps every later serving assertion -// refusing, and that refusal reads as the later test's own bug. Registered with -// t.Cleanup rather than written at the end of the body for exactly that reason. -func closeEveryGap(t *testing.T, ctx context.Context) { - t.Helper() - for _, gap := range uncommittedOperations(t, ctx) { - closeGap(t, ctx, gap.OperationID) - } -} - -// requireCommitted asserts that one operation is NOT among the uncommitted, -// rather than that nothing is. -// -// `clearData` cannot reset the policy log relations — neither is deletable, by -// design, because a receipt is the evidence that an append happened — so a gap -// another test left behind is still in the table. Asserting emptiness would make -// this test report somebody else's leftover as its own failure; asserting about -// the operation it appended is the claim it can actually make. -func requireCommitted(t *testing.T, ctx context.Context, operationID string) { - t.Helper() - for _, gap := range uncommittedOperations(t, ctx) { - require.NotEqual(t, operationID, gap.OperationID, - "the narrowing committed, so its receipt must have committed in the same transaction") - } -} - -func uncommittedOperations(t *testing.T, ctx context.Context) []*business.PolicyLogGap { - t.Helper() - var gaps []*business.PolicyLogGap - require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { - out, err := testStore.UncommittedPolicyLogOperations(ctx) - gaps = out - return err - })) - return gaps -} - -// A real uninstall, through the real service: it appends, and the revocation and -// the receipt's commit land together. -// -// This is the test the condition asks for. Nothing is stubbed on the host's -// side: the installation is composed by InstallSolution, revoked by -// UninstallSolution, and the receipt is read back out of PostgreSQL. -func TestARealUninstallAppendsAndThenCommitsItsReceipt(t *testing.T) { - clearData(t) - ctx := testCtx - mark := testPolicyLog.mark() - - adminID, orgID := mustUserAndOrg(t, ctx, "pluninstall@example.com", "pluninstall", "PolicyLog Co") - roleID := seedInstallableRole(t, ctx, orgID) - targetID, _ := declarePresence(t, "pluninstall-solution") - installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/pluninstall:1.0.0", - TargetID: targetID, - RootScopeLabel: "PolicyLog Solution", - RoleID: roleID, - AllowedScopes: []string{"doc"}, - }) - require.NoError(t, err) - acknowledgeInstallation(t, installation) - - // An install is not a narrowing, so it must not have been offered to the - // log. A protocol that appended on every write would make "was authority - // reduced" unanswerable from the log. - require.Empty(t, testPolicyLog.appended(mark), - "installing is not a narrowing and must not reach the policy log") - - require.NoError(t, testService.UninstallSolution(ctx, adminID, orgID, installation.Id)) - - offered := testPolicyLog.appended(mark) - require.Len(t, offered, 1, "one uninstall is one appended entry") - entry := offered[0] - require.Equal(t, business.PolicyLogNarrowed, entry.Decision) - require.Equal(t, business.PolicyLogInstallation, entry.SubjectKind) - require.Equal(t, installation.Id, entry.SubjectID) - require.Equal(t, adminID, entry.Actor) - require.Equal(t, orgID, entry.Policy["org_id"]) - require.Equal(t, "revoked", entry.Policy["status"], - "the entry carries the authority as it stands AFTER the operation") - - // The receipt committed, so this operation is no gap: the revocation and the - // receipt were one transaction. - requireCommitted(t, ctx, entry.OperationID) - - // And the narrowing really happened — the receipt is not the only thing - // that landed. - _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID("pluninstall"), orgID, installation.Id) - require.ErrorIs(t, err, business.ErrModuleInstallationInactive) -} - -// A commit that fails AFTER its append landed stops this host serving, and keeps -// it stopped until the gap closes. -// -// The failure is real: RevokeScope's production statement is handed an -// organisation id PostgreSQL cannot cast to uuid, so the transaction carrying -// both the delete and the receipt's commit aborts — after the append has already -// been accepted by the log and recorded here. That is precisely the -// append-ok/commit-fail state, reached without reaching into the protocol. -func TestACommitFailingAfterTheAppendStopsThisHostServing(t *testing.T) { - clearData(t) - ctx := testCtx - mark := testPolicyLog.mark() - // The gate refuses on ANY gap, so a gap another test left in a relation - // nothing truncates would make the refusal below unattributable. Closed - // before, and closed after even if this test fails on the way. - closeEveryGap(t, ctx) - t.Cleanup(func() { closeEveryGap(t, ctx) }) - - // Serving first, so the refusal below is attributable to the gap. - reachTheLog(t, ctx) - require.NoError(t, testService.RequireServing(ctx), - "the host must be serving before the gap, or the assertion proves nothing") - - err := testService.RevokeScope(ctx, "policy-log-actor", &gen.RevokeScopeRequest{ - OrgId: "not-a-uuid", - SubjectId: "11111111-1111-1111-1111-111111111111", - SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, - ScopePath: "root", - RoleId: "22222222-2222-2222-2222-222222222222", - }) - require.Error(t, err, "the narrowing's transaction must have failed") - - offered := testPolicyLog.appended(mark) - require.Len(t, offered, 1, "the append must have landed before the commit was attempted") - operationID := offered[0].OperationID - - gaps := uncommittedOperations(t, ctx) - require.Len(t, gaps, 1) - require.Equal(t, operationID, gaps[0].OperationID, - "the appended-but-unapplied operation is the one whose commit failed") - - // THE SERVING CONSEQUENCE. The log says a narrowing happened and this host - // has not applied it, so it must not serve the wider authority it still - // holds locally. - servingErr := testService.RequireServing(ctx) - require.Error(t, servingErr, "a host holding an unapplied logged narrowing must stop serving") - require.Equal(t, codes.Unavailable, status.Code(servingErr), - "an unreconciled host is UNABLE, not refusing the caller: a PermissionDenied here would tell "+ - "every caller their authority was revoked when nothing of theirs was") - require.Contains(t, servingErr.Error(), "not applied here") - - ok, state, err := testService.MayServe(ctx) - require.NoError(t, err) - require.False(t, ok) - require.Len(t, state.Gaps, 1) - require.False(t, state.Unreachable, "the log was reachable; the gap is the reason") - - // Still refused on a later request, not just the first: the gate is not a - // one-shot and the cached answer does not expire into serving. - require.Error(t, testService.RequireServing(ctx)) - - // EVERY REPLICA, not only the one whose commit failed. A second host over - // the same database — its own process state, its own gate cache, having - // performed no narrowing itself — must refuse too, because the gap lives in - // the shared control-plane relation rather than in the memory of the - // process that opened it. A gap that closed only its own replica would - // leave the others serving exactly the authority the log says was revoked. - replica, err := business.NewService(testStore) - require.NoError(t, err) - replica.SetPolicyLog(testPolicyLog, testStore) - replicaErr := replica.RequireServing(ctx) - require.Error(t, replicaErr, "a replica that did not perform the narrowing must still stop serving") - require.Equal(t, codes.Unavailable, status.Code(replicaErr)) - - // Closing the gap resumes serving. Nothing else changed, so the gap is what - // the gate was refusing on. - closeGap(t, ctx, operationID) - require.Empty(t, uncommittedOperations(t, ctx)) - require.NoError(t, testService.RequireServing(ctx), - "the host must serve again once the gap is closed") - require.NoError(t, replica.RequireServing(ctx), - "the replica must serve again too: it read the gap from the shared relation, "+ - "so closing it there is what reopens every host") -} - -// An append the log refuses narrows nothing, and leaves the host serving. -// -// The other half of the asymmetry: authority was not reduced and nobody was told -// it was, so there is nothing to fail closed about. -func TestANarrowingTheLogRefusesChangesNothing(t *testing.T) { - clearData(t) - ctx := testCtx - mark := testPolicyLog.mark() - - adminID, orgID := mustUserAndOrg(t, ctx, "plrefuse@example.com", "plrefuse", "Refuse Co") - roleID := seedInstallableRole(t, ctx, orgID) - targetID, alias := declarePresence(t, "plrefuse-solution") - installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/plrefuse:1.0.0", - TargetID: targetID, - RootScopeLabel: "Refuse Solution", - RoleID: roleID, - AllowedScopes: []string{"doc"}, - }) - require.NoError(t, err) - acknowledgeInstallation(t, installation) - - testPolicyLog.mu.Lock() - testPolicyLog.failWith = errPolicyLogDown - testPolicyLog.mu.Unlock() - defer func() { - testPolicyLog.mu.Lock() - testPolicyLog.failWith = nil - testPolicyLog.mu.Unlock() - }() - - err = testService.UninstallSolution(ctx, adminID, orgID, installation.Id) - require.Error(t, err) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) - - require.Empty(t, testPolicyLog.appended(mark)) - require.Empty(t, uncommittedOperations(t, ctx), - "a refused append records no receipt, so there is no gap") - - // The installation is untouched: the narrowing did not happen. - _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID(alias), orgID, installation.Id) - require.NoError(t, err, "a refused append must leave authority exactly as it was") -} - -var errPolicyLogDown = &policyLogDownError{} - -type policyLogDownError struct{} - -func (*policyLogDownError) Error() string { return "warehouse unreachable" } - -// A real team-membership removal and a real scope-grant revocation both append. -// -// Together with the uninstall above and the withdrawal test beside it, this is -// the set the condition names; they are one test because each is the same -// assertion about a different call site. -func TestTeamRemovalAndScopeRevocationAppendBeforeTheyApply(t *testing.T) { - clearData(t) - ctx := testCtx - - ownerID, orgID := mustUserAndOrg(t, ctx, "plteam@example.com", "plteam", "Team Co") - memberID, _ := mustUserAndOrg(t, ctx, "plmember@example.com", "plmember", "Member Co") - require.NoError(t, testService.AddOrgMember(ctx, ownerID, &gen.AddOrgMemberRequest{ - OrgId: orgID, UserId: memberID, Role: gen.OrgRole_ORG_ROLE_MEMBER, - })) - team, err := testService.CreateTeam(ctx, ownerID, &gen.CreateTeamRequest{OrgId: orgID, Name: "Operators"}) - require.NoError(t, err) - require.NoError(t, testService.AddTeamMember(ctx, ownerID, &gen.AddTeamMemberRequest{ - TeamId: team.Team.Id, UserId: memberID, Role: gen.TeamRole_TEAM_ROLE_MEMBER, - })) - - mark := testPolicyLog.mark() - require.NoError(t, testService.RemoveTeamMember(ctx, ownerID, &gen.RemoveTeamMemberRequest{ - TeamId: team.Team.Id, UserId: memberID, - })) - offered := testPolicyLog.appended(mark) - require.Len(t, offered, 1) - require.Equal(t, business.PolicyLogTeamMembership, offered[0].SubjectKind) - require.Equal(t, team.Team.Id+"/"+memberID, offered[0].SubjectID) - require.Equal(t, business.PolicyLogNarrowed, offered[0].Decision) - requireCommitted(t, ctx, offered[0].OperationID) - - roleID := seedInstallableRole(t, ctx, orgID) - // A grant must name a REGISTERED node: the scope tree is the authority, so - // granting on a path nothing registered is refused before the policy log is - // reached, and the revocation below would have nothing to narrow. - _, err = testService.RegisterScopeNode(ctx, ownerID, &gen.RegisterScopeNodeRequest{ - OrgId: orgID, ScopePath: "plsol", Kind: business.ScopeNodeKindSolution, Label: "PolicyLog Solution", - }) - require.NoError(t, err) - grant, err := testService.GrantScope(ctx, ownerID, &gen.GrantScopeRequest{ - OrgId: orgID, - SubjectId: memberID, - SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, - ScopePath: "plsol", - RoleId: roleID, - }) - require.NoError(t, err) - - mark = testPolicyLog.mark() - require.NoError(t, testService.RevokeScope(ctx, ownerID, &gen.RevokeScopeRequest{ - OrgId: orgID, - SubjectId: memberID, - SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, - ScopePath: grant.Grant.ScopePath, - RoleId: grant.Grant.RoleId, - })) - offered = testPolicyLog.appended(mark) - require.Len(t, offered, 1) - require.Equal(t, business.PolicyLogScopeGrant, offered[0].SubjectKind) - require.True(t, strings.HasPrefix(offered[0].SubjectID, orgID+"/"), - "a scope grant is named by the key the revocation is addressed with") - requireCommitted(t, ctx, offered[0].OperationID) -} - -// A read the log refuses does NOT advance the cursor, which is what makes the -// staleness window close and the host stop serving. -// -// The window itself is unit-tested against a fixed clock (policy_log_test.go); -// what only a real store can show is that a failed pass leaves `reached_at` -// exactly where the last successful one put it. A pass that advanced it on -// failure would keep a host that cannot read the log reporting itself current -// forever — the deleted UnavailablePolicyLog's permanent-outage twin, failing -// open instead of closed. -func TestAFailedReadDoesNotAdvanceTheCursorThatKeepsTheHostServing(t *testing.T) { - clearData(t) - ctx := testCtx - - closeEveryGap(t, ctx) - reachTheLog(t, ctx) - require.NoError(t, testService.RequireServing(ctx)) - - var reachedBefore *time.Time - var sequenceBefore uint64 - require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { - var err error - sequenceBefore, reachedBefore, err = testStore.PolicyLogCursorState(ctx) - return err - })) - require.NotNil(t, reachedBefore) - - testPolicyLog.mu.Lock() - testPolicyLog.failWith = errPolicyLogDown - testPolicyLog.mu.Unlock() - defer func() { - testPolicyLog.mu.Lock() - testPolicyLog.failWith = nil - testPolicyLog.mu.Unlock() - reachTheLog(t, ctx) - }() - - _, err := testService.ReconcilePolicyLog(ctx) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) - - var reachedAfter *time.Time - var sequenceAfter uint64 - require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { - var err error - sequenceAfter, reachedAfter, err = testStore.PolicyLogCursorState(ctx) - return err - })) - require.NotNil(t, reachedAfter) - require.True(t, reachedBefore.Equal(*reachedAfter), - "an unreachable log must not refresh the instant the staleness window is measured from") - require.Equal(t, sequenceBefore, sequenceAfter, - "an unreachable log must not advance the reconciled sequence past entries nobody read") -} - -// A real withdrawal, through the real reconciler: closing the solution target -// appends before it closes, and commits its receipt in the same transaction. -// -// The target close is the narrowing with the widest blast radius of the four — -// it revokes EVERY active installation naming the target — and it is the only -// one that does not start from an RPC, so it is the one most easily left off -// the protocol. It is driven here the way delivery drives it: a present -// generation, then a tombstone, through the reconciler's own pass. -func TestARealWithdrawalAppendsBeforeItClosesTheTarget(t *testing.T) { - solutionID := testDeclaredSolutionID(t) - present := declaredBinding(t, solutionID, 4) - mount := &deliveredSet{} - mount.put(t, present) - reconciler := newTestReconciler(t, mount) - require.NoError(t, reconciler.RunOnce(testCtx)) - opened := liveTarget(t, present.Binding) - require.NotNil(t, opened) - - // Presenting is not a narrowing: opening a target starts a period an - // organisation may consent to, and nothing's authority was reduced. - mark := testPolicyLog.mark() - require.Empty(t, testPolicyLog.appended(mark)) - - mount.put(t, tombstoneOf(t, present, 5)) - require.NoError(t, reconciler.RunOnce(testCtx)) - - offered := testPolicyLog.appended(mark) - require.Len(t, offered, 1, "one withdrawal with a live target is one appended entry") - entry := offered[0] - require.Equal(t, business.PolicyLogNarrowed, entry.Decision) - require.Equal(t, opened.ID, entry.SubjectID, - "the subject is the target identity whose consent period ended") - require.Equal(t, uint64(5), entry.EnvelopeRevision, - "the entry names the generation the withdrawal was decided against") - require.Equal(t, "closed", entry.Policy["status"]) - require.Equal(t, "revoked", entry.Policy["installations"]) - requireCommitted(t, testCtx, entry.OperationID) - - require.Nil(t, liveTarget(t, present.Binding), "the narrowing really applied") -} - -// A withdrawal for a binding with NO live target appends nothing. -// -// There is no period to end, so there is no narrowing to witness — and an entry -// here would be a logged narrowing that narrowed nothing, a gap that nothing -// could ever close by re-running the operation. -func TestAWithdrawalWithNoLiveTargetAppendsNothing(t *testing.T) { - solutionID := testDeclaredSolutionID(t) - present := declaredBinding(t, solutionID, 2) - mount := &deliveredSet{} - mount.put(t, present) - reconciler := newTestReconciler(t, mount) - require.NoError(t, reconciler.RunOnce(testCtx)) - - mount.put(t, tombstoneOf(t, present, 3)) - require.NoError(t, reconciler.RunOnce(testCtx)) - require.Nil(t, liveTarget(t, present.Binding)) - - // A second withdrawal at a higher generation: nothing is live any more. - mark := testPolicyLog.mark() - mount.put(t, tombstoneOf(t, present, 4)) - require.NoError(t, reconciler.RunOnce(testCtx)) - require.Empty(t, testPolicyLog.appended(mark), - "a withdrawal with nothing live to close must not append an entry nothing could close") -} diff --git a/module/services/accounts/code/pkg/business/policy_log_test.go b/module/services/accounts/code/pkg/business/policy_log_test.go deleted file mode 100644 index 2ee07ee0d..000000000 --- a/module/services/accounts/code/pkg/business/policy_log_test.go +++ /dev/null @@ -1,546 +0,0 @@ -//go:build pure - -package business - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// The policy log protocol, and every way a host could end up serving authority -// the log says was revoked. -// -// These run on a fake log and a fake local store, in-package, because what is -// under test is the ORDERING and the failure handling — which of the two writes -// happens first, and what is true after each one fails. A database would add -// nothing to that and would hide the interleavings behind a transaction. - -// noopControlPlaneStore satisfies the Store interface by embedding it, so every -// method exists and only the one these tests reach is implemented. -// -// Embedding rather than writing out the whole interface: a hand-written stub of -// a large interface has to be updated whenever the interface grows, and the -// update is mechanical, so it gets done without thought. An embedded nil panics -// loudly if a test reaches a method it did not mean to — which is the behaviour -// worth having, since these tests are about ordering and not about persistence. -type noopControlPlaneStore struct{ Store } - -func (noopControlPlaneStore) WithControlPlane(ctx context.Context, fn func(ctx context.Context) error) error { - return fn(ctx) -} - -// fakePolicyLog records what it was asked and can be made to fail or hang. -type fakePolicyLog struct { - appends []*PolicyLogEntry - receipts map[string]*PolicyLogReceipt - nextSeq uint64 - - appendErr error - entriesErr error - // forceReceipt makes Append return exactly this, including nil, so a - // malformed receipt is testable without pre-seeding the idempotency map — - // which a first draft tried and could not express "returns nil". - forceReceipt *PolicyLogReceipt - forceReceiptSet bool - entries []*PolicyLogRecord - // block makes Append wait, so the bounded deadline is testable. - block time.Duration -} - -func newFakePolicyLog() *fakePolicyLog { - return &fakePolicyLog{receipts: map[string]*PolicyLogReceipt{}} -} - -func (f *fakePolicyLog) Append(ctx context.Context, entry *PolicyLogEntry) (*PolicyLogReceipt, error) { - if f.block > 0 { - select { - case <-time.After(f.block): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - if f.appendErr != nil { - return nil, f.appendErr - } - if f.forceReceiptSet { - return f.forceReceipt, nil - } - // Idempotent on the operation id, as the real log must be: a retry returns - // the original receipt rather than appending again. - if existing, ok := f.receipts[entry.OperationID]; ok { - return existing, nil - } - f.nextSeq++ - receipt := &PolicyLogReceipt{ - Receipt: "receipt-" + entry.OperationID, - Sequence: f.nextSeq, - AppendedAt: time.Unix(1700000000, 0).UTC(), - } - f.receipts[entry.OperationID] = receipt - f.appends = append(f.appends, entry) - return receipt, nil -} - -func (f *fakePolicyLog) Entries(_ context.Context, after uint64, _ int) ([]*PolicyLogRecord, error) { - if f.entriesErr != nil { - return nil, f.entriesErr - } - var out []*PolicyLogRecord - for _, record := range f.entries { - if record.Sequence > after { - out = append(out, record) - } - } - return out, nil -} - -// fakePolicyLogStore is the local half, with hooks to fail the commit. -type fakePolicyLogStore struct { - appended map[string]*PolicyLogGap - committed map[string]time.Time - sequence uint64 - reachedAt *time.Time - - commitErr error - appendErr error -} - -func newFakePolicyLogStore() *fakePolicyLogStore { - return &fakePolicyLogStore{ - appended: map[string]*PolicyLogGap{}, - committed: map[string]time.Time{}, - } -} - -func (f *fakePolicyLogStore) RecordPolicyLogAppend( - _ context.Context, receipt *PolicyLogReceipt, entry *PolicyLogEntry, -) (bool, error) { - if f.appendErr != nil { - return false, f.appendErr - } - if _, ok := f.appended[entry.OperationID]; ok { - return false, nil - } - f.appended[entry.OperationID] = &PolicyLogGap{ - OperationID: entry.OperationID, - Sequence: receipt.Sequence, - SubjectKind: entry.SubjectKind, - SubjectID: entry.SubjectID, - AppendedAt: receipt.AppendedAt, - } - return true, nil -} - -func (f *fakePolicyLogStore) CommitPolicyLogOperation(_ context.Context, id string, at time.Time) error { - if f.commitErr != nil { - return f.commitErr - } - if _, ok := f.appended[id]; !ok { - return errors.New("no append recorded for " + id) - } - if _, already := f.committed[id]; !already { - f.committed[id] = at - } - return nil -} - -func (f *fakePolicyLogStore) UncommittedPolicyLogOperations(_ context.Context) ([]*PolicyLogGap, error) { - gaps := []*PolicyLogGap{} - for id, gap := range f.appended { - if _, done := f.committed[id]; !done { - gaps = append(gaps, gap) - } - } - return gaps, nil -} - -func (f *fakePolicyLogStore) PolicyLogCursorState(_ context.Context) (uint64, *time.Time, error) { - return f.sequence, f.reachedAt, nil -} - -func (f *fakePolicyLogStore) AdvancePolicyLogCursor(_ context.Context, sequence uint64, at time.Time) error { - if sequence > f.sequence { - f.sequence = sequence - } - stamp := at - f.reachedAt = &stamp - return nil -} - -// policyLogService wires a service with both fakes and a frozen clock. -// -// A frozen clock, not a real one: the staleness window is a duration, and a test -// that waited for real time would either be slow or would assert on a window it -// had not actually crossed. -func policyLogService(t *testing.T, log PolicyLog, store PolicyLogStore, now time.Time) *Service { - t.Helper() - service := &Service{store: noopControlPlaneStore{}} - service.SetPolicyLog(log, store) - service.policyClock = func() time.Time { return now } - return service -} - -func narrowing(id string) *PolicyLogEntry { - return &PolicyLogEntry{ - OperationID: id, - Decision: PolicyLogNarrowed, - SubjectKind: PolicyLogPrincipal, - SubjectID: "principal-1", - Actor: "admin-1", - } -} - -// --------------------------------------------------------------------------- -// The ordering: append BEFORE anything narrows -// --------------------------------------------------------------------------- - -// The happy path appends, records, applies and commits — and the APPLY must not -// have run before the append landed. -func TestNarrowingAppendsBeforeItApplies(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - // A serving host has reached the log. Without this the MayServe assertion - // below would pass for the WRONG reason — staleness rather than the gap — - // and a first draft of this test failed here, which is the implementation - // refusing to let the test lie about why it was happy. - store.reachedAt = &now - - applied := false - require.NoError(t, service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - // By the time the narrowing runs, the log must already have - // witnessed it: otherwise a crash here leaves authority reduced - // with nothing to say so. - require.Len(t, log.appends, 1, "the append must precede the narrowing") - require.Contains(t, store.appended, "op-1", "the receipt must be recorded before the narrowing") - applied = true - return nil - })) - require.True(t, applied) - require.Contains(t, store.committed, "op-1") - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.True(t, mayServe) - require.Empty(t, state.Gaps) -} - -// An append that FAILS narrows nothing. Safe, because authority was not reduced -// and nobody was told it was. -func TestNarrowingIsRefusedWhenTheAppendFails(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - log.appendErr = errors.New("warehouse unavailable") - service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) - - applied := false - err := service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - applied = true - return nil - }) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.False(t, applied, "nothing may narrow when the log did not witness it") - require.Empty(t, store.appended) -} - -// A log that returns NO usable receipt has witnessed nothing, whatever it -// returned. Storing an empty receipt would make "witnessed" and "attempted" -// indistinguishable afterwards. -func TestNarrowingIsRefusedWhenTheLogReturnsNoReceipt(t *testing.T) { - for name, receipt := range map[string]*PolicyLogReceipt{ - "nothing at all": nil, - "no token": {Sequence: 1}, - "no sequence": {Receipt: "r"}, - } { - t.Run(name, func(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - log.forceReceipt, log.forceReceiptSet = receipt, true - service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) - - applied := false - err := service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - applied = true - return nil - }) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.False(t, applied, "a narrowing the log did not witness must not apply") - require.Empty(t, store.appended, "and no receipt may be recorded for it") - }) - } -} - -// --------------------------------------------------------------------------- -// The asymmetry: append-ok / commit-fail must FAIL CLOSED -// --------------------------------------------------------------------------- - -// The central case. The log says the narrowing happened; this host did not apply -// it; so this host MUST STOP SERVING until the gap is closed. -// -// Serving the wider authority it still holds locally would be serving authority -// that was revoked — and the log, which is the record, has already said so. -func TestAppendedButUncommittedNarrowingStopsServing(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - store.commitErr = errors.New("database went away mid-transaction") - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - // The log has been reached recently, so staleness is NOT what refuses here. - store.reachedAt = &now - - err := service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { return nil }) - require.Error(t, err) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.False(t, mayServe, "a host with an unapplied logged narrowing must not serve") - require.Len(t, state.Gaps, 1) - require.Equal(t, "op-1", state.Gaps[0].OperationID) - require.False(t, state.Unreachable, "the refusal must be the GAP, not staleness") -} - -// Once the gap is closed, serving resumes. Without this the first test could -// pass against a host that never serves at all. -func TestServingResumesWhenTheGapIsClosed(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - store.commitErr = errors.New("transient") - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - store.reachedAt = &now - - require.Error(t, service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { return nil })) - mayServe, _, _ := service.MayServe(context.Background()) - require.False(t, mayServe) - - // The retry succeeds: the log returns the SAME receipt, and the commit lands. - store.commitErr = nil - require.NoError(t, service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { return nil })) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.True(t, mayServe) - require.Empty(t, state.Gaps) - require.Len(t, log.appends, 1, "a retry of one operation must append ONCE") -} - -// A narrowing whose APPLY fails leaves the gap too, and that is deliberate: the -// log has witnessed the operation, so until this host either applies it or an -// operator resolves it, the host must not serve the authority the log says is -// gone. -func TestFailedApplyAlsoStopsServing(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - store.reachedAt = &now - - require.Error(t, service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - return errors.New("the narrowing itself failed") - })) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.False(t, mayServe) - require.Len(t, state.Gaps, 1) -} - -// --------------------------------------------------------------------------- -// Refuse to serve while the log is unreachable -// --------------------------------------------------------------------------- - -// A host that has never reached the log does not know whether its authority is -// current, so it does not serve. -func TestNeverHavingReachedTheLogStopsServing(t *testing.T) { - service := policyLogService(t, newFakePolicyLog(), newFakePolicyLogStore(), - time.Unix(1700000100, 0).UTC()) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.False(t, mayServe) - require.True(t, state.Unreachable) - require.Nil(t, state.LastReachedAt) -} - -// Having reached it too long ago is the same answer. Serving what the host last -// believed is serving authority that may have been revoked since. -func TestStaleLogContactStopsServing(t *testing.T) { - store := newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - stale := now.Add(-PolicyLogStaleAfter - time.Second) - store.reachedAt = &stale - service := policyLogService(t, newFakePolicyLog(), store, now) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.False(t, mayServe) - require.True(t, state.Unreachable) -} - -// Inside the window it serves. The window is not zero deliberately: a log round -// trip per request would make the log's availability the host's own, and a blip -// would become an outage. -func TestRecentLogContactServes(t *testing.T) { - store := newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - fresh := now.Add(-PolicyLogStaleAfter + time.Second) - store.reachedAt = &fresh - service := policyLogService(t, newFakePolicyLog(), store, now) - - mayServe, state, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.True(t, mayServe) - require.False(t, state.Unreachable) -} - -// --------------------------------------------------------------------------- -// The bounded deadline -// --------------------------------------------------------------------------- - -// A log that hangs costs a refusal, not an indefinite wait. The point is lock -// accumulation: the append happens before any transaction opens, so a slow log -// cannot hold database locks — but a caller blocked forever still holds its -// request and everything above it. -func TestASlowLogRefusesRatherThanHanging(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - log.block = PolicyLogDeadline + 2*time.Second - service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) - - applied := false - started := time.Now() - err := service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - applied = true - return nil - }) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.False(t, applied, "a narrowing the log did not witness must not apply") - require.Less(t, time.Since(started), PolicyLogDeadline+time.Second, - "the append must be bounded by PolicyLogDeadline") - require.Empty(t, store.appended) -} - -// --------------------------------------------------------------------------- -// Reconciliation finds a gap another replica opened -// --------------------------------------------------------------------------- - -// A narrowing appended by ANOTHER replica — one this host never performed — -// becomes a gap here, and stops this host serving until it is applied. -// -// This is the requirement that a startup-only check cannot meet: the entry -// appears in the log at runtime, and every replica must honour it. -func TestReconciliationFindsAnotherReplicasAppend(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - store.reachedAt = &now - - mayServe, _, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.True(t, mayServe, "the control: nothing is outstanding yet") - - // Another replica appended a revocation this host knows nothing about. - log.entries = []*PolicyLogRecord{{ - PolicyLogEntry: *narrowing("op-elsewhere"), - Receipt: "receipt-op-elsewhere", - Sequence: 7, - LoggedAt: now, - }} - - state, err := service.ReconcilePolicyLog(context.Background()) - require.NoError(t, err) - require.Len(t, state.Gaps, 1) - require.Equal(t, "op-elsewhere", state.Gaps[0].OperationID) - - mayServe, _, err = service.MayServe(context.Background()) - require.NoError(t, err) - require.False(t, mayServe, "a narrowing another replica logged must stop this host too") - require.Equal(t, uint64(7), store.sequence, "the cursor advances past what was recorded") -} - -// An unreachable log does NOT advance the cursor, so the staleness window closes -// and the host stops serving. A reconciler that advanced the cursor on failure -// would make an unreachable log look like a quiet one. -func TestReconciliationDoesNotAdvanceTheCursorWhenTheLogIsUnreachable(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - log.entriesErr = errors.New("warehouse unavailable") - now := time.Unix(1700000100, 0).UTC() - store.reachedAt = &now - store.sequence = 3 - service := policyLogService(t, log, store, now) - - _, err := service.ReconcilePolicyLog(context.Background()) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.Equal(t, uint64(3), store.sequence, "the cursor must not move when the log was not read") - require.Equal(t, &now, store.reachedAt, "nor may reached_at, or staleness would never trigger") -} - -// --------------------------------------------------------------------------- -// What may be logged -// --------------------------------------------------------------------------- - -// An entry the log could not be replayed from must not reach the log: a bad -// entry that is accepted is worse than a refused operation, because it is a gap -// nothing can close. -func TestUnreplayableEntriesAreRefusedBeforeTheyReachTheLog(t *testing.T) { - for name, entry := range map[string]*PolicyLogEntry{ - "no operation id": {Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, SubjectID: "p", Actor: "a"}, - "no actor": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, SubjectID: "p"}, - "no subject": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, Actor: "a"}, - "unknown kind": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: "other", SubjectID: "p", Actor: "a"}, - "unknown decision": {OperationID: "op", Decision: "deleted", SubjectKind: PolicyLogPrincipal, - SubjectID: "p", Actor: "a"}, - } { - t.Run(name, func(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) - require.Error(t, service.WithPolicyLoggedNarrowing( - context.Background(), entry, func(context.Context) error { return nil })) - require.Empty(t, log.appends, "a malformed entry must not reach the log") - }) - } -} - -// An authorised REGRANT is logged too. Without it the log cannot tell "this -// authority was restored" from "it was never taken away", and those have -// different answers to whether the subject may act now. -func TestRegrantsAreLoggedAsWell(t *testing.T) { - log, store := newFakePolicyLog(), newFakePolicyLogStore() - now := time.Unix(1700000100, 0).UTC() - service := policyLogService(t, log, store, now) - - entry := narrowing("op-regrant") - entry.Decision = PolicyLogRegranted - require.NoError(t, service.WithPolicyLoggedNarrowing( - context.Background(), entry, func(context.Context) error { return nil })) - require.Len(t, log.appends, 1) - require.Equal(t, PolicyLogRegranted, log.appends[0].Decision) -} - -// A host with NO log refuses to narrow, because the narrowing would be -// unwitnessed and a restore would undo it silently — while still serving, because -// nothing has been narrowed through the protocol and there is nothing -// unreconciled. The two answers look inconsistent and are not. -func TestWithoutALogNarrowingIsRefusedButServingContinues(t *testing.T) { - service := &Service{store: noopControlPlaneStore{}} - service.policyClock = func() time.Time { return time.Unix(1700000100, 0).UTC() } - - applied := false - err := service.WithPolicyLoggedNarrowing( - context.Background(), narrowing("op-1"), func(context.Context) error { - applied = true - return nil - }) - require.ErrorIs(t, err, ErrPolicyLogUnreachable) - require.False(t, applied) - - mayServe, _, err := service.MayServe(context.Background()) - require.NoError(t, err) - require.True(t, mayServe) -} diff --git a/module/services/accounts/code/pkg/business/principals.go b/module/services/accounts/code/pkg/business/principals.go index 9791a0c17..7a658d2ed 100644 --- a/module/services/accounts/code/pkg/business/principals.go +++ b/module/services/accounts/code/pkg/business/principals.go @@ -444,29 +444,12 @@ func (s *Service) CreateAgentPrincipal(ctx context.Context, req CreateAgentReque // lifecycles are still separate, which is a deliberate gap kept for code that // reads users directly. // -// It is a NARROWING — the identity itself stops being able to act — so it runs -// under the policy log: the entry is appended to the external record and -// receipted BEFORE the row is revoked, and the revocation commits in the same -// transaction as the receipt's commit. A host that cannot witness the append -// refuses rather than revoking unwitnessed. -// -// WHAT CHANGED ABOUT THE TRANSACTION, and what did not. Before this, the write -// and its audit event ran in the scope the principal's own row belongs to: a -// tenant transaction for an org-scoped principal, the control plane for an -// org-less human (withPrincipalScope says why the two are not interchangeable). -// The receipt relation is control-plane only and the receipt has to commit with -// the revocation, so both now run under the control plane. The org-less half is -// unchanged; the org-scoped half loses the tenant policy, and what replaces it -// is that every statement here names the principal by id — GetPrincipal and -// RevokePrincipal are id point-writes, not scans — and the audit row carries -// the organisation read back from the principal itself rather than one a caller -// supplied. -// -// The scope lookup stays OUTSIDE the wrapper, deliberately: the entry has to -// name the organisation before the append, and the append happens before any -// transaction opens. Opening one inside the wrapper's transaction is not an -// option either — WithPolicyLoggedNarrowing's apply already runs in a -// control-plane transaction and tenant_tx.go forbids nesting a second. +// It is a NARROWING — the identity itself stops being able to act — so it +// applies directly (narrow): the revocation and its audit event commit in one +// control-plane transaction. Every statement names the principal by id — +// GetPrincipal and RevokePrincipal are id point-writes, not scans — and the +// audit row carries the organisation read back from the principal itself +// rather than one a caller supplied. func (s *Service) RevokePrincipal(ctx context.Context, id, reason string) error { w := wool.Get(ctx).In("RevokePrincipal", wool.Field("principal_id", id)) @@ -478,62 +461,31 @@ func (s *Service) RevokePrincipal(ctx context.Context, id, reason string) error // readable reason being recorded. return w.NewError("reason required (no silent revocations)") } - orgID, err := s.principalOrgID(ctx, id) - if err != nil { - return w.Wrapf(err, "cannot resolve principal scope") - } // The actor is "system" because this surface takes none: it is reached by // operators and by internal incident paths, and it has always recorded its - // audit event as the system. The log refuses an entry with no actor, so the - // same attribution is stated rather than left empty. - if err := s.WithPolicyLoggedNarrowing(ctx, - revokePrincipalPolicyLogEntry("system", id, orgID, reason), - func(ctx context.Context) error { - // Re-read inside that transaction so "did this call change anything" - // and the write itself are one read-modify-write, and an idempotent - // repeat emits no duplicate audit event. - p, e := s.principalStore().GetPrincipal(ctx, id) - if e != nil { - return e - } - alreadyRevoked := p.IsRevoked() - if err := s.principalStore().RevokePrincipal(ctx, id, reason); err != nil { - return err - } - if alreadyRevoked { - return nil - } - return s.emitTx(ctx, "system", "system", EventPrincipalRevoked, "principal", id, orgID, - map[string]any{"reason": reason}) - }); err != nil { - return w.Wrapf(err, "cannot revoke principal") - } - w.Info("principal revoked", wool.Field("reason", reason)) - return nil -} - -// principalOrgID reads the organisation a principal's row belongs to, "" for an -// org-less human principal (principals_org_scope). It is the first half of what -// withPrincipalScope does, split out for a caller that needs the organisation -// BEFORE it opens the transaction it will write in — a policy-logged narrowing -// has to name its subject's organisation in the entry it appends, and the append -// happens before any transaction opens. -// -// The lookup spans tenants by necessity, as withPrincipalScope's does: the -// caller has no organisation yet, and it reads nothing else. -func (s *Service) principalOrgID(ctx context.Context, id string) (string, error) { - var orgID string - if err := s.store.As(System()).Within(ctx, func(ctx context.Context) error { - p, err := s.principalStore().GetPrincipal(ctx, id) - if err != nil { + // audit event as the system. + if err := s.narrow(ctx, func(ctx context.Context) error { + // Re-read inside that transaction so "did this call change anything" + // and the write itself are one read-modify-write, and an idempotent + // repeat emits no duplicate audit event. + p, e := s.principalStore().GetPrincipal(ctx, id) + if e != nil { + return e + } + alreadyRevoked := p.IsRevoked() + if err := s.principalStore().RevokePrincipal(ctx, id, reason); err != nil { return err } - orgID = p.OrgID - return nil + if alreadyRevoked { + return nil + } + return s.emitTx(ctx, "system", "system", EventPrincipalRevoked, "principal", id, p.OrgID, + map[string]any{"reason": reason}) }); err != nil { - return "", err + return w.Wrapf(err, "cannot revoke principal") } - return orgID, nil + w.Info("principal revoked", wool.Field("reason", reason)) + return nil } // DisableAgentPrincipal reversibly suspends an agent principal. Idempotent: diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index 21407d6cd..cbfa992c7 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -181,23 +181,8 @@ type Service struct { frontendSource frontendpayload.Source frontendLazyLoadWindow time.Duration - // The policy log's wiring: the external append-only record, this host's - // local receipts and cursor, and an overridable clock. - // - // Both nil means no log. WithPolicyLoggedNarrowing then REFUSES — a host - // that cannot witness a narrowing must not perform one — while MayServe - // allows serving, because nothing has been narrowed through the protocol and - // there is therefore nothing unreconciled. Those two answers look - // inconsistent and are not: the first is about reducing authority, the - // second about honouring reductions that were recorded. - policyLog PolicyLog - policyLogStore PolicyLogStore - policyClock func() time.Time - // policyServing is the serving gate's bounded memory of its last - // answer. It lives here rather than in the gate because the gate is - // consulted per request and the answer is per host; see - // policy_log_gate.go for why the reuse window is safe. - policyServing policyLogServingCache + // policyClock is the clock narrowing-dependent reads judge time by; nil means time.Now. + policyClock func() time.Time // The live-authority reads enforcement at use depends on. Nil means this // host cannot re-read, and AuthorizeModuleCapability then refuses every @@ -221,6 +206,9 @@ type Service struct { // super_admin writes (module_envelope.go). Nil answers no question about // the ceiling: the RPCs refuse and the reconcilers derive nothing. moduleEnvelopes ModuleEnvelopeStore + // authorityCore decides every operation of the host interfaces: the shared + // authority core over this host's store (authority_core.go). + authorityCore *authorityCoreState // admissionKeys persists the hop's registered admission keys // (hop_admission_keys.go). Nil publishes none and registers none. admissionKeys AdmissionKeyStore @@ -241,14 +229,6 @@ func (s *Service) SetModuleAuthorityReads( s.moduleOperationBindings = bindings } -// SetPolicyLog wires the append-only authority record and its local half. -// -// Boot wiring, called once before the service listens. -func (s *Service) SetPolicyLog(log PolicyLog, store PolicyLogStore) { - s.policyLog = log - s.policyLogStore = store -} - // SetSolutionDelivery wires the delivery endpoint: the pinned-key verifier, // the inbox's persistence and the carrier authorizer. Which ownership domains a // signer may deliver under is the verifier's own pin policy. diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index 1dfb0af83..7b3af1c6e 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -148,13 +148,6 @@ func runBusinessTests(m *testing.M) int { } service.SetWebhookJobProducer(store) - // The policy log's two halves. Every narrowing — uninstall, solution-target - // close, scope-grant revocation, team-membership removal — now REFUSES on a - // host with no log, so a service without one could not exercise any of them. - // The local half is the real postgres store; only the external warehouse is a - // double (policy_log_production_path_test.go says why). - wirePolicyLogForTests(service, store) - // Wire optional components. The key service is bound exactly as work.go // binds it, so these suites exercise the real stored envelope format rather // than a stand-in that cannot disagree with it. diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go index 26b8b63d2..81e03c454 100644 --- a/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go +++ b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go @@ -230,20 +230,8 @@ func (s *Service) applySolutionHostBinding( } // A withdrawal CLOSES the installable target and revokes every active - // installation naming it. That is a narrowing of authority, so it must be - // witnessed by the external policy log before it takes effect, and the - // append has to happen BEFORE this transaction opens — a slow log must not - // hold database locks. Whether a close is coming is only knowable from the - // live target, so it is read here, unlocked; the locked read inside the - // transaction is still the one that decides. - // - // The two reads can disagree, and only one direction is dangerous. If this - // one saw a live target and the locked one finds none, another replica - // closed it first: the appended entry is still true and commits. If this - // one saw none and the locked one finds one, a close would run with nothing - // appended for it — so reconcileSolutionTarget REFUSES an unwitnessed - // close, and the next pass, which now reads a live target, carries it. - var witnessedTargetClose string + // installation naming it. That narrowing applies directly: the close, the + // revocations and the audit event commit in this one transaction. apply := func(ctx context.Context) error { record, err := s.store.GetSolutionHostBindingForUpdate(ctx, document.Binding) if err != nil { @@ -302,7 +290,7 @@ func (s *Service) applySolutionHostBinding( // The identity an organisation installs moves with this transaction, so a // reused alias cannot carry an installation across to another binding // (solution_targets.go). - targetID, err := s.reconcileSolutionTarget(ctx, document, solutionID, witnessedTargetClose, now) + targetID, err := s.reconcileSolutionTarget(ctx, document, solutionID, now) if err != nil { return err } @@ -402,52 +390,9 @@ func (s *Service) applySolutionHostBinding( }) } - // A withdrawal: find the target this binding has live, and run the whole - // transaction under the policy log when there is one to close. - if document.Removed { - live, err := s.liveSolutionTargetForBinding(ctx, document.Binding) - if err != nil { - return err - } - if live != nil { - witnessedTargetClose = newPolicyLogOperationID("close-solution-target") - return s.WithPolicyLoggedNarrowing(ctx, closedSolutionTargetPolicyLogEntry( - witnessedTargetClose, live.ID, document.Binding, live.SolutionID, document.Generation, - ), apply) - } - } return s.store.WithControlPlane(ctx, apply) } -// liveSolutionTargetForBinding is the unlocked read that decides whether a -// withdrawal has a target to close, so the policy log append can happen before -// the apply transaction opens. -// -// It filters the whole target listing rather than taking a lock, because the -// locked read inside the transaction is the one that decides and a second lock -// here would only widen the window it holds. The listing is one row per -// solution instance this host has ever presented, which is the same set the -// operator surface already pages through. -func (s *Service) liveSolutionTargetForBinding(ctx context.Context, bindingID string) (*SolutionTarget, error) { - var live *SolutionTarget - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - targets, err := s.store.ListSolutionTargets(ctx) - if err != nil { - return err - } - for _, target := range targets { - if target.BindingID == bindingID && target.Live() { - live = target - return nil - } - } - return nil - }); err != nil { - return nil, err - } - return live, nil -} - // declareSolutionRegistration records the binding and immutable target that own // this alias. Only observations for that same declared target may survive. func (s *Service) declareSolutionRegistration( @@ -664,14 +609,9 @@ func solutionHostBindingResource(record *SolutionHostBindingRecord) string { // history and the audit event to the identity that ended. Reading it back after // the close would find nothing, which is how the tombstone's history row lost // its target. -// witnessedTargetClose is the policy log operation whose append landed for the -// close this call is about to perform, and "" when none did. A close with no -// witness is REFUSED: closing the target revokes every installation naming it, -// and performing that with nothing appended is the unwitnessed narrowing the -// protocol forbids outright. func (s *Service) reconcileSolutionTarget( ctx context.Context, document *solutionhost.ModuleManifest, - solutionID, witnessedTargetClose string, now time.Time, + solutionID string, now time.Time, ) (string, error) { live, err := s.store.GetLiveSolutionTargetForUpdate(ctx, document.Binding) if err != nil { @@ -681,12 +621,6 @@ func (s *Service) reconcileSolutionTarget( if live == nil { return "", nil } - if witnessedTargetClose == "" { - return "", fmt.Errorf( - "%w: closing solution target %s was not appended to the policy log, "+ - "so the installations it revokes would be unwitnessed", - ErrPolicyLogUnreachable, live.ID) - } if err := s.store.CloseSolutionTarget(ctx, live.ID, document.Generation, now); err != nil { return "", err } diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 8c011fbae..b34400ce2 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -571,24 +571,12 @@ func (s *Service) ListSourceDelegations(ctx context.Context, orgID, sourceID str // ErrSourceDelegationNotFound, whether or not it exists elsewhere. // // A NARROWING — the module binding stops holding the owner's authority over -// that source — so it runs under the policy log: appended and receipted before -// the row is marked, with the revocation and the receipt's commit in one -// transaction, and a refusal rather than an unwitnessed revocation when the log -// cannot be reached. -// -// That transaction is the policy log's control-plane one, because the receipt -// relation is control-plane only and the two writes must be atomic. The -// organisation's own scoping is not lost: both statements below carry orgID in -// their filter — the revoke through SourceDelegationFilter, the fallback read -// through GetSourceDelegation — so a delegation of another organisation is -// still ErrSourceDelegationNotFound rather than revocable by id. -// -// Revoking an already-revoked delegation appends an entry and commits nothing -// else, which is the correct trade and not an oversight: the alternative is -// reading the row before the append to find out, and a read then cannot bind -// what the transaction will find. An over-recorded entry replays to the same -// answer (see newPolicyLogOperationID); a narrowing that skipped the log -// because a pre-read said it would be a no-op would not. +// that source — so it applies directly (narrow): the revocation and its audit +// record commit in one control-plane transaction. The organisation's own +// scoping is not lost: both statements below carry orgID in their filter — the +// revoke through SourceDelegationFilter, the fallback read through +// GetSourceDelegation — so a delegation of another organisation is still +// ErrSourceDelegationNotFound rather than revocable by id. func (s *Service) RevokeSourceDelegation(ctx context.Context, actorID, orgID, id string) (*SourceDelegation, error) { orgID = strings.TrimSpace(orgID) id = strings.TrimSpace(id) @@ -596,27 +584,25 @@ func (s *Service) RevokeSourceDelegation(ctx context.Context, actorID, orgID, id return nil, errors.New("org id and delegation id are required") } var out *SourceDelegation - if err := s.WithPolicyLoggedNarrowing(ctx, - revokeSourceDelegationPolicyLogEntry(actorID, orgID, id), - func(ctx context.Context) error { - revoked, err := s.store.RevokeSourceDelegations(ctx, SourceDelegationFilter{OrgID: orgID, ID: id}, SourceDelegationRevokedByAdmin, actorID) - if err != nil { - return err - } - if len(revoked) == 1 { - out = revoked[0] - return s.emitSourceDelegationRevokedTx(ctx, actorID, out, SourceDelegationRevokedByAdmin) - } - existing, err := s.store.GetSourceDelegation(ctx, orgID, id) - if err != nil { - return err - } - if existing == nil { - return ErrSourceDelegationNotFound - } - out = existing - return nil - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + revoked, err := s.store.RevokeSourceDelegations(ctx, SourceDelegationFilter{OrgID: orgID, ID: id}, SourceDelegationRevokedByAdmin, actorID) + if err != nil { + return err + } + if len(revoked) == 1 { + out = revoked[0] + return s.emitSourceDelegationRevokedTx(ctx, actorID, out, SourceDelegationRevokedByAdmin) + } + existing, err := s.store.GetSourceDelegation(ctx, orgID, id) + if err != nil { + return err + } + if existing == nil { + return ErrSourceDelegationNotFound + } + out = existing + return nil + }); err != nil { return nil, err } return out, nil diff --git a/module/services/accounts/code/pkg/business/store.go b/module/services/accounts/code/pkg/business/store.go index f9d4ba551..58545494d 100644 --- a/module/services/accounts/code/pkg/business/store.go +++ b/module/services/accounts/code/pkg/business/store.go @@ -267,22 +267,6 @@ type Store interface { GetDatasourceDomain(ctx context.Context, orgID, id string) (*DatasourceDomain, error) MarkDatasourceDomainVerified(ctx context.Context, orgID, id string, at time.Time) error - // Module delegations (module_delegation.go, migration 51). Tenant-facing - // calls run in the organization's transaction; a module's lookup by id runs - // under the control plane with an empty orgID. - // - // InsertModuleDelegation records one active delegation and sets its - // creation time. - InsertModuleDelegation(ctx context.Context, delegation *ModuleDelegation) error - // ModuleDelegations reads the delegations among ids, in orgID, or by id - // alone when orgID is empty (control plane only). - ModuleDelegations(ctx context.Context, orgID string, ids []string) ([]*ModuleDelegation, error) - // RevokeModuleDelegation stamps delegation id in orgID as revoked by - // revokedBy, if it is active, and returns it as it then stands, and whether - // this call revoked it. grantor, when set, must be the delegation's grantor. - // Nil when orgID holds no such delegation. - RevokeModuleDelegation(ctx context.Context, orgID, grantor, id, revokedBy string) (*ModuleDelegation, bool, error) - // Source delegations (source_delegation.go, migration 9). The tenant-facing // calls run inside the organization's transaction; the module-facing mint // and revision check run under the control plane and name the organization diff --git a/module/services/accounts/code/pkg/business/subject_tenancy_test.go b/module/services/accounts/code/pkg/business/subject_tenancy_test.go index 2e6d71fc1..cc433b140 100644 --- a/module/services/accounts/code/pkg/business/subject_tenancy_test.go +++ b/module/services/accounts/code/pkg/business/subject_tenancy_test.go @@ -168,7 +168,6 @@ func TestDeleteOrganizationAcquiresMembershipLocksBeforeRevocations(t *testing.T lockErr := errors.New("membership lock unavailable") store := &bulkRemovalOrderStore{canonicalTenancyStore: canonicalTenancyStore{t: t, principalKind: PrincipalKindHuman}, lockErr: lockErr} service := &Service{store: store} - service.SetPolicyLog(newFakePolicyLog(), newFakePolicyLogStore()) err := service.DeleteOrganization(context.Background(), strings.ToUpper(tenancyTestSubject), &gen.DeleteOrganizationRequest{OrgId: strings.ToUpper(tenancyTestOrg), ConfirmSlug: "example"}) require.ErrorIs(t, err, lockErr, "bulk membership locks must precede every revocation's trigger locks") } @@ -177,7 +176,6 @@ func TestDeleteOrganizationAcquiresInstallationLocksBeforeTriggerWrites(t *testi lockErr := errors.New("installation lock unavailable") store := &bulkRemovalOrderStore{canonicalTenancyStore: canonicalTenancyStore{t: t, principalKind: PrincipalKindHuman}, installationErr: lockErr} service := &Service{store: store} - service.SetPolicyLog(newFakePolicyLog(), newFakePolicyLogStore()) err := service.DeleteOrganization(context.Background(), tenancyTestSubject, &gen.DeleteOrganizationRequest{OrgId: tenancyTestOrg, ConfirmSlug: "example"}) require.ErrorIs(t, err, lockErr, "the installation batch lock must precede membership deletion and all revocations") } diff --git a/module/services/accounts/code/pkg/business/teams.go b/module/services/accounts/code/pkg/business/teams.go index d4aedd9f5..ce3a9727d 100644 --- a/module/services/accounts/code/pkg/business/teams.go +++ b/module/services/accounts/code/pkg/business/teams.go @@ -80,17 +80,14 @@ func teamRoleToString(role gen.TeamRole) string { // RemoveTeamMember removes a member from a team. // // A NARROWING — a team is a subject roles are granted to, so losing a -// membership reduces what the removed user may do — and it therefore runs under -// the policy log: appended and receipted before the membership goes, with the -// delete and the receipt's commit in one transaction. -// -// That transaction is the policy log's control-plane one, because the receipt -// relation is control-plane only and the two writes must be atomic. The delete -// itself is keyed on (team, user) and carries no organisation column, so -// team_members' tenant policy was what confined it to this organisation; the -// re-read below is that predicate made explicit on the same transaction. It is -// not a convenience check: without it this delete would be addressable across -// tenants by a caller that named another organisation's team. +// membership reduces what the removed user may do — and it therefore applies +// directly (narrow): the delete and its audit event commit in one control-plane +// transaction. The delete itself is keyed on (team, user) and carries no +// organisation column, so team_members' tenant policy is what would confine it +// to this organisation; the re-read below is that predicate made explicit on +// the same transaction. It is not a convenience check: without it this delete +// would be addressable across tenants by a caller that named another +// organisation's team. func (s *Service) RemoveTeamMember(ctx context.Context, actorID string, req *gen.RemoveTeamMemberRequest) error { w := wool.Get(ctx).In("RemoveTeamMember") @@ -99,24 +96,23 @@ func (s *Service) RemoveTeamMember(ctx context.Context, actorID string, req *gen return w.Wrapf(err, "cannot resolve team org") } - if err := s.WithPolicyLoggedNarrowing(ctx, - removeTeamMemberPolicyLogEntry(actorID, orgID, req.TeamId, req.UserId), - func(ctx context.Context) error { - owning, err := s.store.GetTeamOrgID(ctx, req.TeamId) - if err != nil { - return err - } - if owning != orgID { - return NewStoreError( - fmt.Errorf("team %s belongs to organization %q, not %q", - req.TeamId, owning, orgID), - ErrTypeValidation) - } - if err := s.store.RemoveTeamMember(ctx, req.TeamId, req.UserId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventTeamMemberRemoved, "team", req.TeamId, orgID) - }); err != nil { + if err := s.narrow(ctx, func(ctx context.Context) error { + owning, err := s.store.GetTeamOrgID(ctx, req.TeamId) + if err != nil { + return err + } + if owning != orgID { + return NewStoreError( + fmt.Errorf("team %s belongs to organization %q, not %q", + req.TeamId, owning, orgID), + ErrTypeValidation) + } + if err := s.store.RemoveTeamMember(ctx, req.TeamId, req.UserId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventTeamMemberRemoved, "team", req.TeamId, orgID, + map[string]any{"user_id": req.UserId}) + }); err != nil { return w.Wrapf(err, "cannot remove team member") } return nil diff --git a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go index 47ce5d3c9..b3c9df70e 100644 --- a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go +++ b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go @@ -52,7 +52,7 @@ func TestAuthorizationCatalogCompilationAndPolicyProjection(t *testing.T) { // number would be silently wrong, so this is read off the regenerated // generated/authz-methods.json. require.Equal(t, 55, internalCount) // ApplyManifest now authenticates its signed carrier on the tenant listener - require.Equal(t, 24, failClosedCount) // +1: signed composition delivery; +2: the session interface's SignIn and ListFixturePeople are rate-limited as authentication and fail closed; +4: the authority interface's workload-credential calls (RegisterAdmissionKey, AttachResource, DetachResource, AuthorizeTransfer) + require.Equal(t, 25, failClosedCount) // +1: signed composition delivery; +2: the session interface's SignIn and ListFixturePeople are rate-limited as authentication and fail closed; +4: the authority interface's workload-credential calls (RegisterAdmissionKey, AttachResource, DetachResource, AuthorizeTransfer); +1: the installation interface's Current, authenticated by the module's workload credential require.Equal(t, 2, factorAttemptCount) require.Equal( t, @@ -184,5 +184,5 @@ func TestImpersonationRestrictionsMatchTheDescriptorsTheInterceptorReads(t *test } require.Equal(t, catalogued, enforced) - require.Len(t, enforced, 65) // 57 of the API package + Issue, Delegate, RevokeDelegation, GetDelegation, Install, GrantToTeam, List, Current of the implemented interfaces; Check and ListResources are public, authenticated by the token they check + require.Len(t, enforced, 68) // 57 of the API package + Issue, Delegate, RevokeDelegation, GetDelegation, Install, Review, GrantRole, RevokeRole, ListRoleGrants, List, Current of the implemented interfaces; Check and ListResources are public, authenticated by the token they check } diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go index b95225a88..f1b4d9dbe 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go @@ -71,15 +71,15 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.Equal(t, string(readFixture(t, "testdata/network-policy.golden.yaml")), string(first.NetworkPolicy), "run: go generate ./pkg/cataloggen") require.Equal(t, string(readFixture(t, "testdata/mesh-policy.golden.yaml")), string(first.MeshPolicy), "run: go generate ./pkg/cataloggen") - require.Len(t, first.Catalog.GetServices(), 8) + require.Len(t, first.Catalog.GetServices(), 7) require.Len(t, first.Catalog.GetInterfaceEndpoints(), 7) - require.Len(t, first.Catalog.GetPublicEgress(), 4) + require.Len(t, first.Catalog.GetPublicEgress(), 3) endpointCount, dependencyCount := 0, 0 for _, service := range first.Catalog.GetServices() { endpointCount += len(service.GetEndpoints()) dependencyCount += len(service.GetDependencies()) } - require.Equal(t, 14, endpointCount) + require.Equal(t, 13, endpointCount) require.Equal(t, 6, dependencyCount) // Accounts REST stays private. Gateway REST is exported for module mints; // solutions resolve its separately exported workload listener. @@ -113,7 +113,7 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { for _, grant := range first.Catalog.GetPublicEgress() { egress[grant.GetService()] = true } - require.Equal(t, map[string]bool{"accounts": true, "frontend": true, "marketing": true, "policy-log": true}, egress) + require.Equal(t, map[string]bool{"accounts": true, "frontend": true, "marketing": true}, egress) require.False(t, egress["auth-gateway"], "auth-gateway must have no public egress") accountsConnectExposed, accountsAuthorityExposed, gatewayRESTExposed := false, false, false for _, endpoint := range first.Catalog.GetInterfaceEndpoints() { @@ -134,7 +134,7 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.True(t, accountsConnectExposed) require.True(t, accountsAuthorityExposed) require.True(t, gatewayRESTExposed) - require.Equal(t, 19, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) + require.Equal(t, 18, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) require.NotContains(t, string(first.NetworkPolicy), "allow-intra-namespace") for _, name := range []string{ "allow-accounts-from-dependents", "allow-auth-gateway-from-dependents", "allow-auth-gateway-to-dependencies", @@ -225,7 +225,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, loadedModule.ValidateInterface(ctx)) loadedServices, err := loadedModule.LoadServices(ctx) require.NoError(t, err) - require.Len(t, loadedServices, 8) + require.Len(t, loadedServices, 7) moduleDocument := readFixture(t, "../../../../../module.codefly.yaml") var moduleEntry struct { @@ -237,7 +237,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, yaml.Unmarshal(moduleDocument, &module)) _, err = module.Proto(ctx) require.NoError(t, err) - require.Len(t, module.ServiceReferences, 8) + require.Len(t, module.ServiceReferences, 7) for _, reference := range module.ServiceReferences { document := readFixture(t, filepath.Join("../../../../../services", reference.Name, "service.codefly.yaml")) @@ -279,7 +279,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.False(t, names[document.Metadata.Name], "duplicate NetworkPolicy %s", document.Metadata.Name) names[document.Metadata.Name] = true } - require.Len(t, names, 19) + require.Len(t, names, 18) require.True(t, names["allow-istio-ingress-to-marketing"]) require.True(t, names["allow-istio-ingress-to-frontend"]) require.False(t, names["allow-istio-ingress-to-auth-gateway"]) diff --git a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go index e3111c0a8..beaa865ef 100644 --- a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go +++ b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go @@ -33,7 +33,7 @@ func TestGatewayRouteCatalogCompilationAndParity(t *testing.T) { } require.NotEqual(t, policyv1.Exposure_EXPOSURE_INTERNAL, route.GetExposure()) } - require.Equal(t, 59, publicCount) // +5: the authority interface's Keys, Check and ListResources (authenticated by the token they check) and the session interface's SignIn and ListFixturePeople (before anyone signs in); ApplyManifest is public on accounts' own listener but never routed (ServedInClusterOnly) + require.Equal(t, 60, publicCount) // +5: the authority interface's Keys, Check and ListResources (authenticated by the token they check) and the session interface's SignIn and ListFixturePeople (before anyone signs in); +1: the installation interface's Current (authenticated by the module's workload credential and its parent); ApplyManifest is public on accounts' own listener but never routed (ServedInClusterOnly) require.Nil(t, byMatch["POST /saas.accounts.v1.APIKeyService/ValidateAPIKey"]) require.Equal(t, policyv1.Exposure_EXPOSURE_PUBLIC, byMatch["POST /saas.accounts.v1.AuthService/BeginOAuth"].GetExposure()) diff --git a/module/services/accounts/code/pkg/cataloggen/implemented_interfaces_test.go b/module/services/accounts/code/pkg/cataloggen/implemented_interfaces_test.go index fae8c5b93..0384bae53 100644 --- a/module/services/accounts/code/pkg/cataloggen/implemented_interfaces_test.go +++ b/module/services/accounts/code/pkg/cataloggen/implemented_interfaces_test.go @@ -80,7 +80,10 @@ func interfaceMethods(t *testing.T, pkg string) []string { protoregistry.GlobalFiles.RangeFilesByPackage(protoreflect.FullName(pkg), func(file protoreflect.FileDescriptor) bool { for i := 0; i < file.Services().Len(); i++ { service := file.Services().Get(i) - if service.FullName() == "host.installation.v1.InstallableService" { + // InstallableService is the solution's side of the installation + // interface, and OfferService the local authority's setup read beside + // it: codefly.dev/installation declares InstallationService alone. + if service.FullName() == "host.installation.v1.InstallableService" || service.FullName() == "host.installation.v1.OfferService" { continue } for j := 0; j < service.Methods().Len(); j++ { diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml index af981918c..1e7e3d0bd 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml @@ -490,55 +490,3 @@ spec: ports: - protocol: TCP port: 443 ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: allow-policy-log-public-egress - namespace: saas-starter -spec: - podSelector: - matchLabels: - app: policy-log - policyTypes: - - Egress - egress: - - to: - - ipBlock: - cidr: 0.0.0.0/0 - except: - - 0.0.0.0/8 - - 10.0.0.0/8 - - 100.64.0.0/10 - - 127.0.0.0/8 - - 169.254.0.0/16 - - 172.16.0.0/12 - - 192.0.0.0/24 - - 192.0.2.0/24 - - 192.31.196.0/24 - - 192.52.193.0/24 - - 192.88.99.0/24 - - 192.168.0.0/16 - - 192.175.48.0/24 - - 198.18.0.0/15 - - 198.51.100.0/24 - - 203.0.113.0/24 - - 224.0.0.0/4 - - 240.0.0.0/4 - - ipBlock: - cidr: ::/0 - except: - - ::/128 - - ::1/128 - - 64:ff9b::/96 - - 64:ff9b:1::/48 - - 100::/64 - - 2001::/23 - - 2001:db8::/32 - - 2002::/16 - - fc00::/7 - - fe80::/10 - - ff00::/8 - ports: - - protocol: TCP - port: 443 diff --git a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go index 14b82b378..df6c60ae0 100644 --- a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go +++ b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go @@ -45,6 +45,14 @@ var published = [...]PublishedEvent{ {Type: "saas.auth.mfa_challenge_started", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.auth.organization_switched", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.auth.sso_jit_provisioned", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.installation_activated", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.installation_requested", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.installation_uninstalled", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.resource_attached", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.resource_detached", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.resource_transferred", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.role_granted", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.authority.role_revoked", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.billing.checkout_started", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.billing.free_plan_selected", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.billing.portal_opened", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, diff --git a/module/services/accounts/code/pkg/gen/host/authority/v1/authority.pb.go b/module/services/accounts/code/pkg/gen/host/authority/v1/authority.pb.go index 1ad626650..5d2f5c2bf 100644 --- a/module/services/accounts/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/module/services/accounts/code/pkg/gen/host/authority/v1/authority.pb.go @@ -1130,7 +1130,18 @@ type RegisterAdmissionKeyRequest struct { // The hop's workload credential; any other workload is refused. Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` // The raw Ed25519 public key the hop signs admissions with: 32 bytes. - PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + PublicKey []byte `protobuf:"bytes,2,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. + Proof string `protobuf:"bytes,3,opt,name=proof,proto3" json:"proof,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1179,6 +1190,13 @@ func (x *RegisterAdmissionKeyRequest) GetPublicKey() []byte { return nil } +func (x *RegisterAdmissionKeyRequest) GetProof() string { + if x != nil { + return x.Proof + } + return "" +} + // RegisterAdmissionKeyResponse is the key as Keys now publishes it. type RegisterAdmissionKeyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1948,11 +1966,12 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n" + "\n" + "public_key\x18\x03 \x01(\fR\tpublicKey\x12\x18\n" + - "\apurpose\x18\x04 \x01(\tR\apurpose\"y\n" + + "\apurpose\x18\x04 \x01(\tR\apurpose\"\x8f\x01\n" + "\x1bRegisterAdmissionKeyRequest\x12;\n" + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12\x1d\n" + "\n" + - "public_key\x18\x02 \x01(\fR\tpublicKey\"v\n" + + "public_key\x18\x02 \x01(\fR\tpublicKey\x12\x14\n" + + "\x05proof\x18\x03 \x01(\tR\x05proof\"v\n" + "\x1cRegisterAdmissionKeyResponse\x12.\n" + "\x03key\x18\x01 \x01(\v2\x1c.host.authority.v1.PublicKeyR\x03key\x12&\n" + "\x0fexpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\"\x85\x01\n" + diff --git a/module/services/accounts/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/module/services/accounts/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index 02afed32e..692f79c0b 100644 --- a/module/services/accounts/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -60,14 +60,36 @@ type AuthorityServiceClient interface { Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. RegisterAdmissionKey(ctx context.Context, in *RegisterAdmissionKeyRequest, opts ...grpc.CallOption) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -274,14 +296,36 @@ type AuthorityServiceServer interface { Keys(context.Context, *KeysRequest) (*KeysResponse, error) // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. RegisterAdmissionKey(context.Context, *RegisterAdmissionKeyRequest) (*RegisterAdmissionKeyResponse, error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call diff --git a/module/services/accounts/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/module/services/accounts/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index 63bf33761..fee291e30 100644 --- a/module/services/accounts/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/module/services/accounts/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -88,14 +88,36 @@ type AuthorityServiceClient interface { Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -330,14 +352,36 @@ type AuthorityServiceHandler interface { Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. RegisterAdmissionKey(context.Context, *connect.Request[v1.RegisterAdmissionKeyRequest]) (*connect.Response[v1.RegisterAdmissionKeyResponse], error) // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call diff --git a/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go b/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go index 54cce65a0..c40db481c 100644 --- a/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go @@ -136,7 +136,12 @@ type AuthorityManifestV1 struct { // one of them installer. Headless-only scopes cannot supply a role grant. // Core's manifest carries no roles, so they travel here until it does. A // module declares none; a module's delivery listing any is refused. - Roles []*RoleV1 `protobuf:"bytes,6,rep,name=roles,proto3" json:"roles,omitempty"` + Roles []*RoleV1 `protobuf:"bytes,6,rep,name=roles,proto3" json:"roles,omitempty"` + // The module checks a resource's owner against an owner-selected scope + // (Claims.AllowsOwned): only an audience declaring it receives one, and a + // delivery withdrawing it refuses the owner-selected capabilities issued + // before. Every solution receiving its roles' scopes declares it. + OwnerAware bool `protobuf:"varint,7,opt,name=owner_aware,json=ownerAware,proto3" json:"owner_aware,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -213,6 +218,13 @@ func (x *AuthorityManifestV1) GetRoles() []*RoleV1 { return nil } +func (x *AuthorityManifestV1) GetOwnerAware() bool { + if x != nil { + return x.OwnerAware + } + return false +} + // MethodPolicyV1 is what a call to one method must hold, as the module's // contract declares it with the authority.hop.v1.policy option. type MethodPolicyV1 struct { @@ -417,7 +429,7 @@ type ModuleManifestV1 struct { Page *PageV1 `protobuf:"bytes,7,opt,name=page,proto3" json:"page,omitempty"` // The roles a solution declares a person may hold on an installation of it, // each within what the solution serves or explicitly consumes through - // invoke/lookup bindings; one is named installer, held by whoever installs it. + // invoke/lookup bindings; one is named installer, held only by whom it is granted to. // Headless-only scopes cannot supply a role grant. A module declares none. Roles []*RoleV1 `protobuf:"bytes,8,rep,name=roles,proto3" json:"roles,omitempty"` unknownFields protoimpl.UnknownFields @@ -512,11 +524,20 @@ func (x *ModuleManifestV1) GetRoles() []*RoleV1 { // RoleV1 is one role a solution declares. type RoleV1 struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Scopes the role grants only on the resources the installation owns: those + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + InstallationOwnedScopes []*v1.WorkScopeV1 `protobuf:"bytes,3,rep,name=installation_owned_scopes,json=installationOwnedScopes,proto3" json:"installation_owned_scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoleV1) Reset() { @@ -563,6 +584,13 @@ func (x *RoleV1) GetScopes() []*v1.WorkScopeV1 { return nil } +func (x *RoleV1) GetInstallationOwnedScopes() []*v1.WorkScopeV1 { + if x != nil { + return x.InstallationOwnedScopes + } + return nil +} + // BindingV1 is one declared call towards another module. type BindingV1 struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -769,7 +797,7 @@ const file_host_composition_v1_composition_proto_rawDesc = "" + "\n" + "credential\x18\x03 \x01(\tR\n" + "credential\x12C\n" + - "\tauthority\x18\x04 \x01(\v2%.host.composition.v1.SignedManifestV1R\tauthority\"\xb8\x02\n" + + "\tauthority\x18\x04 \x01(\v2%.host.composition.v1.SignedManifestV1R\tauthority\"\xd9\x02\n" + "\x13AuthorityManifestV1\x12\x16\n" + "\x06module\x18\x01 \x01(\tR\x06module\x12\x1e\n" + "\n" + @@ -778,7 +806,9 @@ const file_host_composition_v1_composition_proto_rawDesc = "" + "\x14core_manifest_digest\x18\x03 \x01(\tR\x12coreManifestDigest\x12=\n" + "\amethods\x18\x04 \x03(\v2#.host.composition.v1.MethodPolicyV1R\amethods\x12E\n" + "\x0escope_ceilings\x18\x05 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\rscopeCeilings\x121\n" + - "\x05roles\x18\x06 \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"\x92\x01\n" + + "\x05roles\x18\x06 \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\x12\x1f\n" + + "\vowner_aware\x18\a \x01(\bR\n" + + "ownerAware\"\x92\x01\n" + "\x0eMethodPolicyV1\x12\x16\n" + "\x06method\x18\x01 \x01(\tR\x06method\x12:\n" + "\brequires\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\brequires\x12,\n" + @@ -802,10 +832,11 @@ const file_host_composition_v1_composition_proto_rawDesc = "" + "\bbindings\x18\x05 \x03(\v2\x1e.host.composition.v1.BindingV1R\bbindings\x12J\n" + "\x0eresource_kinds\x18\x06 \x03(\v2#.host.composition.v1.ResourceKindV1R\rresourceKinds\x12/\n" + "\x04page\x18\a \x01(\v2\x1b.host.composition.v1.PageV1R\x04page\x121\n" + - "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"T\n" + + "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"\xb0\x01\n" + "\x06RoleV1\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + - "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\"\x8a\x02\n" + + "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12Z\n" + + "\x19installation_owned_scopes\x18\x03 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x17installationOwnedScopes\"\x8a\x02\n" + "\tBindingV1\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12C\n" + @@ -861,16 +892,17 @@ var file_host_composition_v1_composition_proto_depIdxs = []int32{ 9, // 9: host.composition.v1.ModuleManifestV1.page:type_name -> host.composition.v1.PageV1 6, // 10: host.composition.v1.ModuleManifestV1.roles:type_name -> host.composition.v1.RoleV1 10, // 11: host.composition.v1.RoleV1.scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 12: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 13: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 14: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 - 0, // 15: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest - 4, // 16: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse - 16, // [16:17] is the sub-list for method output_type - 15, // [15:16] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 10, // 12: host.composition.v1.RoleV1.installation_owned_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 13: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 14: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 15: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 + 0, // 16: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest + 4, // 17: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse + 17, // [17:18] is the sub-list for method output_type + 16, // [16:17] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_host_composition_v1_composition_proto_init() } diff --git a/module/services/accounts/code/pkg/gen/host/installation/v1/installation.pb.go b/module/services/accounts/code/pkg/gen/host/installation/v1/installation.pb.go index a06e88d61..9bc9cbdd4 100644 --- a/module/services/accounts/code/pkg/gen/host/installation/v1/installation.pb.go +++ b/module/services/accounts/code/pkg/gen/host/installation/v1/installation.pb.go @@ -5,8 +5,10 @@ // source: host/installation/v1/installation.proto // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package installationv1 @@ -80,7 +82,136 @@ func (x Installation_Status) Number() protoreflect.EnumNumber { // Deprecated: Use Installation_Status.Descriptor instead. func (Installation_Status) EnumDescriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{5, 0} + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{12, 0} +} + +type CurrentInstallationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ParentToken string `protobuf:"bytes,1,opt,name=parent_token,json=parentToken,proto3" json:"parent_token,omitempty"` + ActorCredential string `protobuf:"bytes,2,opt,name=actor_credential,json=actorCredential,proto3" json:"actor_credential,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CurrentInstallationRequest) Reset() { + *x = CurrentInstallationRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CurrentInstallationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CurrentInstallationRequest) ProtoMessage() {} + +func (x *CurrentInstallationRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CurrentInstallationRequest.ProtoReflect.Descriptor instead. +func (*CurrentInstallationRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{0} +} + +func (x *CurrentInstallationRequest) GetParentToken() string { + if x != nil { + return x.ParentToken + } + return "" +} + +func (x *CurrentInstallationRequest) GetActorCredential() string { + if x != nil { + return x.ActorCredential + } + return "" +} + +// CurrentInstallation is request-local metadata, never a lease or consent. +type CurrentInstallation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + OrganizationId string `protobuf:"bytes,2,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` + Revision uint64 `protobuf:"varint,4,opt,name=revision,proto3" json:"revision,omitempty"` + Namespace string `protobuf:"bytes,5,opt,name=namespace,proto3" json:"namespace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CurrentInstallation) Reset() { + *x = CurrentInstallation{} + mi := &file_host_installation_v1_installation_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CurrentInstallation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CurrentInstallation) ProtoMessage() {} + +func (x *CurrentInstallation) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CurrentInstallation.ProtoReflect.Descriptor instead. +func (*CurrentInstallation) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{1} +} + +func (x *CurrentInstallation) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *CurrentInstallation) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *CurrentInstallation) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *CurrentInstallation) GetRevision() uint64 { + if x != nil { + return x.Revision + } + return 0 +} + +func (x *CurrentInstallation) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" } // InstallRequest asks to install one target at one organisation. @@ -94,7 +225,7 @@ type InstallRequest struct { Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` // The accountable person; an administrator of the organisation. OwnerPersonId string `protobuf:"bytes,4,opt,name=owner_person_id,json=ownerPersonId,proto3" json:"owner_person_id,omitempty"` - // The exact catalogue offer and installation state the administrator approved. + // Required: the exact offer reviewed by the administrator; unchanged on retry. Approval *ConsentApproval `protobuf:"bytes,5,opt,name=approval,proto3" json:"approval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -102,7 +233,7 @@ type InstallRequest struct { func (x *InstallRequest) Reset() { *x = InstallRequest{} - mi := &file_host_installation_v1_installation_proto_msgTypes[0] + mi := &file_host_installation_v1_installation_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -114,7 +245,7 @@ func (x *InstallRequest) String() string { func (*InstallRequest) ProtoMessage() {} func (x *InstallRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[0] + mi := &file_host_installation_v1_installation_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -127,7 +258,7 @@ func (x *InstallRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallRequest.ProtoReflect.Descriptor instead. func (*InstallRequest) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{0} + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{2} } func (x *InstallRequest) GetOrganizationId() string { @@ -165,35 +296,886 @@ func (x *InstallRequest) GetApproval() *ConsentApproval { return nil } -// ConsentApproval binds an explicit approval to the state reviewed by the admin. -// Obtain target_id and generation from ListAvailableSolutions, and revision from -// the installation listing (zero means absent). Keep these values and operation_id -// unchanged on retry; a refusal requires a fresh review and operation. +// ReviewRequest names the offer an administrator intends to review. +type ReviewRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReviewRequest) Reset() { + *x = ReviewRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReviewRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReviewRequest) ProtoMessage() {} + +func (x *ReviewRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReviewRequest.ProtoReflect.Descriptor instead. +func (*ReviewRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{3} +} + +func (x *ReviewRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *ReviewRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +// ConsentApproval binds consent to an immutable target, its reviewed delivery and +// the installation revision (zero if absent). Review leaves operation_id empty; +// the caller supplies a fresh UUID and preserves the entire request on retry. type ConsentApproval struct { state protoimpl.MessageState `protogen:"open.v1"` TargetId string `protobuf:"bytes,1,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` Generation uint64 `protobuf:"varint,2,opt,name=generation,proto3" json:"generation,omitempty"` ExpectedRevision uint64 `protobuf:"varint,3,opt,name=expected_revision,json=expectedRevision,proto3" json:"expected_revision,omitempty"` OperationId string `protobuf:"bytes,4,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sha256 digest of the reviewed authority offer. + Digest string `protobuf:"bytes,5,opt,name=digest,proto3" json:"digest,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConsentApproval) Reset() { + *x = ConsentApproval{} + mi := &file_host_installation_v1_installation_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConsentApproval) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConsentApproval) ProtoMessage() {} + +func (x *ConsentApproval) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConsentApproval.ProtoReflect.Descriptor instead. +func (*ConsentApproval) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{4} +} + +func (x *ConsentApproval) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *ConsentApproval) GetGeneration() uint64 { + if x != nil { + return x.Generation + } + return 0 +} + +func (x *ConsentApproval) GetExpectedRevision() uint64 { + if x != nil { + return x.ExpectedRevision + } + return 0 +} + +func (x *ConsentApproval) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *ConsentApproval) GetDigest() string { + if x != nil { + return x.Digest + } + return "" +} + +// GrantRoleRequest grants one role to exactly one person or team. +type GrantRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The organization the installation belongs to; a request naming another + // organization's installation is refused naming the organization, before + // the caller's authority is read. + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The installation. + InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + // One of the roles the solution declares. + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` + // The person granted, a member of the organization; exactly one of + // person_id and team_id is set. + PersonId string `protobuf:"bytes,4,opt,name=person_id,json=personId,proto3" json:"person_id,omitempty"` + // The team granted, a team of the organization. + TeamId string `protobuf:"bytes,5,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantRoleRequest) Reset() { + *x = GrantRoleRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantRoleRequest) ProtoMessage() {} + +func (x *GrantRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GrantRoleRequest.ProtoReflect.Descriptor instead. +func (*GrantRoleRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{5} +} + +func (x *GrantRoleRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *GrantRoleRequest) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *GrantRoleRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *GrantRoleRequest) GetPersonId() string { + if x != nil { + return x.PersonId + } + return "" +} + +func (x *GrantRoleRequest) GetTeamId() string { + if x != nil { + return x.TeamId + } + return "" +} + +// RevokeRoleRequest ends one grant, named as GrantRole named it. +type RevokeRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The organization the installation belongs to. + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The installation. + InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + // The role. + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` + // The person; exactly one of person_id and team_id is set. + PersonId string `protobuf:"bytes,4,opt,name=person_id,json=personId,proto3" json:"person_id,omitempty"` + // The team. + TeamId string `protobuf:"bytes,5,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeRoleRequest) Reset() { + *x = RevokeRoleRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeRoleRequest) ProtoMessage() {} + +func (x *RevokeRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeRoleRequest.ProtoReflect.Descriptor instead. +func (*RevokeRoleRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{6} +} + +func (x *RevokeRoleRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *RevokeRoleRequest) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *RevokeRoleRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *RevokeRoleRequest) GetPersonId() string { + if x != nil { + return x.PersonId + } + return "" +} + +func (x *RevokeRoleRequest) GetTeamId() string { + if x != nil { + return x.TeamId + } + return "" +} + +// ListRoleGrantsRequest names one installation. +type ListRoleGrantsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The organization the installation belongs to. + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The installation. + InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRoleGrantsRequest) Reset() { + *x = ListRoleGrantsRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRoleGrantsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRoleGrantsRequest) ProtoMessage() {} + +func (x *ListRoleGrantsRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRoleGrantsRequest.ProtoReflect.Descriptor instead. +func (*ListRoleGrantsRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{7} +} + +func (x *ListRoleGrantsRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *ListRoleGrantsRequest) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +// ListRoleGrantsResponse is every grant on the installation. +type ListRoleGrantsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The grants. + Grants []*RoleGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRoleGrantsResponse) Reset() { + *x = ListRoleGrantsResponse{} + mi := &file_host_installation_v1_installation_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRoleGrantsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRoleGrantsResponse) ProtoMessage() {} + +func (x *ListRoleGrantsResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRoleGrantsResponse.ProtoReflect.Descriptor instead. +func (*ListRoleGrantsResponse) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{8} +} + +func (x *ListRoleGrantsResponse) GetGrants() []*RoleGrant { + if x != nil { + return x.Grants + } + return nil +} + +// RoleGrant is one role held by one person or team. +type RoleGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The role. + Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` + // The person, when a person holds it. + PersonId string `protobuf:"bytes,2,opt,name=person_id,json=personId,proto3" json:"person_id,omitempty"` + // The team, when a team holds it. + TeamId string `protobuf:"bytes,3,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoleGrant) Reset() { + *x = RoleGrant{} + mi := &file_host_installation_v1_installation_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoleGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoleGrant) ProtoMessage() {} + +func (x *RoleGrant) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoleGrant.ProtoReflect.Descriptor instead. +func (*RoleGrant) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{9} +} + +func (x *RoleGrant) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *RoleGrant) GetPersonId() string { + if x != nil { + return x.PersonId + } + return "" +} + +func (x *RoleGrant) GetTeamId() string { + if x != nil { + return x.TeamId + } + return "" +} + +// ListRequest asks for one organisation's installations. +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The organisation. + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{10} +} + +func (x *ListRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +// ListResponse lists installations. +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The installations. + Installations []*Installation `protobuf:"bytes,1,rep,name=installations,proto3" json:"installations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_host_installation_v1_installation_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{11} +} + +func (x *ListResponse) GetInstallations() []*Installation { + if x != nil { + return x.Installations + } + return nil +} + +// Installation is one organisation's installation of one target. +type Installation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The installation id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // The organisation. + OrganizationId string `protobuf:"bytes,2,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The target installed. + Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` + // Where the installation stands. + Status Installation_Status `protobuf:"varint,4,opt,name=status,proto3,enum=host.installation.v1.Installation_Status" json:"status,omitempty"` + // Bumped on every change that narrows or widens the installation. + Revision uint64 `protobuf:"varint,5,opt,name=revision,proto3" json:"revision,omitempty"` + // The role the installation holds when it acts for itself (its agent's + // role); no person or team inherits it. + Role string `protobuf:"bytes,6,opt,name=role,proto3" json:"role,omitempty"` + // The namespace the installation's own authored content belongs to (an + // agent's source): the host names it, so a module never chooses which source + // an installation may run. A module installed by composition is + // ".". + Namespace string `protobuf:"bytes,9,opt,name=namespace,proto3" json:"namespace,omitempty"` + // The solution principal: the identity the installation's data is owned by + // and the installation acts as for itself, a stable UUID per organization + // and target, reused by a reinstall. Empty for a module installation. + PrincipalId string `protobuf:"bytes,10,opt,name=principal_id,json=principalId,proto3" json:"principal_id,omitempty"` + // The roles the solution declares, sorted. + Roles []string `protobuf:"bytes,11,rep,name=roles,proto3" json:"roles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Installation) Reset() { + *x = Installation{} + mi := &file_host_installation_v1_installation_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Installation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Installation) ProtoMessage() {} + +func (x *Installation) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Installation.ProtoReflect.Descriptor instead. +func (*Installation) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{12} +} + +func (x *Installation) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Installation) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *Installation) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *Installation) GetStatus() Installation_Status { + if x != nil { + return x.Status + } + return Installation_STATUS_UNSPECIFIED +} + +func (x *Installation) GetRevision() uint64 { + if x != nil { + return x.Revision + } + return 0 +} + +func (x *Installation) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *Installation) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *Installation) GetPrincipalId() string { + if x != nil { + return x.PrincipalId + } + return "" +} + +func (x *Installation) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +// InstallationNotice is what the host hands the solution's install step. +type InstallationNotice struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The installation being set up. + Installation *Installation `protobuf:"bytes,1,opt,name=installation,proto3" json:"installation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallationNotice) Reset() { + *x = InstallationNotice{} + mi := &file_host_installation_v1_installation_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallationNotice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallationNotice) ProtoMessage() {} + +func (x *InstallationNotice) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallationNotice.ProtoReflect.Descriptor instead. +func (*InstallationNotice) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{13} +} + +func (x *InstallationNotice) GetInstallation() *Installation { + if x != nil { + return x.Installation + } + return nil +} + +// InstallationAck acknowledges the install step. +type InstallationAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallationAck) Reset() { + *x = InstallationAck{} + mi := &file_host_installation_v1_installation_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallationAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallationAck) ProtoMessage() {} + +func (x *InstallationAck) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallationAck.ProtoReflect.Descriptor instead. +func (*InstallationAck) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{14} +} + +// ListOffersRequest names the organization whose administrator reads. +type ListOffersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOffersRequest) Reset() { + *x = ListOffersRequest{} + mi := &file_host_installation_v1_installation_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOffersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOffersRequest) ProtoMessage() {} + +func (x *ListOffersRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOffersRequest.ProtoReflect.Descriptor instead. +func (*ListOffersRequest) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{15} +} + +func (x *ListOffersRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +// ListOffersResponse is every delivered solution, by name. +type ListOffersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Offers []*Offer `protobuf:"bytes,1,rep,name=offers,proto3" json:"offers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOffersResponse) Reset() { + *x = ListOffersResponse{} + mi := &file_host_installation_v1_installation_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOffersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOffersResponse) ProtoMessage() {} + +func (x *ListOffersResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOffersResponse.ProtoReflect.Descriptor instead. +func (*ListOffersResponse) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{16} +} + +func (x *ListOffersResponse) GetOffers() []*Offer { + if x != nil { + return x.Offers + } + return nil } -func (x *ConsentApproval) Reset() { - *x = ConsentApproval{} - mi := &file_host_installation_v1_installation_proto_msgTypes[1] +// Offer is one delivered solution. +type Offer struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The solution's name in the composition: Install's target. + Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` + // The delivery generation an approval binds. + Generation uint64 `protobuf:"varint,2,opt,name=generation,proto3" json:"generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Offer) Reset() { + *x = Offer{} + mi := &file_host_installation_v1_installation_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ConsentApproval) String() string { +func (x *Offer) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ConsentApproval) ProtoMessage() {} +func (*Offer) ProtoMessage() {} -func (x *ConsentApproval) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[1] +func (x *Offer) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -204,69 +1186,62 @@ func (x *ConsentApproval) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ConsentApproval.ProtoReflect.Descriptor instead. -func (*ConsentApproval) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{1} +// Deprecated: Use Offer.ProtoReflect.Descriptor instead. +func (*Offer) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{17} } -func (x *ConsentApproval) GetTargetId() string { +func (x *Offer) GetTarget() string { if x != nil { - return x.TargetId + return x.Target } return "" } -func (x *ConsentApproval) GetGeneration() uint64 { +func (x *Offer) GetGeneration() uint64 { if x != nil { return x.Generation } return 0 } -func (x *ConsentApproval) GetExpectedRevision() uint64 { - if x != nil { - return x.ExpectedRevision - } - return 0 -} - -func (x *ConsentApproval) GetOperationId() string { - if x != nil { - return x.OperationId - } - return "" -} - -// GrantToTeamRequest exposes an installation to a team. -type GrantToTeamRequest struct { +// ReviewedOffer is one solution's authority offer and the consent that names it. +type ReviewedOffer struct { state protoimpl.MessageState `protogen:"open.v1"` - // The installation. - InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` - // The team granted the installation's role at its root. - TeamId string `protobuf:"bytes,2,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` - // The organisation the installation belongs to; a grant is an organisation - // administrator's act, and the host checks the actor against it before it - // reads the installation. Learned from the second implementation (the host). - OrganizationId string `protobuf:"bytes,3,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // What Install takes, with a fresh operation_id; read with the rest. + Approval *ConsentApproval `protobuf:"bytes,1,opt,name=approval,proto3" json:"approval,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + // Each role the solution declares, the installer role included. Every role + // scope is confined to this installation: to what it owns, or to the + // resources attached to it. + Roles []*OfferRole `protobuf:"bytes,3,rep,name=roles,proto3" json:"roles,omitempty"` + // The solution's own installation at the organization; absent before Install. + Installation *Installation `protobuf:"bytes,4,opt,name=installation,proto3" json:"installation,omitempty"` + // The permission ceiling: each module the solution calls, and the most it + // may ask there. No role reaches beyond it. + Ceiling []*OfferBinding `protobuf:"bytes,5,rep,name=ceiling,proto3" json:"ceiling,omitempty"` + // False when this run delivered no prepared Runnable bindings: no tool is + // shown, which says nothing about what a composed binding would expose. + ToolsComposed bool `protobuf:"varint,6,opt,name=tools_composed,json=toolsComposed,proto3" json:"tools_composed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *GrantToTeamRequest) Reset() { - *x = GrantToTeamRequest{} - mi := &file_host_installation_v1_installation_proto_msgTypes[2] +func (x *ReviewedOffer) Reset() { + *x = ReviewedOffer{} + mi := &file_host_installation_v1_installation_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GrantToTeamRequest) String() string { +func (x *ReviewedOffer) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GrantToTeamRequest) ProtoMessage() {} +func (*ReviewedOffer) ProtoMessage() {} -func (x *GrantToTeamRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[2] +func (x *ReviewedOffer) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -277,102 +1252,79 @@ func (x *GrantToTeamRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GrantToTeamRequest.ProtoReflect.Descriptor instead. -func (*GrantToTeamRequest) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{2} +// Deprecated: Use ReviewedOffer.ProtoReflect.Descriptor instead. +func (*ReviewedOffer) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{18} } -func (x *GrantToTeamRequest) GetInstallationId() string { +func (x *ReviewedOffer) GetApproval() *ConsentApproval { if x != nil { - return x.InstallationId + return x.Approval } - return "" + return nil } -func (x *GrantToTeamRequest) GetTeamId() string { +func (x *ReviewedOffer) GetTarget() string { if x != nil { - return x.TeamId + return x.Target } return "" } -func (x *GrantToTeamRequest) GetOrganizationId() string { +func (x *ReviewedOffer) GetRoles() []*OfferRole { if x != nil { - return x.OrganizationId + return x.Roles } - return "" -} - -// ListRequest asks for one organisation's installations. -type ListRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The organisation. - OrganizationId string `protobuf:"bytes,1,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListRequest) Reset() { - *x = ListRequest{} - mi := &file_host_installation_v1_installation_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListRequest) String() string { - return protoimpl.X.MessageStringOf(x) + return nil } -func (*ListRequest) ProtoMessage() {} - -func (x *ListRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[3] +func (x *ReviewedOffer) GetInstallation() *Installation { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Installation } - return mi.MessageOf(x) + return nil } -// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. -func (*ListRequest) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{3} +func (x *ReviewedOffer) GetCeiling() []*OfferBinding { + if x != nil { + return x.Ceiling + } + return nil } -func (x *ListRequest) GetOrganizationId() string { +func (x *ReviewedOffer) GetToolsComposed() bool { if x != nil { - return x.OrganizationId + return x.ToolsComposed } - return "" + return false } -// ListResponse lists installations. -type ListResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The installations. - Installations []*Installation `protobuf:"bytes,1,rep,name=installations,proto3" json:"installations,omitempty"` +// OfferBinding is one declared call from the solution to a module. +type OfferBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + Binding string `protobuf:"bytes,1,opt,name=binding,proto3" json:"binding,omitempty"` + // The module called. + Module string `protobuf:"bytes,2,opt,name=module,proto3" json:"module,omitempty"` + Scopes []*OfferScope `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListResponse) Reset() { - *x = ListResponse{} - mi := &file_host_installation_v1_installation_proto_msgTypes[4] +func (x *OfferBinding) Reset() { + *x = OfferBinding{} + mi := &file_host_installation_v1_installation_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListResponse) String() string { +func (x *OfferBinding) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListResponse) ProtoMessage() {} +func (*OfferBinding) ProtoMessage() {} -func (x *ListResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[4] +func (x *OfferBinding) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -383,61 +1335,59 @@ func (x *ListResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. -func (*ListResponse) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{4} +// Deprecated: Use OfferBinding.ProtoReflect.Descriptor instead. +func (*OfferBinding) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{19} } -func (x *ListResponse) GetInstallations() []*Installation { +func (x *OfferBinding) GetBinding() string { if x != nil { - return x.Installations + return x.Binding + } + return "" +} + +func (x *OfferBinding) GetModule() string { + if x != nil { + return x.Module + } + return "" +} + +func (x *OfferBinding) GetScopes() []*OfferScope { + if x != nil { + return x.Scopes } return nil } -// Installation is one organisation's installation of one target. -type Installation struct { +// OfferRole is one declared role and what a grant of it reaches. +type OfferRole struct { state protoimpl.MessageState `protogen:"open.v1"` - // The installation id. - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // The organisation. - OrganizationId string `protobuf:"bytes,2,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` - // The target installed. - Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` - // Where the installation stands. - Status Installation_Status `protobuf:"varint,4,opt,name=status,proto3,enum=host.installation.v1.Installation_Status" json:"status,omitempty"` - // Bumped on every change that narrows or widens the installation. - Revision uint64 `protobuf:"varint,5,opt,name=revision,proto3" json:"revision,omitempty"` - // The role the installation holds. - Role string `protobuf:"bytes,6,opt,name=role,proto3" json:"role,omitempty"` - // The teams granted it. - TeamIds []string `protobuf:"bytes,7,rep,name=team_ids,json=teamIds,proto3" json:"team_ids,omitempty"` - // The principal the installation acts as for itself. - AgentPrincipalId string `protobuf:"bytes,8,opt,name=agent_principal_id,json=agentPrincipalId,proto3" json:"agent_principal_id,omitempty"` - // The namespace the installation's own authored content belongs to (an - // agent's source): the host names it, so a module never chooses which source - // an installation may run. A module installed by composition is - // ".". - Namespace string `protobuf:"bytes,9,opt,name=namespace,proto3" json:"namespace,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The scopes the role declares. + Scopes []*OfferScope `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Each composed operation the role's scopes cover. + Capabilities []*OfferCapability `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Installation) Reset() { - *x = Installation{} - mi := &file_host_installation_v1_installation_proto_msgTypes[5] +func (x *OfferRole) Reset() { + *x = OfferRole{} + mi := &file_host_installation_v1_installation_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Installation) String() string { +func (x *OfferRole) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Installation) ProtoMessage() {} +func (*OfferRole) ProtoMessage() {} -func (x *Installation) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[5] +func (x *OfferRole) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -448,98 +1398,134 @@ func (x *Installation) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Installation.ProtoReflect.Descriptor instead. -func (*Installation) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{5} +// Deprecated: Use OfferRole.ProtoReflect.Descriptor instead. +func (*OfferRole) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{20} } -func (x *Installation) GetId() string { +func (x *OfferRole) GetName() string { if x != nil { - return x.Id + return x.Name } return "" } -func (x *Installation) GetOrganizationId() string { +func (x *OfferRole) GetScopes() []*OfferScope { if x != nil { - return x.OrganizationId + return x.Scopes } - return "" + return nil } -func (x *Installation) GetTarget() string { +func (x *OfferRole) GetCapabilities() []*OfferCapability { if x != nil { - return x.Target + return x.Capabilities } - return "" + return nil } -func (x *Installation) GetStatus() Installation_Status { - if x != nil { - return x.Status - } - return Installation_STATUS_UNSPECIFIED +// OfferScope is one declared scope. +type OfferScope struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResourceKind string `protobuf:"bytes,1,opt,name=resource_kind,json=resourceKind,proto3" json:"resource_kind,omitempty"` + Actions []string `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` + // True when the scope reaches only the resources attached to the + // installation, listed in resource_ids (none until one is attached); false + // when it reaches every resource the installation owns. + AttachedOnly bool `protobuf:"varint,3,opt,name=attached_only,json=attachedOnly,proto3" json:"attached_only,omitempty"` + ResourceIds []string `protobuf:"bytes,4,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *Installation) GetRevision() uint64 { +func (x *OfferScope) Reset() { + *x = OfferScope{} + mi := &file_host_installation_v1_installation_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OfferScope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OfferScope) ProtoMessage() {} + +func (x *OfferScope) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[21] if x != nil { - return x.Revision + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return 0 + return mi.MessageOf(x) } -func (x *Installation) GetRole() string { +// Deprecated: Use OfferScope.ProtoReflect.Descriptor instead. +func (*OfferScope) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{21} +} + +func (x *OfferScope) GetResourceKind() string { if x != nil { - return x.Role + return x.ResourceKind } return "" } -func (x *Installation) GetTeamIds() []string { +func (x *OfferScope) GetActions() []string { if x != nil { - return x.TeamIds + return x.Actions } return nil } -func (x *Installation) GetAgentPrincipalId() string { +func (x *OfferScope) GetAttachedOnly() bool { if x != nil { - return x.AgentPrincipalId + return x.AttachedOnly } - return "" + return false } -func (x *Installation) GetNamespace() string { +func (x *OfferScope) GetResourceIds() []string { if x != nil { - return x.Namespace + return x.ResourceIds } - return "" + return nil } -// InstallationNotice is what the host hands the solution's install step. -type InstallationNotice struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The installation being set up. - Installation *Installation `protobuf:"bytes,1,opt,name=installation,proto3" json:"installation,omitempty"` +// OfferCapability is one module operation a role's scopes cover. +type OfferCapability struct { + state protoimpl.MessageState `protogen:"open.v1"` + Module string `protobuf:"bytes,1,opt,name=module,proto3" json:"module,omitempty"` + // The operation as the module's authority document names it. + Operation string `protobuf:"bytes,2,opt,name=operation,proto3" json:"operation,omitempty"` + // What a call to it must hold. + Requires []*OfferScope `protobuf:"bytes,3,rep,name=requires,proto3" json:"requires,omitempty"` + // The tools composed for this operation, from its prepared Runnable + // bindings; empty when no binding exposes it as a tool. + Tools []*OfferTool `protobuf:"bytes,4,rep,name=tools,proto3" json:"tools,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *InstallationNotice) Reset() { - *x = InstallationNotice{} - mi := &file_host_installation_v1_installation_proto_msgTypes[6] +func (x *OfferCapability) Reset() { + *x = OfferCapability{} + mi := &file_host_installation_v1_installation_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *InstallationNotice) String() string { +func (x *OfferCapability) String() string { return protoimpl.X.MessageStringOf(x) } -func (*InstallationNotice) ProtoMessage() {} +func (*OfferCapability) ProtoMessage() {} -func (x *InstallationNotice) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[6] +func (x *OfferCapability) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -550,40 +1536,67 @@ func (x *InstallationNotice) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use InstallationNotice.ProtoReflect.Descriptor instead. -func (*InstallationNotice) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{6} +// Deprecated: Use OfferCapability.ProtoReflect.Descriptor instead. +func (*OfferCapability) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{22} } -func (x *InstallationNotice) GetInstallation() *Installation { +func (x *OfferCapability) GetModule() string { if x != nil { - return x.Installation + return x.Module + } + return "" +} + +func (x *OfferCapability) GetOperation() string { + if x != nil { + return x.Operation + } + return "" +} + +func (x *OfferCapability) GetRequires() []*OfferScope { + if x != nil { + return x.Requires } return nil } -// InstallationAck acknowledges the install step. -type InstallationAck struct { - state protoimpl.MessageState `protogen:"open.v1"` +func (x *OfferCapability) GetTools() []*OfferTool { + if x != nil { + return x.Tools + } + return nil +} + +// OfferTool is one tool-exposed Runnable of an operation. +type OfferTool struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The tool's name, as its exposure declares it. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The prepared binding a caller names to run it. + Binding string `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + // True when the tool changes data. + Write bool `protobuf:"varint,3,opt,name=write,proto3" json:"write,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *InstallationAck) Reset() { - *x = InstallationAck{} - mi := &file_host_installation_v1_installation_proto_msgTypes[7] +func (x *OfferTool) Reset() { + *x = OfferTool{} + mi := &file_host_installation_v1_installation_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *InstallationAck) String() string { +func (x *OfferTool) String() string { return protoimpl.X.MessageStringOf(x) } -func (*InstallationAck) ProtoMessage() {} +func (*OfferTool) ProtoMessage() {} -func (x *InstallationAck) ProtoReflect() protoreflect.Message { - mi := &file_host_installation_v1_installation_proto_msgTypes[7] +func (x *OfferTool) ProtoReflect() protoreflect.Message { + mi := &file_host_installation_v1_installation_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -594,61 +1607,161 @@ func (x *InstallationAck) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use InstallationAck.ProtoReflect.Descriptor instead. -func (*InstallationAck) Descriptor() ([]byte, []int) { - return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{7} +// Deprecated: Use OfferTool.ProtoReflect.Descriptor instead. +func (*OfferTool) Descriptor() ([]byte, []int) { + return file_host_installation_v1_installation_proto_rawDescGZIP(), []int{23} +} + +func (x *OfferTool) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OfferTool) GetBinding() string { + if x != nil { + return x.Binding + } + return "" +} + +func (x *OfferTool) GetWrite() bool { + if x != nil { + return x.Write + } + return false } var File_host_installation_v1_installation_proto protoreflect.FileDescriptor const file_host_installation_v1_installation_proto_rawDesc = "" + "\n" + - "'host/installation/v1/installation.proto\x12\x14host.installation.v1\"\xd0\x01\n" + + "'host/installation/v1/installation.proto\x12\x14host.installation.v1\"j\n" + + "\x1aCurrentInstallationRequest\x12!\n" + + "\fparent_token\x18\x01 \x01(\tR\vparentToken\x12)\n" + + "\x10actor_credential\x18\x02 \x01(\tR\x0factorCredential\"\xa0\x01\n" + + "\x13CurrentInstallation\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12'\n" + + "\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x16\n" + + "\x06target\x18\x03 \x01(\tR\x06target\x12\x1a\n" + + "\brevision\x18\x04 \x01(\x04R\brevision\x12\x1c\n" + + "\tnamespace\x18\x05 \x01(\tR\tnamespace\"\xd0\x01\n" + "\x0eInstallRequest\x12'\n" + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\x12\x16\n" + "\x06target\x18\x02 \x01(\tR\x06target\x12\x12\n" + "\x04role\x18\x03 \x01(\tR\x04role\x12&\n" + "\x0fowner_person_id\x18\x04 \x01(\tR\rownerPersonId\x12A\n" + - "\bapproval\x18\x05 \x01(\v2%.host.installation.v1.ConsentApprovalR\bapproval\"\x9e\x01\n" + + "\bapproval\x18\x05 \x01(\v2%.host.installation.v1.ConsentApprovalR\bapproval\"P\n" + + "\rReviewRequest\x12'\n" + + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\"\xb6\x01\n" + "\x0fConsentApproval\x12\x1b\n" + "\ttarget_id\x18\x01 \x01(\tR\btargetId\x12\x1e\n" + "\n" + "generation\x18\x02 \x01(\x04R\n" + "generation\x12+\n" + "\x11expected_revision\x18\x03 \x01(\x04R\x10expectedRevision\x12!\n" + - "\foperation_id\x18\x04 \x01(\tR\voperationId\"\x7f\n" + - "\x12GrantToTeamRequest\x12'\n" + - "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\x12\x17\n" + - "\ateam_id\x18\x02 \x01(\tR\x06teamId\x12'\n" + - "\x0forganization_id\x18\x03 \x01(\tR\x0eorganizationId\"6\n" + + "\foperation_id\x18\x04 \x01(\tR\voperationId\x12\x16\n" + + "\x06digest\x18\x05 \x01(\tR\x06digest\"\xae\x01\n" + + "\x10GrantRoleRequest\x12'\n" + + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\x12'\n" + + "\x0finstallation_id\x18\x02 \x01(\tR\x0einstallationId\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\x12\x1b\n" + + "\tperson_id\x18\x04 \x01(\tR\bpersonId\x12\x17\n" + + "\ateam_id\x18\x05 \x01(\tR\x06teamId\"\xaf\x01\n" + + "\x11RevokeRoleRequest\x12'\n" + + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\x12'\n" + + "\x0finstallation_id\x18\x02 \x01(\tR\x0einstallationId\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\x12\x1b\n" + + "\tperson_id\x18\x04 \x01(\tR\bpersonId\x12\x17\n" + + "\ateam_id\x18\x05 \x01(\tR\x06teamId\"i\n" + + "\x15ListRoleGrantsRequest\x12'\n" + + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\x12'\n" + + "\x0finstallation_id\x18\x02 \x01(\tR\x0einstallationId\"Q\n" + + "\x16ListRoleGrantsResponse\x127\n" + + "\x06grants\x18\x01 \x03(\v2\x1f.host.installation.v1.RoleGrantR\x06grants\"U\n" + + "\tRoleGrant\x12\x12\n" + + "\x04role\x18\x01 \x01(\tR\x04role\x12\x1b\n" + + "\tperson_id\x18\x02 \x01(\tR\bpersonId\x12\x17\n" + + "\ateam_id\x18\x03 \x01(\tR\x06teamId\"6\n" + "\vListRequest\x12'\n" + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\"X\n" + "\fListResponse\x12H\n" + - "\rinstallations\x18\x01 \x03(\v2\".host.installation.v1.InstallationR\rinstallations\"\x96\x03\n" + + "\rinstallations\x18\x01 \x03(\v2\".host.installation.v1.InstallationR\rinstallations\"\xb0\x03\n" + "\fInstallation\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12'\n" + "\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x16\n" + "\x06target\x18\x03 \x01(\tR\x06target\x12A\n" + "\x06status\x18\x04 \x01(\x0e2).host.installation.v1.Installation.StatusR\x06status\x12\x1a\n" + "\brevision\x18\x05 \x01(\x04R\brevision\x12\x12\n" + - "\x04role\x18\x06 \x01(\tR\x04role\x12\x19\n" + - "\bteam_ids\x18\a \x03(\tR\ateamIds\x12,\n" + - "\x12agent_principal_id\x18\b \x01(\tR\x10agentPrincipalId\x12\x1c\n" + - "\tnamespace\x18\t \x01(\tR\tnamespace\"[\n" + + "\x04role\x18\x06 \x01(\tR\x04role\x12\x1c\n" + + "\tnamespace\x18\t \x01(\tR\tnamespace\x12!\n" + + "\fprincipal_id\x18\n" + + " \x01(\tR\vprincipalId\x12\x14\n" + + "\x05roles\x18\v \x03(\tR\x05roles\"[\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eSTATUS_PENDING\x10\x01\x12\x11\n" + "\rSTATUS_ACTIVE\x10\x02\x12\x12\n" + - "\x0eSTATUS_REVOKED\x10\x03\"\\\n" + + "\x0eSTATUS_REVOKED\x10\x03J\x04\b\a\x10\bJ\x04\b\b\x10\tR\bteam_idsR\x12agent_principal_id\"\\\n" + "\x12InstallationNotice\x12F\n" + "\finstallation\x18\x01 \x01(\v2\".host.installation.v1.InstallationR\finstallation\"\x11\n" + - "\x0fInstallationAck2\x96\x02\n" + - "\x13InstallationService\x12S\n" + - "\aInstall\x12$.host.installation.v1.InstallRequest\x1a\".host.installation.v1.Installation\x12[\n" + - "\vGrantToTeam\x12(.host.installation.v1.GrantToTeamRequest\x1a\".host.installation.v1.Installation\x12M\n" + + "\x0fInstallationAck\"<\n" + + "\x11ListOffersRequest\x12'\n" + + "\x0forganization_id\x18\x01 \x01(\tR\x0eorganizationId\"I\n" + + "\x12ListOffersResponse\x123\n" + + "\x06offers\x18\x01 \x03(\v2\x1b.host.installation.v1.OfferR\x06offers\"?\n" + + "\x05Offer\x12\x16\n" + + "\x06target\x18\x01 \x01(\tR\x06target\x12\x1e\n" + + "\n" + + "generation\x18\x02 \x01(\x04R\n" + + "generation\"\xce\x02\n" + + "\rReviewedOffer\x12A\n" + + "\bapproval\x18\x01 \x01(\v2%.host.installation.v1.ConsentApprovalR\bapproval\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\x125\n" + + "\x05roles\x18\x03 \x03(\v2\x1f.host.installation.v1.OfferRoleR\x05roles\x12F\n" + + "\finstallation\x18\x04 \x01(\v2\".host.installation.v1.InstallationR\finstallation\x12<\n" + + "\aceiling\x18\x05 \x03(\v2\".host.installation.v1.OfferBindingR\aceiling\x12%\n" + + "\x0etools_composed\x18\x06 \x01(\bR\rtoolsComposed\"z\n" + + "\fOfferBinding\x12\x18\n" + + "\abinding\x18\x01 \x01(\tR\abinding\x12\x16\n" + + "\x06module\x18\x02 \x01(\tR\x06module\x128\n" + + "\x06scopes\x18\x03 \x03(\v2 .host.installation.v1.OfferScopeR\x06scopes\"\xa4\x01\n" + + "\tOfferRole\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + + "\x06scopes\x18\x02 \x03(\v2 .host.installation.v1.OfferScopeR\x06scopes\x12I\n" + + "\fcapabilities\x18\x03 \x03(\v2%.host.installation.v1.OfferCapabilityR\fcapabilities\"\x93\x01\n" + + "\n" + + "OfferScope\x12#\n" + + "\rresource_kind\x18\x01 \x01(\tR\fresourceKind\x12\x18\n" + + "\aactions\x18\x02 \x03(\tR\aactions\x12#\n" + + "\rattached_only\x18\x03 \x01(\bR\fattachedOnly\x12!\n" + + "\fresource_ids\x18\x04 \x03(\tR\vresourceIds\"\xbc\x01\n" + + "\x0fOfferCapability\x12\x16\n" + + "\x06module\x18\x01 \x01(\tR\x06module\x12\x1c\n" + + "\toperation\x18\x02 \x01(\tR\toperation\x12<\n" + + "\brequires\x18\x03 \x03(\v2 .host.installation.v1.OfferScopeR\brequires\x125\n" + + "\x05tools\x18\x04 \x03(\v2\x1f.host.installation.v1.OfferToolR\x05tools\"O\n" + + "\tOfferTool\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\abinding\x18\x02 \x01(\tR\abinding\x12\x14\n" + + "\x05write\x18\x03 \x01(\bR\x05write2\x98\x05\n" + + "\x13InstallationService\x12f\n" + + "\aCurrent\x120.host.installation.v1.CurrentInstallationRequest\x1a).host.installation.v1.CurrentInstallation\x12S\n" + + "\aInstall\x12$.host.installation.v1.InstallRequest\x1a\".host.installation.v1.Installation\x12T\n" + + "\x06Review\x12#.host.installation.v1.ReviewRequest\x1a%.host.installation.v1.ConsentApproval\x12W\n" + + "\tGrantRole\x12&.host.installation.v1.GrantRoleRequest\x1a\".host.installation.v1.Installation\x12Y\n" + + "\n" + + "RevokeRole\x12'.host.installation.v1.RevokeRoleRequest\x1a\".host.installation.v1.Installation\x12k\n" + + "\x0eListRoleGrants\x12+.host.installation.v1.ListRoleGrantsRequest\x1a,.host.installation.v1.ListRoleGrantsResponse\x12M\n" + "\x04List\x12!.host.installation.v1.ListRequest\x1a\".host.installation.v1.ListResponse2p\n" + "\x12InstallableService\x12Z\n" + - "\aInstall\x12(.host.installation.v1.InstallationNotice\x1a%.host.installation.v1.InstallationAckB\xd5\x01\n" + + "\aInstall\x12(.host.installation.v1.InstallationNotice\x1a%.host.installation.v1.InstallationAck2\xc8\x01\n" + + "\fOfferService\x12_\n" + + "\n" + + "ListOffers\x12'.host.installation.v1.ListOffersRequest\x1a(.host.installation.v1.ListOffersResponse\x12W\n" + + "\vReviewOffer\x12#.host.installation.v1.ReviewRequest\x1a#.host.installation.v1.ReviewedOfferB\xd5\x01\n" + "\x18com.host.installation.v1B\x11InstallationProtoP\x01Z4accounts/pkg/gen/host/installation/v1;installationv1\xa2\x02\x03HIX\xaa\x02\x14Host.Installation.V1\xca\x02\x14Host\\Installation\\V1\xe2\x02 Host\\Installation\\V1\\GPBMetadata\xea\x02\x16Host::Installation::V1b\x06proto3" var ( @@ -664,36 +1777,75 @@ func file_host_installation_v1_installation_proto_rawDescGZIP() []byte { } var file_host_installation_v1_installation_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_host_installation_v1_installation_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_host_installation_v1_installation_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_host_installation_v1_installation_proto_goTypes = []any{ - (Installation_Status)(0), // 0: host.installation.v1.Installation.Status - (*InstallRequest)(nil), // 1: host.installation.v1.InstallRequest - (*ConsentApproval)(nil), // 2: host.installation.v1.ConsentApproval - (*GrantToTeamRequest)(nil), // 3: host.installation.v1.GrantToTeamRequest - (*ListRequest)(nil), // 4: host.installation.v1.ListRequest - (*ListResponse)(nil), // 5: host.installation.v1.ListResponse - (*Installation)(nil), // 6: host.installation.v1.Installation - (*InstallationNotice)(nil), // 7: host.installation.v1.InstallationNotice - (*InstallationAck)(nil), // 8: host.installation.v1.InstallationAck + (Installation_Status)(0), // 0: host.installation.v1.Installation.Status + (*CurrentInstallationRequest)(nil), // 1: host.installation.v1.CurrentInstallationRequest + (*CurrentInstallation)(nil), // 2: host.installation.v1.CurrentInstallation + (*InstallRequest)(nil), // 3: host.installation.v1.InstallRequest + (*ReviewRequest)(nil), // 4: host.installation.v1.ReviewRequest + (*ConsentApproval)(nil), // 5: host.installation.v1.ConsentApproval + (*GrantRoleRequest)(nil), // 6: host.installation.v1.GrantRoleRequest + (*RevokeRoleRequest)(nil), // 7: host.installation.v1.RevokeRoleRequest + (*ListRoleGrantsRequest)(nil), // 8: host.installation.v1.ListRoleGrantsRequest + (*ListRoleGrantsResponse)(nil), // 9: host.installation.v1.ListRoleGrantsResponse + (*RoleGrant)(nil), // 10: host.installation.v1.RoleGrant + (*ListRequest)(nil), // 11: host.installation.v1.ListRequest + (*ListResponse)(nil), // 12: host.installation.v1.ListResponse + (*Installation)(nil), // 13: host.installation.v1.Installation + (*InstallationNotice)(nil), // 14: host.installation.v1.InstallationNotice + (*InstallationAck)(nil), // 15: host.installation.v1.InstallationAck + (*ListOffersRequest)(nil), // 16: host.installation.v1.ListOffersRequest + (*ListOffersResponse)(nil), // 17: host.installation.v1.ListOffersResponse + (*Offer)(nil), // 18: host.installation.v1.Offer + (*ReviewedOffer)(nil), // 19: host.installation.v1.ReviewedOffer + (*OfferBinding)(nil), // 20: host.installation.v1.OfferBinding + (*OfferRole)(nil), // 21: host.installation.v1.OfferRole + (*OfferScope)(nil), // 22: host.installation.v1.OfferScope + (*OfferCapability)(nil), // 23: host.installation.v1.OfferCapability + (*OfferTool)(nil), // 24: host.installation.v1.OfferTool } var file_host_installation_v1_installation_proto_depIdxs = []int32{ - 2, // 0: host.installation.v1.InstallRequest.approval:type_name -> host.installation.v1.ConsentApproval - 6, // 1: host.installation.v1.ListResponse.installations:type_name -> host.installation.v1.Installation - 0, // 2: host.installation.v1.Installation.status:type_name -> host.installation.v1.Installation.Status - 6, // 3: host.installation.v1.InstallationNotice.installation:type_name -> host.installation.v1.Installation - 1, // 4: host.installation.v1.InstallationService.Install:input_type -> host.installation.v1.InstallRequest - 3, // 5: host.installation.v1.InstallationService.GrantToTeam:input_type -> host.installation.v1.GrantToTeamRequest - 4, // 6: host.installation.v1.InstallationService.List:input_type -> host.installation.v1.ListRequest - 7, // 7: host.installation.v1.InstallableService.Install:input_type -> host.installation.v1.InstallationNotice - 6, // 8: host.installation.v1.InstallationService.Install:output_type -> host.installation.v1.Installation - 6, // 9: host.installation.v1.InstallationService.GrantToTeam:output_type -> host.installation.v1.Installation - 5, // 10: host.installation.v1.InstallationService.List:output_type -> host.installation.v1.ListResponse - 8, // 11: host.installation.v1.InstallableService.Install:output_type -> host.installation.v1.InstallationAck - 8, // [8:12] is the sub-list for method output_type - 4, // [4:8] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 5, // 0: host.installation.v1.InstallRequest.approval:type_name -> host.installation.v1.ConsentApproval + 10, // 1: host.installation.v1.ListRoleGrantsResponse.grants:type_name -> host.installation.v1.RoleGrant + 13, // 2: host.installation.v1.ListResponse.installations:type_name -> host.installation.v1.Installation + 0, // 3: host.installation.v1.Installation.status:type_name -> host.installation.v1.Installation.Status + 13, // 4: host.installation.v1.InstallationNotice.installation:type_name -> host.installation.v1.Installation + 18, // 5: host.installation.v1.ListOffersResponse.offers:type_name -> host.installation.v1.Offer + 5, // 6: host.installation.v1.ReviewedOffer.approval:type_name -> host.installation.v1.ConsentApproval + 21, // 7: host.installation.v1.ReviewedOffer.roles:type_name -> host.installation.v1.OfferRole + 13, // 8: host.installation.v1.ReviewedOffer.installation:type_name -> host.installation.v1.Installation + 20, // 9: host.installation.v1.ReviewedOffer.ceiling:type_name -> host.installation.v1.OfferBinding + 22, // 10: host.installation.v1.OfferBinding.scopes:type_name -> host.installation.v1.OfferScope + 22, // 11: host.installation.v1.OfferRole.scopes:type_name -> host.installation.v1.OfferScope + 23, // 12: host.installation.v1.OfferRole.capabilities:type_name -> host.installation.v1.OfferCapability + 22, // 13: host.installation.v1.OfferCapability.requires:type_name -> host.installation.v1.OfferScope + 24, // 14: host.installation.v1.OfferCapability.tools:type_name -> host.installation.v1.OfferTool + 1, // 15: host.installation.v1.InstallationService.Current:input_type -> host.installation.v1.CurrentInstallationRequest + 3, // 16: host.installation.v1.InstallationService.Install:input_type -> host.installation.v1.InstallRequest + 4, // 17: host.installation.v1.InstallationService.Review:input_type -> host.installation.v1.ReviewRequest + 6, // 18: host.installation.v1.InstallationService.GrantRole:input_type -> host.installation.v1.GrantRoleRequest + 7, // 19: host.installation.v1.InstallationService.RevokeRole:input_type -> host.installation.v1.RevokeRoleRequest + 8, // 20: host.installation.v1.InstallationService.ListRoleGrants:input_type -> host.installation.v1.ListRoleGrantsRequest + 11, // 21: host.installation.v1.InstallationService.List:input_type -> host.installation.v1.ListRequest + 14, // 22: host.installation.v1.InstallableService.Install:input_type -> host.installation.v1.InstallationNotice + 16, // 23: host.installation.v1.OfferService.ListOffers:input_type -> host.installation.v1.ListOffersRequest + 4, // 24: host.installation.v1.OfferService.ReviewOffer:input_type -> host.installation.v1.ReviewRequest + 2, // 25: host.installation.v1.InstallationService.Current:output_type -> host.installation.v1.CurrentInstallation + 13, // 26: host.installation.v1.InstallationService.Install:output_type -> host.installation.v1.Installation + 5, // 27: host.installation.v1.InstallationService.Review:output_type -> host.installation.v1.ConsentApproval + 13, // 28: host.installation.v1.InstallationService.GrantRole:output_type -> host.installation.v1.Installation + 13, // 29: host.installation.v1.InstallationService.RevokeRole:output_type -> host.installation.v1.Installation + 9, // 30: host.installation.v1.InstallationService.ListRoleGrants:output_type -> host.installation.v1.ListRoleGrantsResponse + 12, // 31: host.installation.v1.InstallationService.List:output_type -> host.installation.v1.ListResponse + 15, // 32: host.installation.v1.InstallableService.Install:output_type -> host.installation.v1.InstallationAck + 17, // 33: host.installation.v1.OfferService.ListOffers:output_type -> host.installation.v1.ListOffersResponse + 19, // 34: host.installation.v1.OfferService.ReviewOffer:output_type -> host.installation.v1.ReviewedOffer + 25, // [25:35] is the sub-list for method output_type + 15, // [15:25] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_host_installation_v1_installation_proto_init() } @@ -707,9 +1859,9 @@ func file_host_installation_v1_installation_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_installation_v1_installation_proto_rawDesc), len(file_host_installation_v1_installation_proto_rawDesc)), NumEnums: 1, - NumMessages: 8, + NumMessages: 24, NumExtensions: 0, - NumServices: 2, + NumServices: 3, }, GoTypes: file_host_installation_v1_installation_proto_goTypes, DependencyIndexes: file_host_installation_v1_installation_proto_depIdxs, diff --git a/module/services/accounts/code/pkg/gen/host/installation/v1/installation_grpc.pb.go b/module/services/accounts/code/pkg/gen/host/installation/v1/installation_grpc.pb.go index 8d5c6fea4..0fdefdf19 100644 --- a/module/services/accounts/code/pkg/gen/host/installation/v1/installation_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/host/installation/v1/installation_grpc.pb.go @@ -5,8 +5,10 @@ // source: host/installation/v1/installation.proto // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package installationv1 @@ -24,9 +26,13 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - InstallationService_Install_FullMethodName = "/host.installation.v1.InstallationService/Install" - InstallationService_GrantToTeam_FullMethodName = "/host.installation.v1.InstallationService/GrantToTeam" - InstallationService_List_FullMethodName = "/host.installation.v1.InstallationService/List" + InstallationService_Current_FullMethodName = "/host.installation.v1.InstallationService/Current" + InstallationService_Install_FullMethodName = "/host.installation.v1.InstallationService/Install" + InstallationService_Review_FullMethodName = "/host.installation.v1.InstallationService/Review" + InstallationService_GrantRole_FullMethodName = "/host.installation.v1.InstallationService/GrantRole" + InstallationService_RevokeRole_FullMethodName = "/host.installation.v1.InstallationService/RevokeRole" + InstallationService_ListRoleGrants_FullMethodName = "/host.installation.v1.InstallationService/ListRoleGrants" + InstallationService_List_FullMethodName = "/host.installation.v1.InstallationService/List" ) // InstallationServiceClient is the client API for InstallationService service. @@ -35,11 +41,26 @@ const ( // // InstallationService is implemented by the host and by the local authority. type InstallationServiceClient interface { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + Current(ctx context.Context, in *CurrentInstallationRequest, opts ...grpc.CallOption) (*CurrentInstallation, error) // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. Install(ctx context.Context, in *InstallRequest, opts ...grpc.CallOption) (*Installation, error) - // GrantToTeam exposes an installation to a team under the installation's role. - GrantToTeam(ctx context.Context, in *GrantToTeamRequest, opts ...grpc.CallOption) (*Installation, error) + // Review reads the current offer and installation revision without granting consent. + Review(ctx context.Context, in *ReviewRequest, opts ...grpc.CallOption) (*ConsentApproval, error) + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*Installation, error) + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*Installation, error) + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + ListRoleGrants(ctx context.Context, in *ListRoleGrantsRequest, opts ...grpc.CallOption) (*ListRoleGrantsResponse, error) // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. @@ -54,6 +75,16 @@ func NewInstallationServiceClient(cc grpc.ClientConnInterface) InstallationServi return &installationServiceClient{cc} } +func (c *installationServiceClient) Current(ctx context.Context, in *CurrentInstallationRequest, opts ...grpc.CallOption) (*CurrentInstallation, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CurrentInstallation) + err := c.cc.Invoke(ctx, InstallationService_Current_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *installationServiceClient) Install(ctx context.Context, in *InstallRequest, opts ...grpc.CallOption) (*Installation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Installation) @@ -64,10 +95,40 @@ func (c *installationServiceClient) Install(ctx context.Context, in *InstallRequ return out, nil } -func (c *installationServiceClient) GrantToTeam(ctx context.Context, in *GrantToTeamRequest, opts ...grpc.CallOption) (*Installation, error) { +func (c *installationServiceClient) Review(ctx context.Context, in *ReviewRequest, opts ...grpc.CallOption) (*ConsentApproval, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConsentApproval) + err := c.cc.Invoke(ctx, InstallationService_Review_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *installationServiceClient) GrantRole(ctx context.Context, in *GrantRoleRequest, opts ...grpc.CallOption) (*Installation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Installation) - err := c.cc.Invoke(ctx, InstallationService_GrantToTeam_FullMethodName, in, out, cOpts...) + err := c.cc.Invoke(ctx, InstallationService_GrantRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *installationServiceClient) RevokeRole(ctx context.Context, in *RevokeRoleRequest, opts ...grpc.CallOption) (*Installation, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Installation) + err := c.cc.Invoke(ctx, InstallationService_RevokeRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *installationServiceClient) ListRoleGrants(ctx context.Context, in *ListRoleGrantsRequest, opts ...grpc.CallOption) (*ListRoleGrantsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListRoleGrantsResponse) + err := c.cc.Invoke(ctx, InstallationService_ListRoleGrants_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -90,11 +151,26 @@ func (c *installationServiceClient) List(ctx context.Context, in *ListRequest, o // // InstallationService is implemented by the host and by the local authority. type InstallationServiceServer interface { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + Current(context.Context, *CurrentInstallationRequest) (*CurrentInstallation, error) // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. Install(context.Context, *InstallRequest) (*Installation, error) - // GrantToTeam exposes an installation to a team under the installation's role. - GrantToTeam(context.Context, *GrantToTeamRequest) (*Installation, error) + // Review reads the current offer and installation revision without granting consent. + Review(context.Context, *ReviewRequest) (*ConsentApproval, error) + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + GrantRole(context.Context, *GrantRoleRequest) (*Installation, error) + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + RevokeRole(context.Context, *RevokeRoleRequest) (*Installation, error) + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + ListRoleGrants(context.Context, *ListRoleGrantsRequest) (*ListRoleGrantsResponse, error) // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. @@ -109,11 +185,23 @@ type InstallationServiceServer interface { // pointer dereference when methods are called. type UnimplementedInstallationServiceServer struct{} +func (UnimplementedInstallationServiceServer) Current(context.Context, *CurrentInstallationRequest) (*CurrentInstallation, error) { + return nil, status.Error(codes.Unimplemented, "method Current not implemented") +} func (UnimplementedInstallationServiceServer) Install(context.Context, *InstallRequest) (*Installation, error) { return nil, status.Error(codes.Unimplemented, "method Install not implemented") } -func (UnimplementedInstallationServiceServer) GrantToTeam(context.Context, *GrantToTeamRequest) (*Installation, error) { - return nil, status.Error(codes.Unimplemented, "method GrantToTeam not implemented") +func (UnimplementedInstallationServiceServer) Review(context.Context, *ReviewRequest) (*ConsentApproval, error) { + return nil, status.Error(codes.Unimplemented, "method Review not implemented") +} +func (UnimplementedInstallationServiceServer) GrantRole(context.Context, *GrantRoleRequest) (*Installation, error) { + return nil, status.Error(codes.Unimplemented, "method GrantRole not implemented") +} +func (UnimplementedInstallationServiceServer) RevokeRole(context.Context, *RevokeRoleRequest) (*Installation, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeRole not implemented") +} +func (UnimplementedInstallationServiceServer) ListRoleGrants(context.Context, *ListRoleGrantsRequest) (*ListRoleGrantsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListRoleGrants not implemented") } func (UnimplementedInstallationServiceServer) List(context.Context, *ListRequest) (*ListResponse, error) { return nil, status.Error(codes.Unimplemented, "method List not implemented") @@ -139,6 +227,24 @@ func RegisterInstallationServiceServer(s grpc.ServiceRegistrar, srv Installation s.RegisterService(&InstallationService_ServiceDesc, srv) } +func _InstallationService_Current_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CurrentInstallationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).Current(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_Current_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).Current(ctx, req.(*CurrentInstallationRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _InstallationService_Install_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InstallRequest) if err := dec(in); err != nil { @@ -157,20 +263,74 @@ func _InstallationService_Install_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -func _InstallationService_GrantToTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GrantToTeamRequest) +func _InstallationService_Review_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReviewRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(InstallationServiceServer).GrantToTeam(ctx, in) + return srv.(InstallationServiceServer).Review(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: InstallationService_GrantToTeam_FullMethodName, + FullMethod: InstallationService_Review_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(InstallationServiceServer).GrantToTeam(ctx, req.(*GrantToTeamRequest)) + return srv.(InstallationServiceServer).Review(ctx, req.(*ReviewRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _InstallationService_GrantRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GrantRoleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).GrantRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_GrantRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).GrantRole(ctx, req.(*GrantRoleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _InstallationService_RevokeRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeRoleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).RevokeRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_RevokeRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).RevokeRole(ctx, req.(*RevokeRoleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _InstallationService_ListRoleGrants_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListRoleGrantsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).ListRoleGrants(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_ListRoleGrants_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).ListRoleGrants(ctx, req.(*ListRoleGrantsRequest)) } return interceptor(ctx, in, info, handler) } @@ -200,13 +360,29 @@ var InstallationService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "host.installation.v1.InstallationService", HandlerType: (*InstallationServiceServer)(nil), Methods: []grpc.MethodDesc{ + { + MethodName: "Current", + Handler: _InstallationService_Current_Handler, + }, { MethodName: "Install", Handler: _InstallationService_Install_Handler, }, { - MethodName: "GrantToTeam", - Handler: _InstallationService_GrantToTeam_Handler, + MethodName: "Review", + Handler: _InstallationService_Review_Handler, + }, + { + MethodName: "GrantRole", + Handler: _InstallationService_GrantRole_Handler, + }, + { + MethodName: "RevokeRole", + Handler: _InstallationService_RevokeRole_Handler, + }, + { + MethodName: "ListRoleGrants", + Handler: _InstallationService_ListRoleGrants_Handler, }, { MethodName: "List", @@ -326,3 +502,167 @@ var InstallableService_ServiceDesc = grpc.ServiceDesc{ Streams: []grpc.StreamDesc{}, Metadata: "host/installation/v1/installation.proto", } + +const ( + OfferService_ListOffers_FullMethodName = "/host.installation.v1.OfferService/ListOffers" + OfferService_ReviewOffer_FullMethodName = "/host.installation.v1.OfferService/ReviewOffer" +) + +// OfferServiceClient is the client API for OfferService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// OfferService is the local authority's setup read, served beside +// InstallationService and not part of codefly.dev/installation: what solutions +// this run delivered and, for one, what installing it would grant. A host +// answers its own product's reads. It grants nothing; Install, GrantRole and +// RevokeRole stay the only changes, each decided by the authority. +type OfferServiceClient interface { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + ListOffers(ctx context.Context, in *ListOffersRequest, opts ...grpc.CallOption) (*ListOffersResponse, error) + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + ReviewOffer(ctx context.Context, in *ReviewRequest, opts ...grpc.CallOption) (*ReviewedOffer, error) +} + +type offerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewOfferServiceClient(cc grpc.ClientConnInterface) OfferServiceClient { + return &offerServiceClient{cc} +} + +func (c *offerServiceClient) ListOffers(ctx context.Context, in *ListOffersRequest, opts ...grpc.CallOption) (*ListOffersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListOffersResponse) + err := c.cc.Invoke(ctx, OfferService_ListOffers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *offerServiceClient) ReviewOffer(ctx context.Context, in *ReviewRequest, opts ...grpc.CallOption) (*ReviewedOffer, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReviewedOffer) + err := c.cc.Invoke(ctx, OfferService_ReviewOffer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// OfferServiceServer is the server API for OfferService service. +// All implementations must embed UnimplementedOfferServiceServer +// for forward compatibility. +// +// OfferService is the local authority's setup read, served beside +// InstallationService and not part of codefly.dev/installation: what solutions +// this run delivered and, for one, what installing it would grant. A host +// answers its own product's reads. It grants nothing; Install, GrantRole and +// RevokeRole stay the only changes, each decided by the authority. +type OfferServiceServer interface { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + ListOffers(context.Context, *ListOffersRequest) (*ListOffersResponse, error) + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + ReviewOffer(context.Context, *ReviewRequest) (*ReviewedOffer, error) + mustEmbedUnimplementedOfferServiceServer() +} + +// UnimplementedOfferServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedOfferServiceServer struct{} + +func (UnimplementedOfferServiceServer) ListOffers(context.Context, *ListOffersRequest) (*ListOffersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListOffers not implemented") +} +func (UnimplementedOfferServiceServer) ReviewOffer(context.Context, *ReviewRequest) (*ReviewedOffer, error) { + return nil, status.Error(codes.Unimplemented, "method ReviewOffer not implemented") +} +func (UnimplementedOfferServiceServer) mustEmbedUnimplementedOfferServiceServer() {} +func (UnimplementedOfferServiceServer) testEmbeddedByValue() {} + +// UnsafeOfferServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to OfferServiceServer will +// result in compilation errors. +type UnsafeOfferServiceServer interface { + mustEmbedUnimplementedOfferServiceServer() +} + +func RegisterOfferServiceServer(s grpc.ServiceRegistrar, srv OfferServiceServer) { + // If the following call panics, it indicates UnimplementedOfferServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&OfferService_ServiceDesc, srv) +} + +func _OfferService_ListOffers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListOffersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OfferServiceServer).ListOffers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OfferService_ListOffers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OfferServiceServer).ListOffers(ctx, req.(*ListOffersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OfferService_ReviewOffer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReviewRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OfferServiceServer).ReviewOffer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OfferService_ReviewOffer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OfferServiceServer).ReviewOffer(ctx, req.(*ReviewRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// OfferService_ServiceDesc is the grpc.ServiceDesc for OfferService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var OfferService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "host.installation.v1.OfferService", + HandlerType: (*OfferServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListOffers", + Handler: _OfferService_ListOffers_Handler, + }, + { + MethodName: "ReviewOffer", + Handler: _OfferService_ReviewOffer_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "host/installation/v1/installation.proto", +} diff --git a/module/services/accounts/code/pkg/gen/host/installation/v1/installationv1connect/installation.connect.go b/module/services/accounts/code/pkg/gen/host/installation/v1/installationv1connect/installation.connect.go index 7d1ae5520..f5cb5ade7 100644 --- a/module/services/accounts/code/pkg/gen/host/installation/v1/installationv1connect/installation.connect.go +++ b/module/services/accounts/code/pkg/gen/host/installation/v1/installationv1connect/installation.connect.go @@ -3,8 +3,10 @@ // Source: host/installation/v1/installation.proto // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package installationv1connect import ( @@ -29,6 +31,8 @@ const ( InstallationServiceName = "host.installation.v1.InstallationService" // InstallableServiceName is the fully-qualified name of the InstallableService service. InstallableServiceName = "host.installation.v1.InstallableService" + // OfferServiceName is the fully-qualified name of the OfferService service. + OfferServiceName = "host.installation.v1.OfferService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -39,27 +43,59 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( + // InstallationServiceCurrentProcedure is the fully-qualified name of the InstallationService's + // Current RPC. + InstallationServiceCurrentProcedure = "/host.installation.v1.InstallationService/Current" // InstallationServiceInstallProcedure is the fully-qualified name of the InstallationService's // Install RPC. InstallationServiceInstallProcedure = "/host.installation.v1.InstallationService/Install" - // InstallationServiceGrantToTeamProcedure is the fully-qualified name of the InstallationService's - // GrantToTeam RPC. - InstallationServiceGrantToTeamProcedure = "/host.installation.v1.InstallationService/GrantToTeam" + // InstallationServiceReviewProcedure is the fully-qualified name of the InstallationService's + // Review RPC. + InstallationServiceReviewProcedure = "/host.installation.v1.InstallationService/Review" + // InstallationServiceGrantRoleProcedure is the fully-qualified name of the InstallationService's + // GrantRole RPC. + InstallationServiceGrantRoleProcedure = "/host.installation.v1.InstallationService/GrantRole" + // InstallationServiceRevokeRoleProcedure is the fully-qualified name of the InstallationService's + // RevokeRole RPC. + InstallationServiceRevokeRoleProcedure = "/host.installation.v1.InstallationService/RevokeRole" + // InstallationServiceListRoleGrantsProcedure is the fully-qualified name of the + // InstallationService's ListRoleGrants RPC. + InstallationServiceListRoleGrantsProcedure = "/host.installation.v1.InstallationService/ListRoleGrants" // InstallationServiceListProcedure is the fully-qualified name of the InstallationService's List // RPC. InstallationServiceListProcedure = "/host.installation.v1.InstallationService/List" // InstallableServiceInstallProcedure is the fully-qualified name of the InstallableService's // Install RPC. InstallableServiceInstallProcedure = "/host.installation.v1.InstallableService/Install" + // OfferServiceListOffersProcedure is the fully-qualified name of the OfferService's ListOffers RPC. + OfferServiceListOffersProcedure = "/host.installation.v1.OfferService/ListOffers" + // OfferServiceReviewOfferProcedure is the fully-qualified name of the OfferService's ReviewOffer + // RPC. + OfferServiceReviewOfferProcedure = "/host.installation.v1.OfferService/ReviewOffer" ) // InstallationServiceClient is a client for the host.installation.v1.InstallationService service. type InstallationServiceClient interface { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + Current(context.Context, *connect.Request[v1.CurrentInstallationRequest]) (*connect.Response[v1.CurrentInstallation], error) // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. Install(context.Context, *connect.Request[v1.InstallRequest]) (*connect.Response[v1.Installation], error) - // GrantToTeam exposes an installation to a team under the installation's role. - GrantToTeam(context.Context, *connect.Request[v1.GrantToTeamRequest]) (*connect.Response[v1.Installation], error) + // Review reads the current offer and installation revision without granting consent. + Review(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ConsentApproval], error) + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + GrantRole(context.Context, *connect.Request[v1.GrantRoleRequest]) (*connect.Response[v1.Installation], error) + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + RevokeRole(context.Context, *connect.Request[v1.RevokeRoleRequest]) (*connect.Response[v1.Installation], error) + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + ListRoleGrants(context.Context, *connect.Request[v1.ListRoleGrantsRequest]) (*connect.Response[v1.ListRoleGrantsResponse], error) // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. @@ -77,16 +113,40 @@ func NewInstallationServiceClient(httpClient connect.HTTPClient, baseURL string, baseURL = strings.TrimRight(baseURL, "/") installationServiceMethods := v1.File_host_installation_v1_installation_proto.Services().ByName("InstallationService").Methods() return &installationServiceClient{ + current: connect.NewClient[v1.CurrentInstallationRequest, v1.CurrentInstallation]( + httpClient, + baseURL+InstallationServiceCurrentProcedure, + connect.WithSchema(installationServiceMethods.ByName("Current")), + connect.WithClientOptions(opts...), + ), install: connect.NewClient[v1.InstallRequest, v1.Installation]( httpClient, baseURL+InstallationServiceInstallProcedure, connect.WithSchema(installationServiceMethods.ByName("Install")), connect.WithClientOptions(opts...), ), - grantToTeam: connect.NewClient[v1.GrantToTeamRequest, v1.Installation]( + review: connect.NewClient[v1.ReviewRequest, v1.ConsentApproval]( + httpClient, + baseURL+InstallationServiceReviewProcedure, + connect.WithSchema(installationServiceMethods.ByName("Review")), + connect.WithClientOptions(opts...), + ), + grantRole: connect.NewClient[v1.GrantRoleRequest, v1.Installation]( + httpClient, + baseURL+InstallationServiceGrantRoleProcedure, + connect.WithSchema(installationServiceMethods.ByName("GrantRole")), + connect.WithClientOptions(opts...), + ), + revokeRole: connect.NewClient[v1.RevokeRoleRequest, v1.Installation]( httpClient, - baseURL+InstallationServiceGrantToTeamProcedure, - connect.WithSchema(installationServiceMethods.ByName("GrantToTeam")), + baseURL+InstallationServiceRevokeRoleProcedure, + connect.WithSchema(installationServiceMethods.ByName("RevokeRole")), + connect.WithClientOptions(opts...), + ), + listRoleGrants: connect.NewClient[v1.ListRoleGrantsRequest, v1.ListRoleGrantsResponse]( + httpClient, + baseURL+InstallationServiceListRoleGrantsProcedure, + connect.WithSchema(installationServiceMethods.ByName("ListRoleGrants")), connect.WithClientOptions(opts...), ), list: connect.NewClient[v1.ListRequest, v1.ListResponse]( @@ -100,9 +160,18 @@ func NewInstallationServiceClient(httpClient connect.HTTPClient, baseURL string, // installationServiceClient implements InstallationServiceClient. type installationServiceClient struct { - install *connect.Client[v1.InstallRequest, v1.Installation] - grantToTeam *connect.Client[v1.GrantToTeamRequest, v1.Installation] - list *connect.Client[v1.ListRequest, v1.ListResponse] + current *connect.Client[v1.CurrentInstallationRequest, v1.CurrentInstallation] + install *connect.Client[v1.InstallRequest, v1.Installation] + review *connect.Client[v1.ReviewRequest, v1.ConsentApproval] + grantRole *connect.Client[v1.GrantRoleRequest, v1.Installation] + revokeRole *connect.Client[v1.RevokeRoleRequest, v1.Installation] + listRoleGrants *connect.Client[v1.ListRoleGrantsRequest, v1.ListRoleGrantsResponse] + list *connect.Client[v1.ListRequest, v1.ListResponse] +} + +// Current calls host.installation.v1.InstallationService.Current. +func (c *installationServiceClient) Current(ctx context.Context, req *connect.Request[v1.CurrentInstallationRequest]) (*connect.Response[v1.CurrentInstallation], error) { + return c.current.CallUnary(ctx, req) } // Install calls host.installation.v1.InstallationService.Install. @@ -110,9 +179,24 @@ func (c *installationServiceClient) Install(ctx context.Context, req *connect.Re return c.install.CallUnary(ctx, req) } -// GrantToTeam calls host.installation.v1.InstallationService.GrantToTeam. -func (c *installationServiceClient) GrantToTeam(ctx context.Context, req *connect.Request[v1.GrantToTeamRequest]) (*connect.Response[v1.Installation], error) { - return c.grantToTeam.CallUnary(ctx, req) +// Review calls host.installation.v1.InstallationService.Review. +func (c *installationServiceClient) Review(ctx context.Context, req *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ConsentApproval], error) { + return c.review.CallUnary(ctx, req) +} + +// GrantRole calls host.installation.v1.InstallationService.GrantRole. +func (c *installationServiceClient) GrantRole(ctx context.Context, req *connect.Request[v1.GrantRoleRequest]) (*connect.Response[v1.Installation], error) { + return c.grantRole.CallUnary(ctx, req) +} + +// RevokeRole calls host.installation.v1.InstallationService.RevokeRole. +func (c *installationServiceClient) RevokeRole(ctx context.Context, req *connect.Request[v1.RevokeRoleRequest]) (*connect.Response[v1.Installation], error) { + return c.revokeRole.CallUnary(ctx, req) +} + +// ListRoleGrants calls host.installation.v1.InstallationService.ListRoleGrants. +func (c *installationServiceClient) ListRoleGrants(ctx context.Context, req *connect.Request[v1.ListRoleGrantsRequest]) (*connect.Response[v1.ListRoleGrantsResponse], error) { + return c.listRoleGrants.CallUnary(ctx, req) } // List calls host.installation.v1.InstallationService.List. @@ -123,11 +207,26 @@ func (c *installationServiceClient) List(ctx context.Context, req *connect.Reque // InstallationServiceHandler is an implementation of the host.installation.v1.InstallationService // service. type InstallationServiceHandler interface { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + Current(context.Context, *connect.Request[v1.CurrentInstallationRequest]) (*connect.Response[v1.CurrentInstallation], error) // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. Install(context.Context, *connect.Request[v1.InstallRequest]) (*connect.Response[v1.Installation], error) - // GrantToTeam exposes an installation to a team under the installation's role. - GrantToTeam(context.Context, *connect.Request[v1.GrantToTeamRequest]) (*connect.Response[v1.Installation], error) + // Review reads the current offer and installation revision without granting consent. + Review(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ConsentApproval], error) + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + GrantRole(context.Context, *connect.Request[v1.GrantRoleRequest]) (*connect.Response[v1.Installation], error) + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + RevokeRole(context.Context, *connect.Request[v1.RevokeRoleRequest]) (*connect.Response[v1.Installation], error) + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + ListRoleGrants(context.Context, *connect.Request[v1.ListRoleGrantsRequest]) (*connect.Response[v1.ListRoleGrantsResponse], error) // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. @@ -141,16 +240,40 @@ type InstallationServiceHandler interface { // and JSON codecs. They also support gzip compression. func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { installationServiceMethods := v1.File_host_installation_v1_installation_proto.Services().ByName("InstallationService").Methods() + installationServiceCurrentHandler := connect.NewUnaryHandler( + InstallationServiceCurrentProcedure, + svc.Current, + connect.WithSchema(installationServiceMethods.ByName("Current")), + connect.WithHandlerOptions(opts...), + ) installationServiceInstallHandler := connect.NewUnaryHandler( InstallationServiceInstallProcedure, svc.Install, connect.WithSchema(installationServiceMethods.ByName("Install")), connect.WithHandlerOptions(opts...), ) - installationServiceGrantToTeamHandler := connect.NewUnaryHandler( - InstallationServiceGrantToTeamProcedure, - svc.GrantToTeam, - connect.WithSchema(installationServiceMethods.ByName("GrantToTeam")), + installationServiceReviewHandler := connect.NewUnaryHandler( + InstallationServiceReviewProcedure, + svc.Review, + connect.WithSchema(installationServiceMethods.ByName("Review")), + connect.WithHandlerOptions(opts...), + ) + installationServiceGrantRoleHandler := connect.NewUnaryHandler( + InstallationServiceGrantRoleProcedure, + svc.GrantRole, + connect.WithSchema(installationServiceMethods.ByName("GrantRole")), + connect.WithHandlerOptions(opts...), + ) + installationServiceRevokeRoleHandler := connect.NewUnaryHandler( + InstallationServiceRevokeRoleProcedure, + svc.RevokeRole, + connect.WithSchema(installationServiceMethods.ByName("RevokeRole")), + connect.WithHandlerOptions(opts...), + ) + installationServiceListRoleGrantsHandler := connect.NewUnaryHandler( + InstallationServiceListRoleGrantsProcedure, + svc.ListRoleGrants, + connect.WithSchema(installationServiceMethods.ByName("ListRoleGrants")), connect.WithHandlerOptions(opts...), ) installationServiceListHandler := connect.NewUnaryHandler( @@ -161,10 +284,18 @@ func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...conne ) return "/host.installation.v1.InstallationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case InstallationServiceCurrentProcedure: + installationServiceCurrentHandler.ServeHTTP(w, r) case InstallationServiceInstallProcedure: installationServiceInstallHandler.ServeHTTP(w, r) - case InstallationServiceGrantToTeamProcedure: - installationServiceGrantToTeamHandler.ServeHTTP(w, r) + case InstallationServiceReviewProcedure: + installationServiceReviewHandler.ServeHTTP(w, r) + case InstallationServiceGrantRoleProcedure: + installationServiceGrantRoleHandler.ServeHTTP(w, r) + case InstallationServiceRevokeRoleProcedure: + installationServiceRevokeRoleHandler.ServeHTTP(w, r) + case InstallationServiceListRoleGrantsProcedure: + installationServiceListRoleGrantsHandler.ServeHTTP(w, r) case InstallationServiceListProcedure: installationServiceListHandler.ServeHTTP(w, r) default: @@ -176,12 +307,28 @@ func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...conne // UnimplementedInstallationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedInstallationServiceHandler struct{} +func (UnimplementedInstallationServiceHandler) Current(context.Context, *connect.Request[v1.CurrentInstallationRequest]) (*connect.Response[v1.CurrentInstallation], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.Current is not implemented")) +} + func (UnimplementedInstallationServiceHandler) Install(context.Context, *connect.Request[v1.InstallRequest]) (*connect.Response[v1.Installation], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.Install is not implemented")) } -func (UnimplementedInstallationServiceHandler) GrantToTeam(context.Context, *connect.Request[v1.GrantToTeamRequest]) (*connect.Response[v1.Installation], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.GrantToTeam is not implemented")) +func (UnimplementedInstallationServiceHandler) Review(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ConsentApproval], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.Review is not implemented")) +} + +func (UnimplementedInstallationServiceHandler) GrantRole(context.Context, *connect.Request[v1.GrantRoleRequest]) (*connect.Response[v1.Installation], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.GrantRole is not implemented")) +} + +func (UnimplementedInstallationServiceHandler) RevokeRole(context.Context, *connect.Request[v1.RevokeRoleRequest]) (*connect.Response[v1.Installation], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.RevokeRole is not implemented")) +} + +func (UnimplementedInstallationServiceHandler) ListRoleGrants(context.Context, *connect.Request[v1.ListRoleGrantsRequest]) (*connect.Response[v1.ListRoleGrantsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallationService.ListRoleGrants is not implemented")) } func (UnimplementedInstallationServiceHandler) List(context.Context, *connect.Request[v1.ListRequest]) (*connect.Response[v1.ListResponse], error) { @@ -260,3 +407,111 @@ type UnimplementedInstallableServiceHandler struct{} func (UnimplementedInstallableServiceHandler) Install(context.Context, *connect.Request[v1.InstallationNotice]) (*connect.Response[v1.InstallationAck], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.InstallableService.Install is not implemented")) } + +// OfferServiceClient is a client for the host.installation.v1.OfferService service. +type OfferServiceClient interface { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + ListOffers(context.Context, *connect.Request[v1.ListOffersRequest]) (*connect.Response[v1.ListOffersResponse], error) + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + ReviewOffer(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ReviewedOffer], error) +} + +// NewOfferServiceClient constructs a client for the host.installation.v1.OfferService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewOfferServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) OfferServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + offerServiceMethods := v1.File_host_installation_v1_installation_proto.Services().ByName("OfferService").Methods() + return &offerServiceClient{ + listOffers: connect.NewClient[v1.ListOffersRequest, v1.ListOffersResponse]( + httpClient, + baseURL+OfferServiceListOffersProcedure, + connect.WithSchema(offerServiceMethods.ByName("ListOffers")), + connect.WithClientOptions(opts...), + ), + reviewOffer: connect.NewClient[v1.ReviewRequest, v1.ReviewedOffer]( + httpClient, + baseURL+OfferServiceReviewOfferProcedure, + connect.WithSchema(offerServiceMethods.ByName("ReviewOffer")), + connect.WithClientOptions(opts...), + ), + } +} + +// offerServiceClient implements OfferServiceClient. +type offerServiceClient struct { + listOffers *connect.Client[v1.ListOffersRequest, v1.ListOffersResponse] + reviewOffer *connect.Client[v1.ReviewRequest, v1.ReviewedOffer] +} + +// ListOffers calls host.installation.v1.OfferService.ListOffers. +func (c *offerServiceClient) ListOffers(ctx context.Context, req *connect.Request[v1.ListOffersRequest]) (*connect.Response[v1.ListOffersResponse], error) { + return c.listOffers.CallUnary(ctx, req) +} + +// ReviewOffer calls host.installation.v1.OfferService.ReviewOffer. +func (c *offerServiceClient) ReviewOffer(ctx context.Context, req *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ReviewedOffer], error) { + return c.reviewOffer.CallUnary(ctx, req) +} + +// OfferServiceHandler is an implementation of the host.installation.v1.OfferService service. +type OfferServiceHandler interface { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + ListOffers(context.Context, *connect.Request[v1.ListOffersRequest]) (*connect.Response[v1.ListOffersResponse], error) + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + ReviewOffer(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ReviewedOffer], error) +} + +// NewOfferServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewOfferServiceHandler(svc OfferServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + offerServiceMethods := v1.File_host_installation_v1_installation_proto.Services().ByName("OfferService").Methods() + offerServiceListOffersHandler := connect.NewUnaryHandler( + OfferServiceListOffersProcedure, + svc.ListOffers, + connect.WithSchema(offerServiceMethods.ByName("ListOffers")), + connect.WithHandlerOptions(opts...), + ) + offerServiceReviewOfferHandler := connect.NewUnaryHandler( + OfferServiceReviewOfferProcedure, + svc.ReviewOffer, + connect.WithSchema(offerServiceMethods.ByName("ReviewOffer")), + connect.WithHandlerOptions(opts...), + ) + return "/host.installation.v1.OfferService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case OfferServiceListOffersProcedure: + offerServiceListOffersHandler.ServeHTTP(w, r) + case OfferServiceReviewOfferProcedure: + offerServiceReviewOfferHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedOfferServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedOfferServiceHandler struct{} + +func (UnimplementedOfferServiceHandler) ListOffers(context.Context, *connect.Request[v1.ListOffersRequest]) (*connect.Response[v1.ListOffersResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.OfferService.ListOffers is not implemented")) +} + +func (UnimplementedOfferServiceHandler) ReviewOffer(context.Context, *connect.Request[v1.ReviewRequest]) (*connect.Response[v1.ReviewedOffer], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.installation.v1.OfferService.ReviewOffer is not implemented")) +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go index 7e6285a5f..49ad7dcd6 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go @@ -82,8 +82,8 @@ type InstallationServiceClient interface { // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation. UninstallSolution(context.Context, *connect.Request[v1.UninstallSolutionRequest]) (*connect.Response[emptypb.Empty], error) - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. NarrowInstallation(context.Context, *connect.Request[v1.NarrowInstallationRequest]) (*connect.Response[emptypb.Empty], error) // RetryInstallation runs a failed installation's install step again, at the // same revision. The installation is pending until the solution acknowledges. @@ -150,9 +150,8 @@ type InstallationServiceClient interface { // ListOrganizationInstallations describes. GrantInstallationToTeam(context.Context, *connect.Request[v1.GrantInstallationToTeamRequest]) (*connect.Response[v1.GrantInstallationToTeamResponse], error) // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. @@ -324,8 +323,8 @@ type InstallationServiceHandler interface { // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation. UninstallSolution(context.Context, *connect.Request[v1.UninstallSolutionRequest]) (*connect.Response[emptypb.Empty], error) - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. NarrowInstallation(context.Context, *connect.Request[v1.NarrowInstallationRequest]) (*connect.Response[emptypb.Empty], error) // RetryInstallation runs a failed installation's install step again, at the // same revision. The installation is pending until the solution acknowledges. @@ -392,9 +391,8 @@ type InstallationServiceHandler interface { // ListOrganizationInstallations describes. GrantInstallationToTeam(context.Context, *connect.Request[v1.GrantInstallationToTeamRequest]) (*connect.Response[v1.GrantInstallationToTeamResponse], error) // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go index cee20fabe..98393735b 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go @@ -52,8 +52,8 @@ type InstallationServiceClient interface { // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation. UninstallSolution(ctx context.Context, in *UninstallSolutionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. NarrowInstallation(ctx context.Context, in *NarrowInstallationRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // RetryInstallation runs a failed installation's install step again, at the // same revision. The installation is pending until the solution acknowledges. @@ -120,9 +120,8 @@ type InstallationServiceClient interface { // ListOrganizationInstallations describes. GrantInstallationToTeam(ctx context.Context, in *GrantInstallationToTeamRequest, opts ...grpc.CallOption) (*GrantInstallationToTeamResponse, error) // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. @@ -265,8 +264,8 @@ type InstallationServiceServer interface { // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation. UninstallSolution(context.Context, *UninstallSolutionRequest) (*emptypb.Empty, error) - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. NarrowInstallation(context.Context, *NarrowInstallationRequest) (*emptypb.Empty, error) // RetryInstallation runs a failed installation's install step again, at the // same revision. The installation is pending until the solution acknowledges. @@ -333,9 +332,8 @@ type InstallationServiceServer interface { // ListOrganizationInstallations describes. GrantInstallationToTeam(context.Context, *GrantInstallationToTeamRequest) (*GrantInstallationToTeamResponse, error) // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. diff --git a/module/services/accounts/code/pkg/infra/authority_delivery_test.go b/module/services/accounts/code/pkg/infra/authority_delivery_test.go index 635029c9e..7f7e5413c 100644 --- a/module/services/accounts/code/pkg/infra/authority_delivery_test.go +++ b/module/services/accounts/code/pkg/infra/authority_delivery_test.go @@ -27,6 +27,9 @@ func TestCompositionPersistsOneImmutablePolicyPair(t *testing.T) { require.NoError(t, err) d.service.SetCompositionReconcile(reconciler.RunOnce) m := productionPathManifest(t, uniqueAlias("policy-pair"), 1) + // A module: the policy pair is what is under test, and a solution would + // have to declare its roles (an installer) for the composition to take it. + m.Kind = solutionhost.KindModule m.Authority = &solutionhost.Authority{Principal: m.Alias, ResourceKinds: []string{"example"}} d.callerNamespace = productionPathNamespace(m.Alias) request := func(m *solutionhost.ModuleManifest, method string) *compositionv1.ApplyManifestRequest { diff --git a/module/services/accounts/code/pkg/infra/authority_document_cache_test.go b/module/services/accounts/code/pkg/infra/authority_document_cache_test.go index 667aa0ddb..b1026b757 100644 --- a/module/services/accounts/code/pkg/infra/authority_document_cache_test.go +++ b/module/services/accounts/code/pkg/infra/authority_document_cache_test.go @@ -32,6 +32,9 @@ func TestAnAppliedAuthorityDocumentIsVerifiedOncePerGeneration(t *testing.T) { require.NoError(t, err) d.service.SetCompositionReconcile(reconciler.RunOnce) m := productionPathManifest(t, uniqueAlias("cached-policy"), 1) + // A module: the policy pair is what is under test, and a solution would + // have to declare its roles (an installer) for the composition to take it. + m.Kind = solutionhost.KindModule m.Authority = &solutionhost.Authority{Principal: m.Alias, ResourceKinds: []string{"example"}} d.callerNamespace = productionPathNamespace(m.Alias) apply := func(method string) { diff --git a/module/services/accounts/code/pkg/infra/installation_dynamic_test.go b/module/services/accounts/code/pkg/infra/installation_dynamic_test.go index 9b6114abb..1aa59bd90 100644 --- a/module/services/accounts/code/pkg/infra/installation_dynamic_test.go +++ b/module/services/accounts/code/pkg/infra/installation_dynamic_test.go @@ -174,7 +174,6 @@ func TestDynamicInstallationDBCancelsOnlyItsOrgAndFencesLeases(t *testing.T) { emitter, err := business.NewDurableAuditEmitter(testStore, testStore) require.NoError(t, err) svc.SetAuditEmitter(emitter) - wireNarrowingPolicyLog(svc) if narrow { scope := business.FullInstallationScope(a.grant) scope.Queues = nil diff --git a/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go b/module/services/accounts/code/pkg/infra/installed_content_mode_change_test.go similarity index 84% rename from module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go rename to module/services/accounts/code/pkg/infra/installed_content_mode_change_test.go index 426103afd..d3f683e47 100644 --- a/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go +++ b/module/services/accounts/code/pkg/infra/installed_content_mode_change_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestInstalledContentReadModeMigrationRequiresWitnessPostgres(t *testing.T) { +func TestInstalledContentReadModeChangeAppliesDirectlyPostgres(t *testing.T) { for _, removing := range []bool{false, true} { t.Run(map[bool]string{false: "ordinary to content", true: "content to ordinary"}[removing], func(t *testing.T) { s := newRegistryScenario(t, "modewitness") @@ -24,7 +24,6 @@ func TestInstalledContentReadModeMigrationRequiresWitnessPostgres(t *testing.T) current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, before, current+1)) require.NoError(t, err) - s.delivery.service.SetPolicyLog(nil, nil) next := envelopeJSONClone(t, before) if removing { delete(binding(next), "delegation_mode") @@ -32,11 +31,10 @@ func TestInstalledContentReadModeMigrationRequiresWitnessPostgres(t *testing.T) binding(next)["delegation_mode"] = business.DelegationModeContentRead } _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current+1, envelopeJSONAt(t, next, current+2)) - require.Error(t, err) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + require.NoError(t, err, "a delegation-mode change applies directly") record, err := s.delivery.service.GetModuleEnvelope(testCtx, s.admin) require.NoError(t, err) - require.Equal(t, current+1, record.Revision) + require.Equal(t, current+2, record.Revision) }) } } diff --git a/module/services/accounts/code/pkg/infra/installed_content_read_test.go b/module/services/accounts/code/pkg/infra/installed_content_read_test.go index 8c4eba527..c235cf8ba 100644 --- a/module/services/accounts/code/pkg/infra/installed_content_read_test.go +++ b/module/services/accounts/code/pkg/infra/installed_content_read_test.go @@ -23,7 +23,7 @@ import ( ) // Owner integration evidence, not native workload/composition qualification: -// the harness controls delivery TokenReview, records policy-log writes, and +// the harness controls delivery TokenReview and // supplies test signing/execution bindings. The module credential is created // directly by StartModuleTask. Registry, applied generations, installations, // human grants and authority reads use migrated PostgreSQL and owner adapters. @@ -198,7 +198,7 @@ func TestInstalledContentReadPostgresRetainedBinding(t *testing.T) { require.Equal(t, gen.WorkContextOperationBindingState_WORK_CONTEXT_OPERATION_BINDING_STATE_RESOLVED, answer.GetOperationBinding().GetState()) // The ordinary binding remains installed. Removing its content-read mode - // is a witnessed restriction, and the callee must receive an explicit + // is a restriction, and the callee must receive an explicit // absence for the retained child's old binding, not an authority outage. marked := grants[s.prefix].Grant.OperationAudiences["evidence"] ordinary := marked diff --git a/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go b/module/services/accounts/code/pkg/infra/module_envelope_narrowing_test.go similarity index 74% rename from module/services/accounts/code/pkg/infra/module_envelope_witness_test.go rename to module/services/accounts/code/pkg/infra/module_envelope_narrowing_test.go index bd8c40ce5..774df68c6 100644 --- a/module/services/accounts/code/pkg/infra/module_envelope_witness_test.go +++ b/module/services/accounts/code/pkg/infra/module_envelope_narrowing_test.go @@ -39,18 +39,13 @@ func envelopeJSONAt(t *testing.T, doc map[string]any, revision uint64) []byte { return raw } -// Only a pure grant skips the policy-log witness: whole principals added, and a -// kept principal holding at least what it held by the rule an installation's -// consent is held to its ceiling with. With no policy log wired, a write that -// needs the witness is refused and writes nothing, so "it landed" is the proof -// a change was applied unwitnessed. -// -// Every refused shape below is a real narrowing that a comparison by "more -// JSON means more authority" let through: a resource list that appears -// restricts an audience that was unrestricted, a required kind that appears -// refuses artifacts that passed, and a tenant that is set removes every other -// organization. A member added to an allowlist is not one of them: it lands. -func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { +// Every change to a granted principal applies directly, the narrowings with +// the pure grants: no witness is asked first. Each narrowing below is a real +// one — a resource list that appears restricts an audience that was +// unrestricted, a required kind that appears refuses artifacts that passed, a +// tenant that is set removes every other organization — and each lands as +// the next revision. +func TestAnEnvelopeChangeToAGrantedPrincipalAppliesDirectly(t *testing.T) { s := newRegistryScenario(t, "rvf") digest := "sha256:" + strings.Repeat("ab", 32) base := envelopeJSON(t, s.envelope(t, 1, envelopeOptions{resources: true})) @@ -78,12 +73,9 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, base, current+1)) - require.NoError(t, err, "the base envelope (witnessed) must be written") + require.NoError(t, err, "the base envelope must be written") state := base - // From here the host has no witness at all. - s.delivery.service.SetPolicyLog(nil, nil) - opAudience := func(doc map[string]any) map[string]any { return grant(doc)["operation_audiences"].(map[string]any)["run"].(map[string]any) } @@ -116,14 +108,14 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { _, parseErr := business.ParseModuleEnvelope(raw) require.NoError(t, parseErr, "the document must be a valid envelope") _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, raw) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable, "the change must need the witness") - require.Equal(t, current, s.currentRevision(t), "a refused write must write nothing") + require.NoError(t, err, "a narrowing applies directly") + require.Equal(t, current+1, s.currentRevision(t)) }) } // The pure grants: a member added to a granted principal's allowlist, and a // whole principal added beside the ones granted. - t.Run("a queue added to a granted principal lands with no witness", func(t *testing.T) { + t.Run("a queue added to a granted principal lands", func(t *testing.T) { next := envelopeJSONClone(t, state) grant(next)["queues"] = []any{"reports.render", "reports.extra"} current := s.currentRevision(t) @@ -132,7 +124,7 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { require.Equal(t, current+1, s.currentRevision(t)) state = next }) - t.Run("a principal added lands with no witness", func(t *testing.T) { + t.Run("a principal added lands", func(t *testing.T) { next := envelopeJSONClone(t, state) next["principals"].(map[string]any)["added-"+s.prefix] = map[string]any{"grant": map[string]any{"cross_tenant": true}} current := s.currentRevision(t) @@ -141,22 +133,22 @@ func TestAnEnvelopeChangeToAGrantedPrincipalNeedsTheWitness(t *testing.T) { require.Equal(t, current+1, s.currentRevision(t)) state = next }) - t.Run("a principal removed needs the witness", func(t *testing.T) { + t.Run("a principal removed lands", func(t *testing.T) { next := envelopeJSONClone(t, state) delete(next["principals"].(map[string]any), "added-"+s.prefix) current := s.currentRevision(t) _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, next, current+1)) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + require.NoError(t, err) + require.Equal(t, current+1, s.currentRevision(t)) }) - s.delivery.service.SetPolicyLog(&productionPathPolicyLog{}, testStore) } -// The installation half: an envelope that would take an organization's -// installation away is a narrowing, and with no witness it is refused and the -// installation stays as it was. The classification reads installations on the -// control plane; read anywhere else a row policy hides them all, and the write -// revoked an active installation unwitnessed. -func TestAnEnvelopeThatWouldRevokeAnInstallationNeedsTheWitness(t *testing.T) { +// The installation half: an envelope that takes an organization's +// installation away applies directly, revokes that installation in the same +// transaction, and the next capability decision for that organization refuses. +// The revocation reads installations on the control plane; read anywhere else +// a row policy hides them all and the installation would survive the ceiling. +func TestAnEnvelopeThatRevokesAnInstallationAppliesDirectly(t *testing.T) { s := newRegistryScenario(t, "rvi") orgB := seedOrg(t, s.admin) require.NoError(t, testStore.As(business.Identity{OrgID: orgB}).AddOrgMember(testCtx, s.admin, "admin")) @@ -190,15 +182,17 @@ func TestAnEnvelopeThatWouldRevokeAnInstallationNeedsTheWitness(t *testing.T) { _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(orgB)) require.NoError(t, err, "org B's installation is usable under the shared tenancy") - s.delivery.service.SetPolicyLog(nil, nil) pinned := envelopeJSONClone(t, shared) grant(pinned)["tenant"] = s.tenant current = s.currentRevision(t) _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, pinned, current+1)) - require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) - require.Equal(t, gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, status(orgB, active.Id), - "an unwitnessed envelope write must not revoke an installation") - s.delivery.service.SetPolicyLog(&productionPathPolicyLog{}, testStore) + require.NoError(t, err, "the narrowing applies directly") + require.Equal(t, current+1, s.currentRevision(t)) + require.Equal(t, gen.InstallationStatus_INSTALLATION_STATUS_REVOKED, status(orgB, active.Id), + "the ceiling no longer allows org B, so its installation is revoked in the same write") + s.pass(t) + _, err = s.delivery.service.AuthorizeModuleCapability(testCtx, s.caller(orgB)) + require.Error(t, err, "org B's next capability decision refuses") } func jsonNumber(value uint64) string { diff --git a/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go b/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go index ab02a7739..4c2a992f6 100644 --- a/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go @@ -29,9 +29,7 @@ func auditedService(t *testing.T, emitter business.AuditEmitter) *business.Servi service, err := business.NewService(testStore) require.NoError(t, err) service.SetAuditEmitter(emitter) - // These tests revoke principals and remove members, which are witnessed - // narrowings; see wireNarrowingPolicyLog. - return wireNarrowingPolicyLog(service) + return service } func countRows(t *testing.T, query string, args ...any) int { diff --git a/module/services/accounts/code/pkg/infra/postgres_authority_core.go b/module/services/accounts/code/pkg/infra/postgres_authority_core.go new file mode 100644 index 000000000..01dd5f05c --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_authority_core.go @@ -0,0 +1,1047 @@ +package infra + +// The authority core's store over accounts' Postgres. +// +// codefly-dev/authority's authority-core decides every operation of the host +// interfaces; this file is where accounts keeps what it decides. The core's +// own records (composed modules and their tombstones, installations, role +// grants, delegations, grant epochs, owner-aware revisions and consumed +// single-use nonces) live in the authority_core_* relations of migration 56, +// control-plane only: the core is the platform's authority and decides across +// organizations (a module delivered is installed at every one). Identity is +// accounts' own and is only read here: organizations, memberships, teams, and +// the organization roles a member holds, synthesized from accounts' RBAC +// (role assignments, scope grants and the members' contributed permissions). +// The organization's authorization revision is accounts' own lever, so every +// RBAC change accounts records already revokes what the core issued. +// +// Read runs at REPEATABLE READ, READ ONLY; Write at SERIALIZABLE, retried on a +// serialization failure and reported as authority.ErrConflict when it keeps +// conflicting. Both run under the control-plane role. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + resources "github.com/codefly-dev/authority/libraries/resources/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "accounts/pkg/business" +) + +// authorityCoreAttempts bounds how often a conflicting write is retried. +const authorityCoreAttempts = 8 + +// AuthorityCoreStore is the core's Store over this host's Postgres. +type AuthorityCoreStore struct { + s *PostgresStore + consent business.ModuleConsent +} + +var _ authority.Store = AuthorityCoreStore{} + +// AuthorityCore is the core's store over this database, reading a module +// installation's consent as allowing every binding as delivered: for the +// store contract's tests. A host serves through AuthorityCoreWithConsent. +func (s *PostgresStore) AuthorityCore() authority.Store { return AuthorityCoreStore{s: s} } + +// AuthorityCoreWithConsent is the core's store over this database, answering +// what each module installation's consent allows (business.ModuleConsent). +func (s *PostgresStore) AuthorityCoreWithConsent(consent business.ModuleConsent) authority.Store { + return AuthorityCoreStore{s: s, consent: consent} +} + +// Read runs fn over one consistent snapshot. +func (c AuthorityCoreStore) Read(ctx context.Context, fn func(authority.Tx) error) error { + recordControlPlane(ctx) + return c.s.withControlPlaneTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(ctx context.Context) error { + return fn(coreTx{ctx: ctx, q: c.s.getQueryExecutor(ctx), consent: c.consent}) + }) +} + +// Write runs fn as one serializable transaction, retrying it on a +// serialization failure; fn is free of other effects, as the Store contract +// requires. +func (c AuthorityCoreStore) Write(ctx context.Context, fn func(authority.Tx) error) error { + recordControlPlane(ctx) + var err error + for attempt := range authorityCoreAttempts { + err = c.s.withControlPlaneTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}, func(ctx context.Context) error { + return fn(coreTx{ctx: ctx, q: c.s.getQueryExecutor(ctx), write: true, consent: c.consent}) + }) + if !serializationFailure(err) { + return err + } + time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) + } + return fmt.Errorf("%w: %w", authority.ErrConflict, err) +} + +func serializationFailure(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && (pgErr.Code == "40001" || pgErr.Code == "40P01") +} + +type coreTx struct { + ctx context.Context + q QueryExecutor + write bool + consent business.ModuleConsent +} + +// Context is the context this transaction's writes bind to: what the host's +// journal records its audit in. +func (t coreTx) Context() context.Context { return t.ctx } + +func coreMissing(what, id string) error { return fmt.Errorf("%s %q: %w", what, id, domain.ErrNotFound) } + +func (t coreTx) mustWrite() error { + if !t.write { + return errors.New("authority core: a write inside Read") + } + return nil +} + +// asUUID is id when it is a UUID; identity ids are UUIDs here, so anything +// else names nothing. +func asUUID(id string) (uuid.UUID, bool) { + parsed, err := uuid.Parse(id) + return parsed, err == nil +} + +// --- identity, read only ---------------------------------------------------- + +func (t coreTx) Organizations() ([]domain.Organization, error) { + rows, err := t.q.Query(t.ctx, `SELECT id::text, name FROM public.organizations ORDER BY id`) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Organization, error) { + var o domain.Organization + return o, row.Scan(&o.ID, &o.Name) + }) +} + +func (t coreTx) Organization(id string) (domain.Organization, error) { + org, ok := asUUID(id) + if !ok { + return domain.Organization{}, coreMissing("organization", id) + } + o := domain.Organization{ID: id} + err := t.q.QueryRow(t.ctx, `SELECT name FROM public.organizations WHERE id = $1`, org).Scan(&o.Name) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Organization{}, coreMissing("organization", id) + } + return o, err +} + +// Person answers whether id names a person of this host at all. +func (t coreTx) Person(id string) (domain.Person, error) { + user, ok := asUUID(id) + if !ok { + return domain.Person{}, coreMissing("person", id) + } + var exists bool + if err := t.q.QueryRow(t.ctx, `SELECT EXISTS (SELECT 1 FROM public.users WHERE uuid = $1)`, user).Scan(&exists); err != nil { + return domain.Person{}, err + } + if !exists { + return domain.Person{}, coreMissing("person", id) + } + return domain.Person{ID: id}, nil +} + +// Member is a person as a member of one organization: their membership role, +// their teams there, and the organization roles they hold (memberRoles). +func (t coreTx) Member(organization, id string) (domain.Person, error) { + if _, ok := asUUID(id); !ok { + // An empty or malformed id names nobody; it must never read as "every + // member" below. + return domain.Person{}, coreMissing("member of "+organization, id) + } + people, err := t.members(organization, id) + if err != nil { + return domain.Person{}, err + } + if len(people) == 0 { + return domain.Person{}, coreMissing("member of "+organization, id) + } + return people[0], nil +} + +func (t coreTx) People(organization string) ([]domain.Person, error) { + return t.members(organization, "") +} + +// members reads an organization's members, or the one person named. +func (t coreTx) members(organization, person string) ([]domain.Person, error) { + org, ok := asUUID(organization) + if !ok { + return nil, nil + } + var only *uuid.UUID + if person != "" { + user, ok := asUUID(person) + if !ok { + return nil, nil + } + only = &user + } + rows, err := t.q.Query(t.ctx, ` + SELECT m.user_id::text, m.role, + COALESCE((SELECT array_agg(tm.team_id::text ORDER BY tm.team_id) + FROM public.team_members tm JOIN public.teams tt ON tt.id = tm.team_id + WHERE tm.user_id = m.user_id AND tt.org_id = m.org_id), ARRAY[]::text[]), + COALESCE((SELECT array_agg(a.id::text ORDER BY a.id) + FROM public.role_assignments a + WHERE a.org_id = m.org_id + AND ((a.subject_kind = 'principal' AND a.subject_id = m.user_id) + OR (a.subject_kind = 'team' AND a.subject_id IN ( + SELECT tm.team_id FROM public.team_members tm JOIN public.teams tt ON tt.id = tm.team_id + WHERE tm.user_id = m.user_id AND tt.org_id = m.org_id)))), ARRAY[]::text[]), + COALESCE((SELECT array_agg(g.id::text ORDER BY g.id) + FROM public.scope_grants g + WHERE g.org_id = m.org_id AND (g.expires_at IS NULL OR g.expires_at > now()) + AND ((g.subject_kind = 'principal' AND g.subject_id = m.user_id) + OR (g.subject_kind = 'team' AND g.subject_id IN ( + SELECT tm.team_id FROM public.team_members tm JOIN public.teams tt ON tt.id = tm.team_id + WHERE tm.user_id = m.user_id AND tt.org_id = m.org_id)))), ARRAY[]::text[]) + FROM public.organization_members m + WHERE m.org_id = $1 AND ($2::uuid IS NULL OR m.user_id = $2) + ORDER BY m.user_id`, org, only) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Person, error) { + p := domain.Person{Organization: organization} + var membership string + var assignments, grants []string + if err := row.Scan(&p.ID, &membership, &p.Teams, &assignments, &grants); err != nil { + return p, err + } + p.Roles = memberRoles(membership, assignments, grants) + return p, nil + }) +} + +// The organization roles a member holds, as the core names them: the +// membership's administrator role ("owner" or "admin", every action on every +// kind the audience serves), the members' contributed permissions, and one +// role per role assignment and per live scope grant reaching them, directly +// or through a team. +const ( + membersRole = "members" + assignmentRolePrefix = "assignment:" + scopeGrantRolePrefix = "scope-grant:" +) + +func memberRoles(membership string, assignments, grants []string) []string { + var roles []string + if membership == "owner" || membership == "admin" { + roles = append(roles, membership) + } + if len(business.MemberPermissions()) > 0 { + roles = append(roles, membersRole) + } + for _, id := range assignments { + roles = append(roles, assignmentRolePrefix+id) + } + for _, id := range grants { + roles = append(roles, scopeGrantRolePrefix+id) + } + return roles +} + +// OrgRole answers one of the roles memberRoles names. +func (t coreTx) OrgRole(organization, name string) (domain.OrgRole, error) { + role := domain.OrgRole{Organization: organization, Name: name} + org, ok := asUUID(organization) + if !ok { + return role, coreMissing("role", organization+"/"+name) + } + switch { + case name == "owner" || name == "admin": + role.Scopes = []domain.RoleScope{{Kind: scope.AnyKind, Actions: []string{scope.AnyAction}}} + return role, nil + case name == membersRole: + for _, p := range business.MemberPermissions() { + role.Scopes = append(role.Scopes, domain.RoleScope{Kind: p.Resource, Actions: []string{p.Action}}) + } + return role, nil + case strings.HasPrefix(name, assignmentRolePrefix): + id, ok := asUUID(strings.TrimPrefix(name, assignmentRolePrefix)) + if !ok { + break + } + return t.grantedRole(role, ` + SELECT p.resource, p.action, a.scope + FROM public.role_assignments a JOIN public.role_permissions p ON p.role_id = a.role_id + WHERE a.org_id = $1 AND a.id = $2 + ORDER BY p.resource, p.action`, org, id) + case strings.HasPrefix(name, scopeGrantRolePrefix): + id, ok := asUUID(strings.TrimPrefix(name, scopeGrantRolePrefix)) + if !ok { + break + } + return t.grantedRole(role, ` + SELECT p.resource, p.action, COALESCE(n.resource_id, n.id::text) + FROM public.scope_grants g + JOIN public.role_permissions p ON p.role_id = g.role_id + LEFT JOIN public.scope_nodes n ON n.org_id = g.org_id AND n.scope_path = g.scope_path + WHERE g.org_id = $1 AND g.id = $2 AND (g.expires_at IS NULL OR g.expires_at > now()) + ORDER BY p.resource, p.action`, org, id) + } + return role, coreMissing("role", organization+"/"+name) +} + +// grantedRole reads a role's permissions, each narrowed to the resource its +// grant names (none: the whole kind). "*" is every kind or every action. +func (t coreTx) grantedRole(role domain.OrgRole, query string, args ...any) (domain.OrgRole, error) { + rows, err := t.q.Query(t.ctx, query, args...) + if err != nil { + return role, err + } + defer rows.Close() + found := false + for rows.Next() { + found = true + var resource, action string + var narrowed *string + if err := rows.Scan(&resource, &action, &narrowed); err != nil { + return role, err + } + sc := domain.RoleScope{Kind: resource, Actions: []string{action}} + if narrowed != nil && *narrowed != "" { + sc.Resources = []resources.Grant{{ID: *narrowed}} + } + role.Scopes = append(role.Scopes, sc) + } + if err := rows.Err(); err != nil { + return role, err + } + if !found { + return role, coreMissing("role", role.Organization+"/"+role.Name) + } + return role, nil +} + +// --- modules and tombstones ------------------------------------------------- + +// moduleDocument is a module's declarations as stored. +type moduleDocument struct { + ResourceKinds []storedScope `json:"resource_kinds,omitempty"` + Bindings map[string]storedBinding `json:"bindings,omitempty"` + Roles map[string][]storedScope `json:"roles,omitempty"` + OwnedRoles map[string][]storedScope `json:"owned_roles,omitempty"` + OwnerAware bool `json:"owner_aware,omitempty"` + Methods map[string]storedMethod `json:"methods,omitempty"` + HasRoles bool `json:"has_roles,omitempty"` + HasMethods bool `json:"has_methods,omitempty"` +} + +type storedScope struct { + Kind string `json:"kind"` + Actions []string `json:"actions,omitempty"` + IDs []string `json:"ids,omitempty"` + Owner string `json:"owner,omitempty"` +} + +type storedBinding struct { + Audience string `json:"audience"` + Invoke []storedScope `json:"invoke,omitempty"` + Headless []storedScope `json:"headless,omitempty"` + Lookup []storedScope `json:"lookup,omitempty"` +} + +type storedMethod struct { + Requires []storedScope `json:"requires,omitempty"` + AnyCheckedCaller bool `json:"any_checked_caller,omitempty"` +} + +func storeScopes(in []*basev0.WorkScopeV1) []storedScope { + out := make([]storedScope, 0, len(in)) + for _, sc := range in { + out = append(out, storedScope{Kind: sc.GetResourceKind(), Actions: slices.Clone(sc.GetActions()), IDs: slices.Clone(sc.GetResourceIds()), Owner: sc.GetResourceOwnerId()}) + } + return out +} + +func loadScopes(in []storedScope) []*basev0.WorkScopeV1 { + if in == nil { + return nil + } + out := make([]*basev0.WorkScopeV1, 0, len(in)) + for _, sc := range in { + out = append(out, &basev0.WorkScopeV1{ResourceKind: sc.Kind, Actions: slices.Clone(sc.Actions), ResourceIds: slices.Clone(sc.IDs), ResourceOwnerId: sc.Owner}) + } + return out +} + +func encodeModule(m domain.Module) ([]byte, error) { + doc := moduleDocument{ResourceKinds: storeScopes(m.ResourceKinds), OwnerAware: m.OwnerAware, HasRoles: m.Roles != nil, HasMethods: m.Methods != nil} + if len(m.Bindings) > 0 { + doc.Bindings = map[string]storedBinding{} + for id, b := range m.Bindings { + doc.Bindings[id] = storedBinding{Audience: b.Audience, Invoke: storeScopes(b.InvokeScopes), Headless: storeScopes(b.HeadlessScopes), Lookup: storeScopes(b.LookupScopes)} + } + } + if len(m.Roles) > 0 { + doc.Roles = map[string][]storedScope{} + for name, scopes := range m.Roles { + doc.Roles[name] = storeScopes(scopes) + } + } + if len(m.OwnedRoles) > 0 { + doc.OwnedRoles = map[string][]storedScope{} + for name, scopes := range m.OwnedRoles { + doc.OwnedRoles[name] = storeScopes(scopes) + } + } + if len(m.Methods) > 0 { + doc.Methods = map[string]storedMethod{} + for method, p := range m.Methods { + doc.Methods[method] = storedMethod{Requires: storeScopes(p.Requires), AnyCheckedCaller: p.AnyCheckedCaller} + } + } + return json.Marshal(doc) +} + +func decodeModule(m *domain.Module, raw []byte) error { + var doc moduleDocument + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("module %s: stored declarations do not read: %w", m.Name, err) + } + m.ResourceKinds, m.OwnerAware = loadScopes(doc.ResourceKinds), doc.OwnerAware + m.Bindings = map[string]domain.Binding{} + for id, b := range doc.Bindings { + m.Bindings[id] = domain.Binding{ID: id, Audience: b.Audience, InvokeScopes: loadScopes(b.Invoke), HeadlessScopes: loadScopes(b.Headless), LookupScopes: loadScopes(b.Lookup)} + } + if doc.HasRoles || len(doc.Roles) > 0 { + m.Roles = map[string][]*basev0.WorkScopeV1{} + for name, scopes := range doc.Roles { + m.Roles[name] = loadScopes(scopes) + } + } + m.OwnedRoles = map[string][]*basev0.WorkScopeV1{} + for name, scopes := range doc.OwnedRoles { + m.OwnedRoles[name] = loadScopes(scopes) + } + if doc.HasMethods || len(doc.Methods) > 0 { + m.Methods = map[string]domain.MethodPolicy{} + for method, p := range doc.Methods { + m.Methods[method] = domain.MethodPolicy{Requires: loadScopes(p.Requires), AnyCheckedCaller: p.AnyCheckedCaller} + } + } + return nil +} + +const coreModuleColumns = `name, principal, kind, generation, stand_in, declarations` + +func scanCoreModule(row pgx.Row) (domain.Module, error) { + var m domain.Module + var generation int64 + var raw []byte + if err := row.Scan(&m.Name, &m.Principal, &m.Kind, &generation, &m.StandIn, &raw); err != nil { + return m, err + } + m.Generation = uint64(generation) + return m, decodeModule(&m, raw) +} + +func (t coreTx) moduleWhere(clause, what, id string) (domain.Module, error) { + m, err := scanCoreModule(t.q.QueryRow(t.ctx, `SELECT `+coreModuleColumns+` FROM public.authority_core_modules WHERE `+clause, id)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Module{}, coreMissing(what, id) + } + return m, err +} + +func (t coreTx) Module(principal string) (domain.Module, error) { + return t.moduleWhere(`principal = $1`, "audience", principal) +} + +func (t coreTx) ModuleNamed(name string) (domain.Module, error) { + return t.moduleWhere(`name = $1`, "module", name) +} + +func (t coreTx) Modules() ([]domain.Module, error) { + rows, err := t.q.Query(t.ctx, `SELECT `+coreModuleColumns+` FROM public.authority_core_modules ORDER BY name`) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Module, error) { return scanCoreModule(row) }) +} + +func (t coreTx) PutModule(m domain.Module) error { + if err := t.mustWrite(); err != nil { + return err + } + raw, err := encodeModule(m) + if err != nil { + return err + } + _, err = t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_modules (name, principal, kind, generation, stand_in, declarations) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (name) DO UPDATE SET principal = EXCLUDED.principal, kind = EXCLUDED.kind, generation = EXCLUDED.generation, + stand_in = EXCLUDED.stand_in, declarations = EXCLUDED.declarations`, + m.Name, m.Principal, m.Kind, int64(m.Generation), m.StandIn, raw) + return err +} + +func (t coreTx) DeleteModule(name string) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, `DELETE FROM public.authority_core_modules WHERE name = $1`, name) + return err +} + +func (t coreTx) tombstoneWhere(clause, what, id string) (domain.Tombstone, error) { + var tomb domain.Tombstone + var generation int64 + err := t.q.QueryRow(t.ctx, `SELECT name, principal, generation FROM public.authority_core_tombstones WHERE `+clause, id).Scan(&tomb.Name, &tomb.Principal, &generation) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Tombstone{}, coreMissing(what, id) + } + tomb.Generation = uint64(generation) + return tomb, err +} + +func (t coreTx) Tombstone(name string) (domain.Tombstone, error) { + return t.tombstoneWhere(`name = $1`, "tombstone", name) +} + +func (t coreTx) TombstoneByPrincipal(principal string) (domain.Tombstone, error) { + return t.tombstoneWhere(`principal = $1`, "tombstone of principal", principal) +} + +func (t coreTx) PutTombstone(tomb domain.Tombstone) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_tombstones (name, principal, generation) VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE SET principal = EXCLUDED.principal, generation = EXCLUDED.generation`, + tomb.Name, tomb.Principal, int64(tomb.Generation)) + return err +} + +// --- installations and role grants ------------------------------------------ + +const coreInstallationColumns = `id, organization_id::text, target, status, revision, agent_role, principal_id, installed_by, consent, owned` + +func scanCoreInstallation(row pgx.Row) (domain.Installation, error) { + var inst domain.Installation + var status string + var revision int64 + var consent, owned []byte + if err := row.Scan(&inst.ID, &inst.Organization, &inst.Target, &status, &revision, &inst.AgentRole, &inst.PrincipalID, &inst.InstalledBy, &consent, &owned); err != nil { + return inst, err + } + inst.Status, inst.Revision = domain.Status(status), uint64(revision) + if err := json.Unmarshal(consent, &inst.Consent); err != nil { + return inst, fmt.Errorf("installation %s: stored consent does not read: %w", inst.ID, err) + } + inst.Owned = map[string][]string{} + if err := json.Unmarshal(owned, &inst.Owned); err != nil { + return inst, fmt.Errorf("installation %s: stored attachments do not read: %w", inst.ID, err) + } + return inst, nil +} + +// A module's installation is accounts' own record (public.installations, +// kind 'module'): an administrator's consent within the module envelope, +// retired by an uninstall or by a ceiling that no longer allows the +// organization, all in accounts' own transactions. The core is configured with +// HostInstallsModules, so it reads those records as its installations, live, +// and writes a module's only to raise its revision when a redelivery narrows +// the module; the raise is kept beside the record +// (authority_core_module_revisions). authority_core_installations holds the +// solutions' installations, which the core decides. + +// hostModules maps the principal accounts records a module's installation +// under (business.ModulePrincipalID of its name) to every composed module. +func (t coreTx) hostModules() (map[uuid.UUID]string, error) { + rows, err := t.q.Query(t.ctx, `SELECT name FROM public.authority_core_modules WHERE kind <> 'solution'`) + if err != nil { + return nil, err + } + names, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return nil, err + } + out := make(map[uuid.UUID]string, len(names)) + for _, name := range names { + principal, err := uuid.Parse(business.ModulePrincipalID(name)) + if err != nil { + return nil, err + } + out[principal] = name + } + return out, nil +} + +// moduleInstallations are accounts' module installation records of composed +// modules matching where (with its arguments from $2), as the core reads them. +func (t coreTx) moduleInstallations(where string, args ...any) ([]domain.Installation, error) { + modules, err := t.hostModules() + if err != nil || len(modules) == 0 { + return nil, err + } + principals := make([]uuid.UUID, 0, len(modules)) + for principal := range modules { + principals = append(principals, principal) + } + // The committed envelope revision, read in this transaction, so the + // consent is judged against the ceiling in force and never a registry a + // newer envelope has replaced. + var envelope uint64 + if t.consent != nil { + var committed int64 + if err := t.q.QueryRow(t.ctx, `SELECT COALESCE(max(revision), 0) FROM public.module_envelopes`).Scan(&committed); err != nil { + return nil, err + } + envelope = uint64(committed) + } + rows, err := t.q.Query(t.ctx, ` + SELECT i.id, i.org_id, i.registry_principal_id, i.status, i.revision + COALESCE(r.raised, 0), i.scope + FROM public.installations i + LEFT JOIN public.authority_core_module_revisions r ON r.installation_id = i.id + WHERE i.kind = 'module' AND i.registry_principal_id = ANY($1)`+where+` + ORDER BY i.id`, append([]any{principals}, args...)...) + if err != nil { + return nil, err + } + type record struct { + inst domain.Installation + scope []byte + } + records, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (record, error) { + var id, org, principal uuid.UUID + var status string + var revision int64 + var consented []byte + if err := row.Scan(&id, &org, &principal, &status, &revision, &consented); err != nil { + return record{}, err + } + // The revision the core seals to and checks moves with everything the + // consent's bindings are decided from: accounts' own revision (the + // consent, its scope, an uninstall), the core's raise on a narrowing + // redelivery, and the committed envelope revision, so an envelope + // write ends every capability decided under the ceiling it replaced. + return record{inst: domain.Installation{ID: id.String(), Organization: org.String(), Target: modules[principal], + Status: moduleInstallationStatus(status), Revision: uint64(revision) + envelope, Owned: map[string][]string{}}, scope: consented}, nil + }) + if err != nil { + return nil, err + } + out := make([]domain.Installation, 0, len(records)) + for _, r := range records { + // What the administrator consented to, within the module envelope: + // an installation whose consent allows no capability is not one the + // core may issue through, and the bindings it allows bound every + // issue through them (domain.Installation.Bindings). + if t.consent != nil { + bindings, active, err := t.consent(r.inst.Target, r.scope, envelope) + if err != nil { + return nil, err + } + if !active && r.inst.Status == domain.Active { + r.inst.Status = domain.Pending + } + r.inst.Bindings = bindings + } + out = append(out, r.inst) + } + return out, nil +} + +// moduleInstallationStatus is how the core reads accounts' lifecycle: only an +// active installation is one; consent awaiting its install step, or one that +// failed it, is pending. +func moduleInstallationStatus(status string) domain.Status { + switch status { + case "active": + return domain.Active + case "revoked": + return domain.Revoked + default: + return domain.Pending + } +} + +func (t coreTx) Installation(id string) (domain.Installation, error) { + inst, err := scanCoreInstallation(t.q.QueryRow(t.ctx, `SELECT `+coreInstallationColumns+` FROM public.authority_core_installations WHERE id = $1`, id)) + if !errors.Is(err, pgx.ErrNoRows) { + return inst, err + } + if native, ok := asUUID(id); ok { + found, err := t.moduleInstallations(` AND i.id = $2`, native) + if err != nil { + return domain.Installation{}, err + } + if len(found) == 1 { + return found[0], nil + } + } + return domain.Installation{}, coreMissing("installation", id) +} + +func (t coreTx) InstallationByPrincipal(principal string) (domain.Installation, error) { + if principal == "" { + return domain.Installation{}, coreMissing("principal", principal) + } + inst, err := scanCoreInstallation(t.q.QueryRow(t.ctx, `SELECT `+coreInstallationColumns+` FROM public.authority_core_installations WHERE principal_id = $1`, principal)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Installation{}, coreMissing("principal", principal) + } + return inst, err +} + +// Installations are the solutions' installations the core decided and the +// module installations accounts holds now (a retired one is read by its id). +func (t coreTx) Installations(organization string) ([]domain.Installation, error) { + // A module's row the core wrote before accounts held its modules' records + // is not an installation any more. + const solutions = `SELECT ` + coreInstallationColumns + ` FROM public.authority_core_installations + WHERE target NOT IN (SELECT name FROM public.authority_core_modules WHERE kind <> 'solution')` + var rows pgx.Rows + var modules []domain.Installation + var err error + if organization == "" { + if modules, err = t.moduleInstallations(` AND i.status <> 'revoked'`); err == nil { + rows, err = t.q.Query(t.ctx, solutions+` ORDER BY id`) + } + } else { + org, ok := asUUID(organization) + if !ok { + return nil, nil + } + if modules, err = t.moduleInstallations(` AND i.status <> 'revoked' AND i.org_id = $2`, org); err == nil { + rows, err = t.q.Query(t.ctx, solutions+` AND organization_id = $1 ORDER BY id`, org) + } + } + if err != nil { + return nil, err + } + decided, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Installation, error) { return scanCoreInstallation(row) }) + if err != nil { + return nil, err + } + all := append(decided, modules...) + sort.Slice(all, func(a, b int) bool { return all[a].ID < all[b].ID }) + return all, nil +} + +func (t coreTx) PutInstallation(inst domain.Installation) error { + if err := t.mustWrite(); err != nil { + return err + } + if handled, err := t.raiseModuleInstallation(inst); handled || err != nil { + return err + } + org, ok := asUUID(inst.Organization) + if !ok { + return fmt.Errorf("installation %s names organization %q, which is not one of this host's", inst.ID, inst.Organization) + } + consent, err := json.Marshal(inst.Consent) + if err != nil { + return err + } + owned := map[string][]string{} + for kind, ids := range inst.Owned { + if len(ids) > 0 { + owned[kind] = ids + } + } + ownedRaw, err := json.Marshal(owned) + if err != nil { + return err + } + _, err = t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_installations (id, organization_id, target, status, revision, agent_role, principal_id, installed_by, consent, owned) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET organization_id = EXCLUDED.organization_id, target = EXCLUDED.target, status = EXCLUDED.status, + revision = EXCLUDED.revision, agent_role = EXCLUDED.agent_role, principal_id = EXCLUDED.principal_id, + installed_by = EXCLUDED.installed_by, consent = EXCLUDED.consent, owned = EXCLUDED.owned`, + inst.ID, org, inst.Target, string(inst.Status), int64(inst.Revision), inst.AgentRole, inst.PrincipalID, inst.InstalledBy, consent, ownedRaw) + return err +} + +// raiseModuleInstallation applies the one write the core makes to a module's +// installation: raising its revision, when a redelivery narrows the module. +// It reports whether inst is a module's installation at all; any other change +// to one is refused, because the record is accounts' own. +func (t coreTx) raiseModuleInstallation(inst domain.Installation) (bool, error) { + var kind string + err := t.q.QueryRow(t.ctx, `SELECT kind FROM public.authority_core_modules WHERE name = $1`, inst.Target).Scan(&kind) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && kind == domain.KindSolution) { + return false, nil + } + if err != nil { + return true, err + } + current, err := t.Installation(inst.ID) + if err != nil { + return true, fmt.Errorf("module installation %s is not one accounts holds: %w", inst.ID, err) + } + if current.Organization != inst.Organization || current.Target != inst.Target || current.Status != inst.Status || + inst.AgentRole != "" || inst.PrincipalID != "" || inst.InstalledBy != "" || len(inst.Owned) > 0 || inst.Revision <= current.Revision { + return true, fmt.Errorf("module installation %s is accounts' own record: the authority core only raises its revision", inst.ID) + } + _, err = t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_module_revisions (installation_id, raised) VALUES ($1, $2) + ON CONFLICT (installation_id) DO UPDATE SET raised = public.authority_core_module_revisions.raised + EXCLUDED.raised`, + inst.ID, int64(inst.Revision-current.Revision)) + return true, err +} + +func (t coreTx) RoleGrants(installationID string) ([]domain.RoleGrant, error) { + rows, err := t.q.Query(t.ctx, `SELECT role, person_id, team_id FROM public.authority_core_role_grants WHERE installation_id = $1 ORDER BY role, person_id, team_id`, installationID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.RoleGrant, error) { + g := domain.RoleGrant{InstallationID: installationID} + return g, row.Scan(&g.Role, &g.PersonID, &g.TeamID) + }) +} + +func (t coreTx) PutRoleGrant(g domain.RoleGrant) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_role_grants (installation_id, role, person_id, team_id) VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING`, g.InstallationID, g.Role, g.PersonID, g.TeamID) + return err +} + +func (t coreTx) DeleteRoleGrant(g domain.RoleGrant) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + DELETE FROM public.authority_core_role_grants WHERE installation_id = $1 AND role = $2 AND person_id = $3 AND team_id = $4`, + g.InstallationID, g.Role, g.PersonID, g.TeamID) + return err +} + +func (t coreTx) DeleteRoleGrants(installationID string) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, `DELETE FROM public.authority_core_role_grants WHERE installation_id = $1`, installationID) + return err +} + +// --- revisions and epochs --------------------------------------------------- + +// AuthorizationRevision is accounts' own organization revision: every RBAC +// change accounts records moves it, and so does every bump the core makes. +func (t coreTx) AuthorizationRevision(organization string) (uint64, error) { + org, ok := asUUID(organization) + if !ok { + return 0, coreMissing("organization", organization) + } + var revision *int64 + err := t.q.QueryRow(t.ctx, ` + SELECT r.revision FROM public.organizations o + LEFT JOIN public.organization_authorization_revisions r ON r.org_id = o.id + WHERE o.id = $1`, org).Scan(&revision) + if errors.Is(err, pgx.ErrNoRows) { + return 0, coreMissing("organization", organization) + } + if err != nil { + return 0, err + } + if revision == nil || *revision <= 0 { + return 0, fmt.Errorf("organization %s has no authorization revision", organization) + } + return uint64(*revision), nil +} + +func (t coreTx) BumpAuthorizationRevision(organization string) error { + if err := t.mustWrite(); err != nil { + return err + } + org, ok := asUUID(organization) + if !ok { + return coreMissing("organization", organization) + } + _, err := t.q.Exec(t.ctx, `SELECT public.bump_organization_authorization_revision($1)`, org) + return err +} + +// PersonEpoch moves when the core is told a person changed, and whenever +// accounts moves the person's own authorization revision in any of their +// organizations: the sum only grows, because each term only grows. +func (t coreTx) PersonEpoch(person string) (uint64, error) { + var epoch int64 + user, isUser := asUUID(person) + var principal *uuid.UUID + if isUser { + principal = &user + } + err := t.q.QueryRow(t.ctx, ` + SELECT COALESCE((SELECT epoch FROM public.authority_core_person_epochs WHERE person_id = $1), 0) + + COALESCE((SELECT sum(revision) FROM public.principal_authorization_revisions WHERE $2::uuid IS NOT NULL AND principal_id = $2), 0) + + 1`, person, principal).Scan(&epoch) + return uint64(epoch), err +} + +func (t coreTx) BumpPersonEpoch(person string) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_person_epochs (person_id, epoch) VALUES ($1, 1) + ON CONFLICT (person_id) DO UPDATE SET epoch = public.authority_core_person_epochs.epoch + 1`, person) + return err +} + +func (t coreTx) GrantEpoch(person, installationID string) (uint64, error) { + var epoch int64 + err := t.q.QueryRow(t.ctx, `SELECT COALESCE((SELECT epoch FROM public.authority_core_grant_epochs WHERE person_id = $1 AND installation_id = $2), 0)`, + person, installationID).Scan(&epoch) + return uint64(epoch), err +} + +func (t coreTx) BumpGrantEpoch(person, installationID string) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_grant_epochs (person_id, installation_id, epoch) VALUES ($1, $2, 1) + ON CONFLICT (person_id, installation_id) DO UPDATE SET epoch = public.authority_core_grant_epochs.epoch + 1`, person, installationID) + return err +} + +func (t coreTx) OwnerAwareRevision(audience string) (uint64, error) { + var revision int64 + err := t.q.QueryRow(t.ctx, `SELECT COALESCE((SELECT revision FROM public.authority_core_owner_aware WHERE audience = $1), 0)`, audience).Scan(&revision) + return uint64(revision), err +} + +func (t coreTx) BumpOwnerAwareRevision(audience string) error { + if err := t.mustWrite(); err != nil { + return err + } + _, err := t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_owner_aware (audience, revision) VALUES ($1, 1) + ON CONFLICT (audience) DO UPDATE SET revision = public.authority_core_owner_aware.revision + 1`, audience) + return err +} + +// --- delegations ------------------------------------------------------------ + +const coreDelegationColumns = `id, organization_id::text, grantor, module, purpose, scopes, revoked` + +func scanCoreDelegation(row pgx.Row) (domain.Delegation, error) { + var d domain.Delegation + var raw []byte + if err := row.Scan(&d.ID, &d.Organization, &d.Grantor, &d.Module, &d.Purpose, &raw, &d.Revoked); err != nil { + return d, err + } + var scopes []storedScope + if err := json.Unmarshal(raw, &scopes); err != nil { + return d, fmt.Errorf("delegation %s: stored scopes do not read: %w", d.ID, err) + } + d.Scopes = loadScopes(scopes) + return d, nil +} + +func (t coreTx) Delegation(id string) (domain.Delegation, error) { + d, err := scanCoreDelegation(t.q.QueryRow(t.ctx, `SELECT `+coreDelegationColumns+` FROM public.authority_core_delegations WHERE id = $1`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Delegation{}, coreMissing("delegation", id) + } + return d, err +} + +func (t coreTx) Delegations() ([]domain.Delegation, error) { + rows, err := t.q.Query(t.ctx, `SELECT `+coreDelegationColumns+` FROM public.authority_core_delegations ORDER BY id`) + if err != nil { + return nil, err + } + out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Delegation, error) { return scanCoreDelegation(row) }) + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out, err +} + +func (t coreTx) PutDelegation(d domain.Delegation) error { + if err := t.mustWrite(); err != nil { + return err + } + org, ok := asUUID(d.Organization) + if !ok { + return fmt.Errorf("delegation %s names organization %q, which is not one of this host's", d.ID, d.Organization) + } + raw, err := json.Marshal(storeScopes(d.Scopes)) + if err != nil { + return err + } + _, err = t.q.Exec(t.ctx, ` + INSERT INTO public.authority_core_delegations (id, organization_id, grantor, module, purpose, scopes, revoked) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET organization_id = EXCLUDED.organization_id, grantor = EXCLUDED.grantor, module = EXCLUDED.module, + purpose = EXCLUDED.purpose, scopes = EXCLUDED.scopes, revoked = EXCLUDED.revoked`, + d.ID, org, d.Grantor, d.Module, d.Purpose, raw, d.Revoked) + return err +} + +// --- single-use nonces ------------------------------------------------------ + +// AuthorityCoreReplay is core's replay store for the capabilities the core +// issues: one row per consumed nonce, shared by every replica. +func (s *PostgresStore) AuthorityCoreReplay() workcontext.ReplayStore { + return AuthorityCoreReplay{s: s} +} + +// AuthorityCoreReplay consumes a nonce once across every replica, judged by +// the database's clock; an expired consumption is reclaimed by the next one. +type AuthorityCoreReplay struct{ s *PostgresStore } + +// Consume records nonce as spent until retainUntil, refusing it the second time. +func (r AuthorityCoreReplay) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + recordControlPlane(ctx) + return r.s.withControlPlaneTx(ctx, pgx.TxOptions{}, func(ctx context.Context) error { + q := r.s.getQueryExecutor(ctx) + if _, err := q.Exec(ctx, `DELETE FROM public.authority_core_replay WHERE nonce = $1 AND retain_until <= now()`, nonce); err != nil { + return err + } + tag, err := q.Exec(ctx, `INSERT INTO public.authority_core_replay (nonce, retain_until) VALUES ($1, $2) ON CONFLICT (nonce) DO NOTHING`, nonce, retainUntil) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: nonce %s", workcontext.ErrReplayed, nonce) + } + return nil + }) +} + +// PurgeExpiredAuthorityReplays deletes the consumptions whose capability has expired. +func (r AuthorityCoreReplay) PurgeExpiredAuthorityReplays(ctx context.Context) (int64, error) { + var purged int64 + err := r.s.WithControlPlane(ctx, func(ctx context.Context) error { + tag, err := r.s.getQueryExecutor(ctx).Exec(ctx, `DELETE FROM public.authority_core_replay WHERE retain_until <= now()`) + purged = tag.RowsAffected() + return err + }) + return purged, err +} diff --git a/module/services/accounts/code/pkg/infra/postgres_authority_core_test.go b/module/services/accounts/code/pkg/infra/postgres_authority_core_test.go new file mode 100644 index 000000000..9074f6a00 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_authority_core_test.go @@ -0,0 +1,164 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + "github.com/codefly-dev/authority/libraries/authority-core/go/storetest" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "accounts/pkg/infra/storetx" +) + +// The authority core's store over accounts' Postgres meets the contract every +// host's store meets (codefly-dev/authority storetest), against a real +// database: one transaction is all or nothing, every record reads back as +// written, revisions only grow, and identity is read from accounts' own +// relations: memberships, teams, and organization roles synthesized from role +// assignments with or without a resource scope. +func TestTheAuthorityCoreStoreMeetsTheStoreContract(t *testing.T) { + storetest.Run(t, func(t *testing.T, id storetest.Identity) authority.Store { + seedAuthorityIdentity(t, id) + return testStore.AuthorityCore() + }) +} + +// seedAuthorityIdentity writes id into accounts' relations as accounts' +// own paths would: an organization with its owner, members with their +// membership role, teams, and one role per member whose permissions are the +// member's scopes, assigned with the resource each names (or none). +func seedAuthorityIdentity(t *testing.T, id storetest.Identity) { + t.Helper() + owners := map[string]string{} + for _, m := range id.Members { + if _, ok := owners[m.Organization]; !ok { + owners[m.Organization] = m.Person + } + } + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + q := storetx.Tx(ctx) + people := map[string]bool{} + for _, m := range id.Members { + if people[m.Person] { + continue + } + people[m.Person] = true + if _, err := q.Exec(ctx, `INSERT INTO users (uuid, primary_email, status, profile) VALUES ($1, $2, 'active', '{}'::jsonb)`, + m.Person, "storetest-"+m.Person+"@example.com"); err != nil { + return err + } + } + for _, o := range id.Organizations { + slug := "storetest-" + strings.ToLower(strings.ReplaceAll(o.ID, "-", ""))[:20] + if _, err := q.Exec(ctx, `INSERT INTO organizations (id, name, slug, owner_id) VALUES ($1, $2, $3, $4)`, o.ID, o.Name, slug, owners[o.ID]); err != nil { + return err + } + } + teams := map[string]bool{} + for _, m := range id.Members { + role := "member" + if m.Administrator { + role = "admin" + } + if _, err := q.Exec(ctx, `INSERT INTO organization_members (org_id, user_id, role) VALUES ($1, $2, $3)`, m.Organization, m.Person, role); err != nil { + return err + } + for _, team := range m.Teams { + if !teams[team] { + teams[team] = true + slug := "t" + strings.ReplaceAll(team, "-", "")[:12] + if _, err := q.Exec(ctx, `INSERT INTO teams (id, org_id, name, slug, path) VALUES ($1, $2, $3, $4, $4)`, team, m.Organization, "Storetest "+team, slug); err != nil { + return err + } + } + if _, err := q.Exec(ctx, `INSERT INTO team_members (team_id, user_id, org_id) VALUES ($1, $2, $3)`, team, m.Person, m.Organization); err != nil { + return err + } + } + for _, sc := range m.Scopes { + roleID := uuid.NewString() + if _, err := q.Exec(ctx, `INSERT INTO roles (id, org_id, name, description) VALUES ($1, $2, $3, '')`, roleID, m.Organization, "storetest "+roleID[:8]); err != nil { + return err + } + for _, action := range sc.Actions { + if _, err := q.Exec(ctx, `INSERT INTO role_permissions (role_id, resource, action) VALUES ($1, $2, $3)`, roleID, sc.Kind, action); err != nil { + return err + } + } + var resource *string + if len(sc.Resources) > 0 { + resource = &sc.Resources[0].ID + } + if _, err := q.Exec(ctx, `INSERT INTO role_assignments (subject_id, subject_kind, role_id, org_id, scope) VALUES ($1, 'principal', $2, $3, $4)`, + m.Person, roleID, m.Organization, resource); err != nil { + return err + } + } + } + return nil + })) +} + +// An organization administrator holds every action on every kind an audience +// serves (accounts' RBAC: an admin or owner is allowed every permission), and +// a person's roles through a team are theirs while they are on it. +func TestTheAuthorityCoreStoreReadsAccountsRBAC(t *testing.T) { + org, admin, member, team := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + seedAuthorityIdentity(t, storetest.Identity{ + Organizations: []domain.Organization{{ID: org, Name: "RBAC " + org[:8]}}, + Members: []storetest.Member{ + {Organization: org, Person: admin, Administrator: true}, + {Organization: org, Person: member, Teams: []string{team}}, + }, + }) + roleID := uuid.NewString() + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + q := storetx.Tx(ctx) + if _, err := q.Exec(ctx, `INSERT INTO roles (id, org_id, name, description) VALUES ($1, $2, 'team reader', '')`, roleID, org); err != nil { + return err + } + if _, err := q.Exec(ctx, `INSERT INTO role_permissions (role_id, resource, action) VALUES ($1, 'rbac.item', 'read')`, roleID); err != nil { + return err + } + _, err := q.Exec(ctx, `INSERT INTO role_assignments (subject_id, subject_kind, role_id, org_id) VALUES ($1, 'team', $2, $3)`, team, roleID, org) + return err + })) + require.NoError(t, testStore.AuthorityCore().Read(testCtx, func(tx authority.Tx) error { + a, err := tx.Member(org, admin) + require.NoError(t, err) + require.Contains(t, a.Roles, "admin") + role, err := tx.OrgRole(org, "admin") + require.NoError(t, err) + require.Equal(t, []domain.RoleScope{{Kind: scope.AnyKind, Actions: []string{scope.AnyAction}}}, role.Scopes) + + m, err := tx.Member(org, member) + require.NoError(t, err) + require.Equal(t, []string{team}, m.Teams) + var held []domain.RoleScope + for _, name := range m.Roles { + r, err := tx.OrgRole(org, name) + require.NoError(t, err) + held = append(held, r.Scopes...) + } + require.Equal(t, []domain.RoleScope{{Kind: "rbac.item", Actions: []string{"read"}}}, held, "the team's role is the member's") + return nil + })) + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + _, err := storetx.Tx(ctx).Exec(ctx, `DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, team, member) + return err + })) + require.NoError(t, testStore.AuthorityCore().Read(testCtx, func(tx authority.Tx) error { + m, err := tx.Member(org, member) + require.NoError(t, err) + require.Empty(t, m.Teams) + require.Empty(t, m.Roles, "a person off the team holds none of its roles") + return nil + })) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_derived_module_access.go b/module/services/accounts/code/pkg/infra/postgres_derived_module_access.go index 098159be1..888132efc 100644 --- a/module/services/accounts/code/pkg/infra/postgres_derived_module_access.go +++ b/module/services/accounts/code/pkg/infra/postgres_derived_module_access.go @@ -142,11 +142,11 @@ func (s *PostgresStore) EnsureDerivedRole(ctx context.Context, role business.Der // team's grant on it, and knows its root as the node id the installation row // carries, so nothing has to resolve a path from a row it just revoked. // -// Ordered so a revocation's policy-log entries and events are in a fixed order +// Ordered so a revocation's events are in a fixed order // rather than the order Postgres happened to return. // // Every relation is named with orgID explicitly, the cause and the node included. -// The uninstall path runs this under the policy log's CONTROL-PLANE transaction, +// The uninstall path runs this under a narrowing's CONTROL-PLANE transaction, // where there is no RLS floor to fall back on, and the predicate the tenant // policy would have applied has to be in the statement instead. func (s *PostgresStore) DerivedScopeGrants(ctx context.Context, orgID, causeGrantID, causeScopeNodeID string) ([]*gen.ScopeGrant, error) { diff --git a/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys.go b/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys.go index e9368700b..ed52442ba 100644 --- a/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys.go +++ b/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys.go @@ -23,7 +23,7 @@ import ( // already published, and answers whether this call began publishing it. Registrations are serialised by one transaction-scoped // advisory lock, so two replicas registering different keys at the bound // cannot both pass the count. -func (s *PostgresStore) RegisterAdmissionKey(ctx context.Context, key business.AdmissionKey, lease time.Duration, max int) (time.Time, bool, error) { +func (s *PostgresStore) RegisterAdmissionKey(ctx context.Context, key business.AdmissionKey, registration business.AdmissionRegistration, lease time.Duration, max int) (time.Time, bool, error) { q := s.getQueryExecutor(ctx) if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('hop-admission-keys',0))`); err != nil { return time.Time{}, false, err @@ -31,6 +31,28 @@ func (s *PostgresStore) RegisterAdmissionKey(ctx context.Context, key business.A if _, err := q.Exec(ctx, `DELETE FROM public.hop_admission_keys WHERE expires_at <= now()`); err != nil { return time.Time{}, false, err } + if _, err := q.Exec(ctx, `DELETE FROM public.hop_admission_proofs WHERE expires_at <= now()`); err != nil { + return time.Time{}, false, err + } + // The proof's nonce is used once: a second registration carrying it, on + // any replica, finds it here. + tag, err := q.Exec(ctx, `INSERT INTO public.hop_admission_proofs (nonce, expires_at) VALUES ($1, $2) ON CONFLICT (nonce) DO NOTHING`, + registration.Nonce, registration.NonceUntil) + if err != nil { + return time.Time{}, false, err + } + if tag.RowsAffected() != 1 { + return time.Time{}, false, fmt.Errorf("%w: the registration proof was already used: sign a fresh one for each registration", business.ErrAdmissionKeyProof) + } + // One key per hop instance until its lease lapses. + var pinned string + switch err := q.QueryRow(ctx, `SELECT key_id FROM public.hop_admission_keys WHERE instance = $1`, registration.Instance).Scan(&pinned); { + case err == nil && pinned != key.ID: + return time.Time{}, false, fmt.Errorf("%w: hop instance %q holds admission key %s until it lapses; another key (%s) is refused for it until then, and a new key comes from a new hop instance", + business.ErrAdmissionKeyPinned, registration.Instance, pinned, key.ID) + case err != nil && !errors.Is(err, pgx.ErrNoRows): + return time.Time{}, false, err + } var ( others int renewed bool @@ -50,16 +72,25 @@ func (s *PostgresStore) RegisterAdmissionKey(ctx context.Context, key business.A ) // xmax is zero on a row this statement inserted and set on one it updated, // so it tells a first publication from a renewal. - err := q.QueryRow(ctx, ` - INSERT INTO public.hop_admission_keys (key_id, public_key, expires_at) - VALUES ($1, $2, now() + make_interval(secs => $3)) - ON CONFLICT (key_id) DO UPDATE SET expires_at = EXCLUDED.expires_at + err = q.QueryRow(ctx, ` + INSERT INTO public.hop_admission_keys (key_id, public_key, expires_at, instance) + VALUES ($1, $2, now() + make_interval(secs => $3), $4) + ON CONFLICT (key_id) DO UPDATE SET expires_at = EXCLUDED.expires_at, instance = EXCLUDED.instance WHERE public.hop_admission_keys.public_key = EXCLUDED.public_key - RETURNING expires_at, xmax = 0`, key.ID, []byte(key.PublicKey), lease.Seconds()).Scan(&expires, &published) + AND (public.hop_admission_keys.instance IS NULL OR public.hop_admission_keys.instance = EXCLUDED.instance) + RETURNING expires_at, xmax = 0`, key.ID, []byte(key.PublicKey), lease.Seconds(), registration.Instance).Scan(&expires, &published) if errors.Is(err, pgx.ErrNoRows) { - // No row means the id is published for another public key: the id is - // derived from the key, so this is a collision, never a renewal. - return time.Time{}, false, fmt.Errorf("%w: key id %s is published for another public key", business.ErrAdmissionKeyMalformed, key.ID) + // No row: the id is published for another public key (the id is + // derived from the key, so a collision, never a renewal), or another + // hop instance holds this key. + var samePublic bool + if err := q.QueryRow(ctx, `SELECT public_key = $2 FROM public.hop_admission_keys WHERE key_id = $1`, key.ID, []byte(key.PublicKey)).Scan(&samePublic); err != nil { + return time.Time{}, false, err + } + if !samePublic { + return time.Time{}, false, fmt.Errorf("%w: key id %s is published for another public key", business.ErrAdmissionKeyMalformed, key.ID) + } + return time.Time{}, false, fmt.Errorf("%w: key %s is held by another hop instance until it lapses", business.ErrAdmissionKeyPinned, key.ID) } if err != nil { return time.Time{}, false, err diff --git a/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys_test.go b/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys_test.go index 4e9112e96..38a3225ca 100644 --- a/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_hop_admission_keys_test.go @@ -3,9 +3,13 @@ package infra_test import ( + "github.com/codefly-dev/authority/libraries/authority-client/go/stamp" + "context" "crypto/ed25519" + "crypto/rand" "crypto/sha256" + "encoding/hex" "os" "slices" "testing" @@ -21,7 +25,7 @@ func admissionKey(t *testing.T, label string) business.AdmissionKey { t.Helper() seed := sha256.Sum256([]byte(label)) public := ed25519.NewKeyFromSeed(seed[:]).Public().(ed25519.PublicKey) - return business.AdmissionKey{ID: business.AdmissionKeyID(public), PublicKey: public} + return business.AdmissionKey{ID: stamp.KeyID(public), PublicKey: public} } func registerAdmissionKey(store *infra.PostgresStore, key business.AdmissionKey, lease time.Duration, max int) (time.Time, error) { @@ -36,7 +40,7 @@ func registerAdmissionKeyPublished(store *infra.PostgresStore, key business.Admi ) err := store.WithControlPlane(testCtx, func(ctx context.Context) error { var err error - expires, published, err = store.RegisterAdmissionKey(ctx, key, lease, max) + expires, published, err = store.RegisterAdmissionKey(ctx, key, freshRegistration(key.ID), lease, max) return err }) return expires, published, err @@ -143,7 +147,8 @@ func TestTheAdmissionKeyBoundHoldsAcrossReplicas(t *testing.T) { finished bool ) require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - if _, _, err := testStore.RegisterAdmissionKey(ctx, admissionKey(t, "racing-a"), business.AdmissionKeyLease, bound); err != nil { + racer := admissionKey(t, "racing-a") + if _, _, err := testStore.RegisterAdmissionKey(ctx, racer, freshRegistration(racer.ID), business.AdmissionKeyLease, bound); err != nil { return err } go func() { @@ -163,3 +168,31 @@ func TestTheAdmissionKeyBoundHoldsAcrossReplicas(t *testing.T) { _, err := registerAdmissionKey(replica, held, business.AdmissionKeyLease, bound) require.NoError(t, err, "a registered key renews at the bound") } + +// freshRegistration is one registration of a key by its own hop instance, +// with a nonce never used. +func freshRegistration(instance string) business.AdmissionRegistration { + nonce := make([]byte, 32) + _, _ = rand.Read(nonce) + return business.AdmissionRegistration{Instance: instance, Nonce: hex.EncodeToString(nonce), NonceUntil: time.Now().Add(time.Minute)} +} + +// A proof's nonce is used once across replicas, and a hop instance keeps the +// one key it registered while that registration holds. +func TestARegistrationNonceIsUsedOnceAndAnInstanceKeepsOneKeyPostgres(t *testing.T) { + register := func(key business.AdmissionKey, registration business.AdmissionRegistration) error { + return testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + _, _, err := testStore.RegisterAdmissionKey(ctx, key, registration, business.AdmissionKeyLease, 1000) + return err + }) + } + first, second := admissionKey(t, "pinned-first"), admissionKey(t, "pinned-second") + registration := freshRegistration("hop-abcd") + require.NoError(t, register(first, registration)) + require.ErrorIs(t, register(first, registration), business.ErrAdmissionKeyProof, "a nonce is used once") + require.NoError(t, register(first, freshRegistration("hop-abcd")), "the same key renews with a fresh proof") + require.ErrorIs(t, register(second, freshRegistration("hop-abcd")), business.ErrAdmissionKeyPinned, "another key for the instance") + require.ErrorIs(t, register(first, freshRegistration("hop-efgh")), business.ErrAdmissionKeyPinned, "the key another instance holds") + require.NoError(t, register(second, freshRegistration("hop-efgh")), "a new key from a new instance") + require.Subset(t, publishedAdmissionKeys(t, testStore), []string{first.ID, second.ID}) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_module_delegations.go b/module/services/accounts/code/pkg/infra/postgres_module_delegations.go deleted file mode 100644 index 7ec132759..000000000 --- a/module/services/accounts/code/pkg/infra/postgres_module_delegations.go +++ /dev/null @@ -1,123 +0,0 @@ -package infra - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "accounts/pkg/business" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// Module delegations (migration 51). The tenant-facing statements run inside -// the organization's transaction, so its policy scopes them; a module's lookup -// by id runs under the control plane, whose policy does not, and returns the -// organization for the caller to compare. Every statement names the -// organization when it has one rather than trusting the policy to. - -const moduleDelegationColumns = `id::text, org_id::text, principal_id::text, module_prefix, scopes, purpose, - created_at, revoked_at, COALESCE(revoked_by::text, '')` - -func scanModuleDelegations(rows pgx.Rows) ([]*business.ModuleDelegation, error) { - defer rows.Close() - var out []*business.ModuleDelegation - for rows.Next() { - var d business.ModuleDelegation - var revokedAt *time.Time - var scopes []byte - if err := rows.Scan(&d.ID, &d.OrgID, &d.PrincipalID, &d.ModulePrefix, &scopes, &d.Purpose, - &d.CreatedAt, &revokedAt, &d.RevokedBy); err != nil { - return nil, err - } - if err := json.Unmarshal(scopes, &d.Scopes); err != nil { - return nil, fmt.Errorf("module delegation %s: scopes do not decode: %w", d.ID, err) - } - d.RevokedAt = revokedAt - out = append(out, &d) - } - return out, rows.Err() -} - -func (s *PostgresStore) InsertModuleDelegation(ctx context.Context, d *business.ModuleDelegation) error { - scopes, err := json.Marshal(d.Scopes) - if err != nil { - return fmt.Errorf("encode module delegation scopes: %w", err) - } - return s.getQueryExecutor(ctx).QueryRow(ctx, ` - INSERT INTO module_delegations (id, org_id, principal_id, module_prefix, scopes, purpose) - VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5::jsonb, $6) - RETURNING created_at`, - d.ID, d.OrgID, d.PrincipalID, d.ModulePrefix, scopes, d.Purpose).Scan(&d.CreatedAt) -} - -func (s *PostgresStore) ModuleDelegations(ctx context.Context, orgID string, ids []string) ([]*business.ModuleDelegation, error) { - // An id that is no uuid names no delegation; it is dropped rather than - // failing the statement, since a hop's delegation id need not be one. - wanted := make([]string, 0, len(ids)) - for _, id := range ids { - if _, err := uuid.Parse(id); err == nil { - wanted = append(wanted, id) - } - } - if len(wanted) == 0 { - return nil, nil - } - rows, err := s.getQueryExecutor(ctx).Query(ctx, ` - SELECT `+moduleDelegationColumns+` - FROM module_delegations - WHERE id = ANY($2::uuid[]) - AND (NULLIF($1, '')::uuid IS NULL OR org_id = NULLIF($1, '')::uuid) - ORDER BY created_at, id`, - orgID, wanted) - if err != nil { - return nil, err - } - return scanModuleDelegations(rows) -} - -func (s *PostgresStore) RevokeModuleDelegation(ctx context.Context, orgID, grantor, id, revokedBy string) (*business.ModuleDelegation, bool, error) { - if _, err := uuid.Parse(id); err != nil { - return nil, false, nil - } - // The stamp lands only on an active row; an already revoked one is read - // back unchanged, so a second revoke is answered as the first left it. - rows, err := s.getQueryExecutor(ctx).Query(ctx, ` - WITH revoked AS ( - UPDATE module_delegations - SET revoked_at = CURRENT_TIMESTAMP, revoked_by = $4::uuid - WHERE org_id = $1::uuid AND id = $3::uuid AND revoked_at IS NULL - AND (NULLIF($2, '')::uuid IS NULL OR principal_id = NULLIF($2, '')::uuid) - RETURNING `+moduleDelegationColumns+` - ) - SELECT *, true FROM revoked - UNION ALL - SELECT `+moduleDelegationColumns+`, false - FROM module_delegations - WHERE org_id = $1::uuid AND id = $3::uuid - AND (NULLIF($2, '')::uuid IS NULL OR principal_id = NULLIF($2, '')::uuid) - AND NOT EXISTS (SELECT 1 FROM revoked)`, - orgID, grantor, id, revokedBy) - if err != nil { - return nil, false, err - } - defer rows.Close() - if !rows.Next() { - return nil, false, rows.Err() - } - var d business.ModuleDelegation - var revokedAt *time.Time - var scopes []byte - var revokedNow bool - if err := rows.Scan(&d.ID, &d.OrgID, &d.PrincipalID, &d.ModulePrefix, &scopes, &d.Purpose, - &d.CreatedAt, &revokedAt, &d.RevokedBy, &revokedNow); err != nil { - return nil, false, err - } - if err := json.Unmarshal(scopes, &d.Scopes); err != nil { - return nil, false, fmt.Errorf("module delegation %s: scopes do not decode: %w", d.ID, err) - } - d.RevokedAt = revokedAt - return &d, revokedNow, rows.Err() -} diff --git a/module/services/accounts/code/pkg/infra/postgres_policy_log.go b/module/services/accounts/code/pkg/infra/postgres_policy_log.go deleted file mode 100644 index 99ca67b3a..000000000 --- a/module/services/accounts/code/pkg/infra/postgres_policy_log.go +++ /dev/null @@ -1,171 +0,0 @@ -package infra - -import ( - "context" - "errors" - "time" - - "accounts/pkg/business" - - "github.com/jackc/pgx/v5" -) - -// The policy log's LOCAL half: receipts and the reconciliation cursor. -// -// The log itself is external, for the reason the protocol exists — a history -// stored beside the state it witnesses is backed up with it, so it cannot -// witness against it. What lives here is the receipt, and the only reachable -// inconsistency is "appended, not applied", which fails closed. -// -// Control-plane only. Neither relation is deletable: a receipt is the evidence -// that an append happened, so deleting one would make an unreconciled gap -// disappear rather than be closed. - -// RecordPolicyLogAppend writes the receipt for an appended operation, with no -// commit yet. -// -// Idempotent on the operation id, and the `DO NOTHING` here is correct where the -// delivery inbox's was not: an operation id is a single logical operation, so a -// second append with the same id IS the same operation and the stored receipt -// is the authoritative one. There are no "different bytes at the same key" to -// distinguish — the log guarantees one entry per operation id, which is why that -// guarantee is the log's and not this host's. -func (s *PostgresStore) RecordPolicyLogAppend( - ctx context.Context, receipt *business.PolicyLogReceipt, entry *business.PolicyLogEntry, -) (bool, error) { - var inserted bool - err := s.getQueryExecutor(ctx).QueryRow(ctx, ` - INSERT INTO public.policy_log_commits - (operation_id, receipt, log_sequence, subject_kind, subject_id, appended_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (operation_id) DO NOTHING - RETURNING TRUE`, - entry.OperationID, receipt.Receipt, int64(receipt.Sequence), - string(entry.SubjectKind), entry.SubjectID, receipt.AppendedAt, - ).Scan(&inserted) - if errors.Is(err, pgx.ErrNoRows) { - return false, nil - } - if err != nil { - return false, err - } - return inserted, nil -} - -// CommitPolicyLogOperation marks an appended operation applied. -// -// It runs in the SAME transaction as the narrowing, which is what makes -// "applied but unwitnessed" impossible. The `committed_at IS NULL` predicate -// makes a double commit a no-op rather than moving the timestamp: the first -// commit is when the narrowing took effect, and a retry must not rewrite that. -// -// A commit for an operation that was never appended is an ERROR, not a silent -// no-op. It would mean a narrowing reached this point without the log having -// witnessed it, which is the one direction the protocol forbids outright. -func (s *PostgresStore) CommitPolicyLogOperation( - ctx context.Context, operationID string, at time.Time, -) error { - tag, err := s.getQueryExecutor(ctx).Exec(ctx, ` - UPDATE public.policy_log_commits - SET committed_at = $2 - WHERE operation_id = $1 AND committed_at IS NULL`, operationID, at) - if err != nil { - return err - } - if tag.RowsAffected() == 1 { - return nil - } - // Either already committed (fine, a retry) or never appended (not fine). - var appended bool - if err := s.getQueryExecutor(ctx).QueryRow(ctx, - `SELECT TRUE FROM public.policy_log_commits WHERE operation_id = $1`, operationID, - ).Scan(&appended); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return business.NewStoreError( - errors.New("cannot commit policy log operation "+operationID+ - ": no append was recorded for it, so the narrowing would be unwitnessed"), - business.ErrTypeConflict) - } - return err - } - return nil -} - -// UncommittedPolicyLogOperations returns appended operations with no commit — -// the gaps the serving gate refuses on. -// -// Ordered by sequence so an operator reading them sees the log's order rather -// than this host's insertion order, which can differ: reconciliation records a -// batch of entries it has just read, and their sequences are the log's. -func (s *PostgresStore) UncommittedPolicyLogOperations( - ctx context.Context, -) ([]*business.PolicyLogGap, error) { - rows, err := s.getQueryExecutor(ctx).Query(ctx, ` - SELECT operation_id, log_sequence, subject_kind, subject_id, appended_at - FROM public.policy_log_commits - WHERE committed_at IS NULL - ORDER BY log_sequence`) - if err != nil { - return nil, err - } - defer rows.Close() - - gaps := []*business.PolicyLogGap{} - for rows.Next() { - var ( - gap business.PolicyLogGap - sequence int64 - kind string - ) - if err := rows.Scan(&gap.OperationID, &sequence, &kind, &gap.SubjectID, &gap.AppendedAt); err != nil { - return nil, err - } - gap.Sequence = uint64(sequence) - gap.SubjectKind = business.PolicyLogSubjectKind(kind) - gaps = append(gaps, &gap) - } - return gaps, rows.Err() -} - -// PolicyLogCursorState reads how far this host has reconciled, and when it last -// reached the log. -func (s *PostgresStore) PolicyLogCursorState( - ctx context.Context, -) (uint64, *time.Time, error) { - var ( - sequence int64 - reachedAt *time.Time - ) - if err := s.getQueryExecutor(ctx).QueryRow(ctx, - `SELECT reconciled_sequence, reached_at FROM public.policy_log_cursor WHERE id = TRUE`, - ).Scan(&sequence, &reachedAt); err != nil { - return 0, nil, err - } - return uint64(sequence), reachedAt, nil -} - -// AdvancePolicyLogCursor records that the log was reached and read to a -// sequence. -// -// The sequence only ever MOVES FORWARD — `GREATEST` rather than assignment. Two -// replicas reconcile independently, so a slower one finishing second must not -// rewind the cursor and cause entries to be re-read and re-recorded as gaps that -// were already closed. -// -// `reached_at` is set unconditionally, because reaching the log is a fact about -// now regardless of whether there was anything new to read. A host that only -// updated it when entries arrived would appear stale during a quiet period and -// would stop serving for lack of news. -func (s *PostgresStore) AdvancePolicyLogCursor( - ctx context.Context, sequence uint64, at time.Time, -) error { - _, err := s.getQueryExecutor(ctx).Exec(ctx, ` - UPDATE public.policy_log_cursor - SET reconciled_sequence = GREATEST(reconciled_sequence, $1), - reached_at = $2, - updated_at = $2 - WHERE id = TRUE`, int64(sequence), at) - return err -} - -var _ business.PolicyLogStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go b/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go deleted file mode 100644 index f83ae1832..000000000 --- a/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go +++ /dev/null @@ -1,189 +0,0 @@ -//go:build !pure - -package infra_test - -import ( - "context" - "testing" - "time" - - "accounts/pkg/business" - - "github.com/stretchr/testify/require" -) - -// The policy log's LOCAL half against real PostgreSQL. -// -// The protocol's ordering and failure handling are tested on fakes, in-package, -// because those are about which write happens first. These are about the SQL the -// fakes cannot prove: that the idempotency key is enforced by the database -// rather than by a check-then-write, that a commit cannot precede an append, -// and that the cursor only moves forward. - -func policyLogEntry(id, subject string) *business.PolicyLogEntry { - return &business.PolicyLogEntry{ - OperationID: id, - Decision: business.PolicyLogNarrowed, - SubjectKind: business.PolicyLogPrincipal, - SubjectID: subject, - Actor: "admin-1", - } -} - -func policyLogReceipt(id string, sequence uint64, at time.Time) *business.PolicyLogReceipt { - return &business.PolicyLogReceipt{ - Receipt: "receipt-" + id, - Sequence: sequence, - AppendedAt: at, - } -} - -// An append then a commit: the row goes from gap to applied. -func TestPolicyLogAppendThenCommit(t *testing.T) { - now := time.Now().UTC().Truncate(time.Millisecond) - operation := "op-" + business.NewIDString() - subject := business.NewIDString() - - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - inserted, err := testStore.RecordPolicyLogAppend(ctx, - policyLogReceipt(operation, 101, now), policyLogEntry(operation, subject)) - require.NoError(t, err) - require.True(t, inserted) - - gaps, err := testStore.UncommittedPolicyLogOperations(ctx) - require.NoError(t, err) - require.True(t, containsPolicyGap(gaps, operation), - "an appended, uncommitted operation must read as a gap") - - require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, now)) - - gaps, err = testStore.UncommittedPolicyLogOperations(ctx) - require.NoError(t, err) - require.False(t, containsPolicyGap(gaps, operation), - "a committed operation is no longer a gap") - return nil - })) -} - -// The idempotency key is enforced by the DATABASE. A second append of the same -// operation writes nothing and reports not-inserted, so a retry cannot produce -// two receipts for one revocation. -func TestPolicyLogAppendIsIdempotentOnTheOperationID(t *testing.T) { - now := time.Now().UTC().Truncate(time.Millisecond) - operation := "op-" + business.NewIDString() - - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - first, err := testStore.RecordPolicyLogAppend(ctx, - policyLogReceipt(operation, 201, now), policyLogEntry(operation, business.NewIDString())) - require.NoError(t, err) - require.True(t, first) - - second, err := testStore.RecordPolicyLogAppend(ctx, - policyLogReceipt(operation, 202, now), policyLogEntry(operation, business.NewIDString())) - require.NoError(t, err) - require.False(t, second, "a retry of one operation must not append twice") - return nil - })) -} - -// A commit for an operation that was never appended is an ERROR, not a quiet -// no-op. -// -// It would mean a narrowing reached the commit without the log having witnessed -// it, which is the one direction the protocol forbids outright — so it must be -// loud rather than absorbed. -func TestPolicyLogCommitWithoutAnAppendIsRefused(t *testing.T) { - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - err := testStore.CommitPolicyLogOperation(ctx, "op-never-appended-"+business.NewIDString(), time.Now().UTC()) - require.Error(t, err, "committing an unwitnessed narrowing must be refused") - return nil - })) -} - -// Committing twice is a no-op rather than a rewrite: the first commit is when -// the narrowing took effect, and a retry must not move that timestamp. -func TestPolicyLogDoubleCommitDoesNotRewriteTheTimestamp(t *testing.T) { - first := time.Now().UTC().Truncate(time.Millisecond) - later := first.Add(time.Hour) - operation := "op-" + business.NewIDString() - - // The write and the read-back are SEPARATE transactions, deliberately. The - // read-back helper opens its own control-plane transaction, so performing it - // inside the writing one would read a snapshot taken before the insert - // committed — a first draft did exactly that and failed with "no rows", - // which is the isolation working rather than the store misbehaving. - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - _, err := testStore.RecordPolicyLogAppend(ctx, - policyLogReceipt(operation, 301, first), policyLogEntry(operation, business.NewIDString())) - require.NoError(t, err) - require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, first)) - require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, later), - "a second commit is a retry, not an error") - return nil - })) - - var committed time.Time - require.NoError(t, testStore.ScanAsControlPlane(testCtx, - `SELECT committed_at FROM public.policy_log_commits WHERE operation_id = $1`, - []any{&committed}, operation)) - require.WithinDuration(t, first, committed, time.Second, - "the commit time is when the narrowing took effect, not when it was retried") -} - -// The cursor only moves FORWARD. -// -// Two replicas reconcile independently, so a slower one finishing second must -// not rewind the cursor — which would cause entries to be re-read and recorded -// again as gaps that had already been closed, stopping both hosts serving for a -// reason neither could resolve. -func TestPolicyLogCursorOnlyMovesForward(t *testing.T) { - now := time.Now().UTC().Truncate(time.Millisecond) - - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, 500, now)) - sequence, reachedAt, err := testStore.PolicyLogCursorState(ctx) - require.NoError(t, err) - require.Equal(t, uint64(500), sequence) - require.NotNil(t, reachedAt) - - // A slower replica reports an older sequence. - require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, 400, now.Add(time.Second))) - sequence, _, err = testStore.PolicyLogCursorState(ctx) - require.NoError(t, err) - require.Equal(t, uint64(500), sequence, "a lagging replica must not rewind the cursor") - return nil - })) -} - -// Reaching the log is recorded even when there was nothing new to read. -// -// A host that only stamped `reached_at` when entries arrived would look stale -// during a quiet period and would stop serving for lack of news — turning an -// absence of authority changes into an outage. -func TestPolicyLogCursorRecordsContactWithNoNewEntries(t *testing.T) { - now := time.Now().UTC().Truncate(time.Millisecond) - - require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { - sequence, _, err := testStore.PolicyLogCursorState(ctx) - require.NoError(t, err) - - later := now.Add(2 * time.Minute) - require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, sequence, later)) - got, reachedAt, err := testStore.PolicyLogCursorState(ctx) - require.NoError(t, err) - require.Equal(t, sequence, got, "no new entries means no new sequence") - require.NotNil(t, reachedAt) - require.WithinDuration(t, later, *reachedAt, time.Second, - "but contact with the log is still a fact about now") - return nil - })) -} - -func containsPolicyGap(gaps []*business.PolicyLogGap, operation string) bool { - for _, gap := range gaps { - if gap.OperationID == operation { - return true - } - } - return false -} diff --git a/module/services/accounts/code/pkg/infra/postgres_principals_test.go b/module/services/accounts/code/pkg/infra/postgres_principals_test.go index 09e607e9d..eebb2b4b9 100644 --- a/module/services/accounts/code/pkg/infra/postgres_principals_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_principals_test.go @@ -498,9 +498,6 @@ func TestPrincipals_CreateAgent_AuditActorTypeFromCreatorKind(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -529,9 +526,6 @@ func TestPrincipals_RevokePrincipal_EmitsAuditEvent(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -558,9 +552,6 @@ func TestPrincipals_CreateAgent_PersistsCeiling(t *testing.T) { orgID := seedOrg(t, owner) svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) // An agent ceiling may only name audiences this host SERVES (issue #952), so // these are declared module prefixes rather than the free text the field used // to admit — declared the way a composition declares them. `github` and `jira` @@ -618,9 +609,6 @@ func TestPrincipals_DisableEnable_LifecycleAndAudit(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -651,9 +639,6 @@ func TestPrincipals_Disable_IdempotentNoDuplicateAudit(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -702,9 +687,6 @@ func TestPrincipals_Enable_RejectedForRevoked(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) require.NoError(t, testStore.As(business.System()).RevokePrincipal(testCtx, agent.ID, "terminal")) err = svc.EnableAgentPrincipal(testCtx, agent.ID) diff --git a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go index 9aecd2b6d..75045d864 100644 --- a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go @@ -61,17 +61,25 @@ var appTenantRelationPrivileges = map[string]relationPrivileges{ // The module envelope: the authority ceiling a platform super_admin // writes. Request traffic has no access; the control plane appends a // revision and never edits one. - "module_envelopes": {}, - // The policy log's local half. Request traffic has no access at all: a - // receipt is evidence about authority, and the request path neither reads - // nor writes it. - "policy_log_commits": {}, - "policy_log_cursor": {}, + "module_envelopes": {}, "datasource_credential_budgets": {}, // platform relation; the control plane meters provider credentials "hop_admission_keys": {}, // platform relation; the hop's public keys, published by the control plane - "job_attempts": {}, - "job_messages": {}, - "job_state_transitions": {}, + "hop_admission_proofs": {}, // platform relation; the registration proofs' nonces, each used once + // The authority core's records (migration 56): the core decides across + // organizations under the control plane; request traffic has no access. + "authority_core_delegations": {}, + "authority_core_grant_epochs": {}, + "authority_core_installations": {}, + "authority_core_module_revisions": {}, + "authority_core_modules": {}, + "authority_core_owner_aware": {}, + "authority_core_person_epochs": {}, + "authority_core_replay": {}, + "authority_core_role_grants": {}, + "authority_core_tombstones": {}, + "job_attempts": {}, + "job_messages": {}, + "job_state_transitions": {}, // Tenant-scoped relations. "actor_chain_journal": {selectRows: true, insertRows: true}, @@ -96,7 +104,6 @@ var appTenantRelationPrivileges = map[string]relationPrivileges{ "github_app_installations": {selectRows: true, insertRows: true, updateRows: true, deleteRows: true}, "delegation_grants": {selectRows: true, insertRows: true, updateRows: true}, "source_delegations": {selectRows: true, insertRows: true, updateRows: true}, // revoked, never deleted - "module_delegations": {selectRows: true, insertRows: true, updateRows: true}, // revoked, never deleted "domain_events": {selectRows: true}, // request traffic reads its own tenant's events; publishes via SECURITY DEFINER "entitlement_overrides": {selectRows: true, insertRows: true, updateRows: true}, "executable_artifact_approvals": {selectRows: true, insertRows: true}, // only revocation columns, no table-wide UPDATE @@ -431,13 +438,16 @@ func TestControlPlaneRelationGrantsAreExact(t *testing.T) { if relation == "module_envelopes" { want = relationPrivileges{selectRows: true, insertRows: true} } - // policy_log_commits and policy_log_cursor are read, inserted and - // updated, never deleted. The commit row is completed by the commit - // and the cursor advances — but deleting either would make an - // unreconciled gap DISAPPEAR rather than be closed, which is the one - // way the fail-closed asymmetry could be defeated locally. - if relation == "policy_log_commits" || relation == "policy_log_cursor" { + // The authority core's records: an installation, a delegation or a + // tombstone is revoked or superseded, never erased, and an epoch or + // a revision only grows; a role grant is granted and revoked by row, + // and a consumed nonce is reclaimed once its capability expired. + switch relation { + case "authority_core_tombstones", "authority_core_installations", "authority_core_module_revisions", "authority_core_delegations", + "authority_core_person_epochs", "authority_core_grant_epochs", "authority_core_owner_aware": want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} + case "authority_core_role_grants", "authority_core_replay", "hop_admission_proofs": + want = relationPrivileges{selectRows: true, insertRows: true, deleteRows: true} } // solution_targets is never row-deleted either: a CLOSED target is the // evidence that an installation's consent ended, and deleting it @@ -459,11 +469,6 @@ func TestControlPlaneRelationGrantsAreExact(t *testing.T) { if relation == "source_delegations" { want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} } - // module_delegations likewise: a revoked delegation to a module stays - // readable as what was delegated, to whom, and when it stopped. - if relation == "module_delegations" { - want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} - } // approval_decisions is append-only like actor_chain_journal: read and // insert, never update or delete (an immutable trigger rejects those). if relation == "approval_decisions" { diff --git a/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go b/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go index bc0787e88..467d8373d 100644 --- a/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go @@ -253,9 +253,6 @@ func TestRevokingAgentPrincipalStalesOutstandingWorkContext(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) require.NoError(t, svc.RevokePrincipal(testCtx, agent.ID, "delegation disabled")) after, err := testStore.ResolveWorkContextAuthority(verified, orgID, ownerID, "", nil) diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go index 131c34172..43f938a7f 100644 --- a/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go +++ b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "strings" - "sync" "testing" "time" @@ -88,10 +87,6 @@ func newProductionPathDelivery(t *testing.T) *productionPathDelivery { service, err := business.NewService(testStore) require.NoError(t, err) - // A withdrawal is a narrowing of authority, witnessed by the external - // policy log before it takes effect; the double stands in for the log - // only, and the receipts go to the real store. - service.SetPolicyLog(&productionPathPolicyLog{}, testStore) service.SetSolutionDelivery(productionVerifier(t), testStore, infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server))) service.SetSolutionDeliveryCoordinate(productionPathCoordinate) @@ -316,54 +311,6 @@ func productionPathCarrier(t *testing.T, manifest *solutionhost.ModuleManifest) return deliverytest.Carrier(t, payload, productionPathSigner) } -// productionPathPolicyLog witnesses a narrowing, so a withdrawal can take -// effect. A RECORDING double: the operation id is what the host's idempotency -// rests on, so it is honoured. -type productionPathPolicyLog struct { - mu sync.Mutex - sequence uint64 - records []*business.PolicyLogRecord -} - -func (l *productionPathPolicyLog) Append(_ context.Context, entry *business.PolicyLogEntry) (*business.PolicyLogReceipt, error) { - l.mu.Lock() - defer l.mu.Unlock() - for _, record := range l.records { - if record.OperationID == entry.OperationID { - return &business.PolicyLogReceipt{Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt}, nil - } - } - l.sequence++ - receipt := &business.PolicyLogReceipt{ - Receipt: "delivery-test-receipt-" + entry.OperationID, Sequence: l.sequence, AppendedAt: time.Now().UTC(), - } - copied := *entry - l.records = append(l.records, &business.PolicyLogRecord{ - PolicyLogEntry: copied, Receipt: receipt.Receipt, Sequence: receipt.Sequence, LoggedAt: receipt.AppendedAt, - }) - return receipt, nil -} - -func (l *productionPathPolicyLog) Entries(_ context.Context, after uint64, _ int) ([]*business.PolicyLogRecord, error) { - l.mu.Lock() - defer l.mu.Unlock() - var out []*business.PolicyLogRecord - for _, record := range l.records { - if record.Sequence > after { - out = append(out, record) - } - } - return out, nil -} - -// wireNarrowingPolicyLog gives a test service the policy log's two halves, so -// a narrowing can actually happen: WithPolicyLoggedNarrowing refuses on a host -// with no log, by design. -func wireNarrowingPolicyLog(service *business.Service) *business.Service { - service.SetPolicyLog(&productionPathPolicyLog{}, testStore) - return service -} - func productionVerifier(t *testing.T) *solutionhost.Verifier { t.Helper() return deliverytest.Verifier(t, map[string][]string{ diff --git a/module/services/accounts/code/pkg/infra/source_delegation_test.go b/module/services/accounts/code/pkg/infra/source_delegation_test.go index 4b72939ed..b43d23520 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_test.go @@ -191,9 +191,6 @@ func (w *delegationWorld) service(t *testing.T, registry business.ModulePrincipa t.Helper() svc, err := business.NewService(testStore) require.NoError(t, err) - // Revoking a principal or a delegation is a witnessed narrowing, so a - // service with no policy log refuses it outright; see wireNarrowingPolicyLog. - wireNarrowingPolicyLog(svc) svc.SetModuleAuthorityReads(testStore, nil) svc.SetWorkContextSealStore(testStore) svc.SetModuleCapabilities(nil, nil, registry) diff --git a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt index 4460f4e9f..bcd45c8a4 100644 --- a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt +++ b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt @@ -29,6 +29,7 @@ pkg/auth/pg/session_store.go: (*SessionStore).RotateRefresh: WithControlPlane pkg/business/audit.go: (*DurableAuditEmitter).Emit: WithControlPlane pkg/business/audit.go: (*Service).AggregateAuditLog: WithControlPlane pkg/business/audit.go: (*Service).QueryAuditLog: WithControlPlane +pkg/business/authority_core_delivery.go: (*Service).reconcileAuthorityCore: WithControlPlane pkg/business/authority_delivery.go: (*Service).verifyAppliedAuthorityDocument: WithControlPlane pkg/business/connector_credentials.go: (*Service).SourceSigningSecret: WithControlPlane pkg/business/current_installation.go: (*Service).ModuleCurrentInstallation: WithControlPlane @@ -83,13 +84,10 @@ pkg/business/module_capabilities.go: (*Service).ModuleSubscribe: WithControlPlan pkg/business/module_capabilities.go: (*Service).ModuleUnsubscribe: WithControlPlane pkg/business/module_capabilities.go: (*Service).moduleAuditSubjectIsMemberOf: WithControlPlane pkg/business/module_capabilities.go: (*Service).requireTenantMember: WithControlPlane -pkg/business/module_delegation.go: (*Service).ModuleDelegation: WithControlPlane pkg/business/module_envelope.go: (*Service).CurrentModuleEnvelope: WithControlPlane pkg/business/module_envelope.go: (*Service).GetModuleEnvelope: WithControlPlane pkg/business/module_envelope.go: (*Service).committedModuleEnvelopeRevision: WithControlPlane pkg/business/module_envelope.go: (*Service).deliveredSince: WithControlPlane -pkg/business/module_envelope.go: (*Service).moduleEnvelopeNarrows: WithControlPlane -pkg/business/module_envelope.go: (*Service).writeModuleEnvelope: WithControlPlane pkg/business/module_envelope_delivery.go: (*Service).ReceiveModuleEnvelopeDelivery: WithControlPlane pkg/business/module_identity.go: (*Service).RecordModuleWorkContextMint: WithControlPlane pkg/business/module_identity.go: (*Service).VerifyModuleTenant: WithControlPlane @@ -98,6 +96,7 @@ pkg/business/module_manifest_grant.go: (*Service).fenceManifestGenerations: With pkg/business/module_operation_context.go: (*Service).RecordModuleOperationContextMint: WithControlPlane pkg/business/module_principal_reconcile.go: (*ModulePrincipalReconciler).appliedManifestsVerified: WithControlPlane pkg/business/module_principal_reconcile.go: (*ModulePrincipalReconciler).readInputs: WithControlPlaneReadSnapshot +pkg/business/narrowing.go: (*Service).narrow: WithControlPlane pkg/business/notifications.go: (*Service).resolveNotificationUser: WithControlPlane pkg/business/onboarding.go: (*Service).organizationConfigured: WithControlPlane pkg/business/organization_lifecycle.go: (*Service).GetOrganizationRoster: WithControlPlane @@ -118,13 +117,7 @@ pkg/business/platform_admin.go: (*Service).StopImpersonation: WithControlPlane pkg/business/platform_admin.go: (*Service).SuspendUser: WithControlPlane pkg/business/platform_admin.go: (*Service).UnsuspendUser: WithControlPlane pkg/business/platform_catalogue.go: (*Service).ListPlatformCatalogue: WithControlPlane -pkg/business/policy_log.go: (*Service).MayServe: WithControlPlane -pkg/business/policy_log.go: (*Service).ReconcilePolicyLog: WithControlPlane -pkg/business/policy_log.go: (*Service).ReconcilePolicyLog: WithControlPlane -pkg/business/policy_log.go: (*Service).WithPolicyLoggedNarrowing: WithControlPlane -pkg/business/policy_log.go: (*Service).WithPolicyLoggedNarrowing: WithControlPlane pkg/business/principals.go: (*Service).GetPrincipal: As(System()) -pkg/business/principals.go: (*Service).principalOrgID: As(System()) pkg/business/principals.go: (*Service).withPrincipalScope: As(System()) pkg/business/principals.go: (*Service).withPrincipalScope: As(System()) pkg/business/privacy_jobs.go: (*Service).finishPrivacyWorkflow: System() @@ -155,7 +148,6 @@ pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionGenerationH pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionHostBindings: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionTargets: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).applySolutionHostBinding: WithControlPlane -pkg/business/solution_host_bindings_apply.go: (*Service).liveSolutionTargetForBinding: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindingDesired: WithControlPlane pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindingRefusal: WithControlPlane pkg/business/solution_registry.go: (*Service).ListSolutionRegistrations: WithControlPlane @@ -185,6 +177,10 @@ pkg/infra/postgres.go: (*PostgresStore).RunInTransaction: txbind.BindRequest pkg/infra/postgres_audit_registry.go: (*PostgresStore).DropAuditPartitionsBefore: withControlPlaneTx pkg/infra/postgres_auth_bootstrap.go: (*PostgresStore).WithAuthBootstrapTx: withControlPlaneTx pkg/infra/postgres_auth_bootstrap.go: (*PostgresStore).WithAuthLookupTx: withControlPlaneTx +pkg/infra/postgres_authority_core.go: (AuthorityCoreReplay).Consume: withControlPlaneTx +pkg/infra/postgres_authority_core.go: (AuthorityCoreReplay).PurgeExpiredAuthorityReplays: WithControlPlane +pkg/infra/postgres_authority_core.go: (AuthorityCoreStore).Read: withControlPlaneTx +pkg/infra/postgres_authority_core.go: (AuthorityCoreStore).Write: withControlPlaneTx pkg/infra/postgres_client_authorization.go: (*PostgresStore).ConsumeClientAuthorizationCode: WithControlPlane pkg/infra/postgres_datasource_sources.go: (*PostgresStore).GetDatasourceSourceByID: WithControlPlane pkg/infra/postgres_installation_jobs.go: (*PostgresStore).InstallationStepLeaseValid: WithControlPlane diff --git a/module/services/accounts/code/pkg/infra/warehouse_policy_log.go b/module/services/accounts/code/pkg/infra/warehouse_policy_log.go deleted file mode 100644 index ffaedf98a..000000000 --- a/module/services/accounts/code/pkg/infra/warehouse_policy_log.go +++ /dev/null @@ -1,55 +0,0 @@ -package infra - -// The external policy log's transport, and why this deployment has none. -// -// SHAPE, from the infrastructure that provisions it: a DAY-partitioned -// `policy_log` table clustered on (operation_id, subject_id) with deletion -// protection, plus a `policy_log_replay` view that dedupes by ROW_NUMBER() over -// operation_id ordered by sequence. Writer and reader are distinct roles, and -// the reader deliberately holds no job-creation permission — so a replay is a -// bounded row read with a restriction, never a query. -// -// WHY NO CLIENT IS WIRED. Not for lack of a transport library: because that -// dataset cannot issue the RECEIPT the protocol is built on. -// `business.PolicyLog.Append` must return a token the LOG minted and a -// MONOTONIC SEQUENCE the LOG assigned, and the presence of those is the whole -// difference between "the log witnessed this narrowing" and "this host decided -// it". A BigQuery dataset gives a writer neither: -// -// - the non-job write paths return no ordinal. `tabledata.insertAll` returns -// only per-row errors, and the Storage Write API's offsets are monotonic -// within one stream, so two replicas appending produce two independent -// counters and no global order for the host's cursor to compare against. -// - ingestion time, which the warehouse does assign, is not readable until -// the streaming buffer flushes — minutes, against an append the protocol -// bounds at five seconds, deliberately, so a slow log costs a refusal -// rather than held locks. -// - reading the row back to learn either is not available to the writer at -// all: the roles are separate precisely so the appender cannot read, and -// the reader holds no job-creation permission. -// -// So the missing piece is a RECEIPT-ISSUING APPENDER inside the warehouse's -// trust domain — the component that owns the writer credential, assigns the -// sequence and hands back the token — and that is infrastructure this service -// does not own. Until it exists, the host wires NO policy log (work.go), which -// means it serves normally and refuses every narrowing of authority. That is -// the protocol's own answer to a host that cannot witness a narrowing. -// -// WHAT MUST NOT GO HERE, and why this file is a comment rather than a type. The -// obvious way to make the four narrowing paths work again is a transport that -// returns a receipt it made up — any sequence, any token. That is the exact -// tautology the protocol exists to prevent: the host would record that the log -// witnessed the narrowing, nothing would have, and a restore of this database -// would silently reinstate the revoked authority with the receipt still sitting -// beside it as evidence that it had not. A client that cannot be exercised -// against the real service, in the service that owns authority, is worth less -// than an honest absence; a client that fabricates the one value the protocol -// checks is worth less than nothing. -// -// The previous occupant of this file, `UnavailablePolicyLog`, refused every -// append and every read. It was the right answer while nothing consulted the -// serving gate, and it became the wrong one the moment the gate reached the -// request path: a host holding a log it can never READ never refreshes -// `reached_at`, so the staleness window closes and the host stops answering -// anything at all. Refusing to narrow authority and refusing to serve are -// different refusals, and a deployment with no transport owes the first only. diff --git a/module/services/accounts/code/pkg/relationcatalog/catalog.go b/module/services/accounts/code/pkg/relationcatalog/catalog.go index 1684589ac..421275ec3 100644 --- a/module/services/accounts/code/pkg/relationcatalog/catalog.go +++ b/module/services/accounts/code/pkg/relationcatalog/catalog.go @@ -95,25 +95,34 @@ func All() map[string]Authority { var authorities = map[string]Authority{ // Platform-wide catalogs. Seeded and administered centrally, read by // request traffic under exact grants. - "audit_event_types": {Scope: ScopeGlobal}, - "bootstrap_state": {Scope: ScopeGlobal}, - "data_retention_policies": {Scope: ScopeGlobal}, - "email_templates": {Scope: ScopeGlobal}, - "feature_flags": {Scope: ScopeGlobal}, - "identity_providers": {Scope: ScopeGlobal}, - "plan_entitlements": {Scope: ScopeGlobal}, - "plans": {Scope: ScopeGlobal}, - "platform_admins": {Scope: ScopeGlobal}, - "solution_registrations": {Scope: ScopeGlobal}, - "solution_host_bindings": {Scope: ScopeGlobal}, - "solution_targets": {Scope: ScopeGlobal}, - "solution_generation_history": {Scope: ScopeGlobal}, - "solution_delivery_documents": {Scope: ScopeGlobal}, - "module_envelopes": {Scope: ScopeGlobal}, - "policy_log_commits": {Scope: ScopeGlobal}, - "policy_log_cursor": {Scope: ScopeGlobal}, - "datasource_credential_budgets": {Scope: ScopeGlobal}, - "hop_admission_keys": {Scope: ScopeGlobal}, + "audit_event_types": {Scope: ScopeGlobal}, + "bootstrap_state": {Scope: ScopeGlobal}, + "data_retention_policies": {Scope: ScopeGlobal}, + "email_templates": {Scope: ScopeGlobal}, + "feature_flags": {Scope: ScopeGlobal}, + "identity_providers": {Scope: ScopeGlobal}, + "plan_entitlements": {Scope: ScopeGlobal}, + "plans": {Scope: ScopeGlobal}, + "platform_admins": {Scope: ScopeGlobal}, + "solution_registrations": {Scope: ScopeGlobal}, + "solution_host_bindings": {Scope: ScopeGlobal}, + "solution_targets": {Scope: ScopeGlobal}, + "solution_generation_history": {Scope: ScopeGlobal}, + "solution_delivery_documents": {Scope: ScopeGlobal}, + "module_envelopes": {Scope: ScopeGlobal}, + "datasource_credential_budgets": {Scope: ScopeGlobal}, + "hop_admission_keys": {Scope: ScopeGlobal}, + "hop_admission_proofs": {Scope: ScopeGlobal}, + "authority_core_delegations": {Scope: ScopeGlobal}, + "authority_core_grant_epochs": {Scope: ScopeGlobal}, + "authority_core_installations": {Scope: ScopeGlobal}, + "authority_core_module_revisions": {Scope: ScopeGlobal}, + "authority_core_modules": {Scope: ScopeGlobal}, + "authority_core_owner_aware": {Scope: ScopeGlobal}, + "authority_core_person_epochs": {Scope: ScopeGlobal}, + "authority_core_replay": {Scope: ScopeGlobal}, + "authority_core_role_grants": {Scope: ScopeGlobal}, + "authority_core_tombstones": {Scope: ScopeGlobal}, // Tenant-scoped relations. "actor_chain_journal": { @@ -153,10 +162,6 @@ var authorities = map[string]Authority{ Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id", Notes: "Revoked, never deleted: a delegation keeps its record after the source or the person it names is gone.", }, - "module_delegations": { - Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id", - Notes: "Revoked, never deleted: a person's delegation to a module keeps its record after it stopped.", - }, "github_app_installations": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "delegation_grants": {Scope: ScopeTenant, PolicyShape: ShapeDirect, ScopeColumn: "org_id"}, "domain_events": { diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 250d98460..42c023bfc 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -524,45 +524,9 @@ func doWork(ctx context.Context) (clean Clean, err error) { return nil, fmt.Errorf("configure solution host binding reconciler: %w", err) } - // The policy log: append → receipt → commit, before authority is narrowed. - // - // THIS DEPLOYMENT HAS NO LOG TRANSPORT, and that is stated by wiring none - // rather than by wiring something that refuses. The difference matters now - // that the serving gate is on the request path: a host holding a log it can - // never reach never refreshes `reached_at`, so the staleness window closes - // and the host stops answering anything — a total outage dressed as - // fail-closed. A host holding NO log has nothing unreconciled to honour, so - // `MayServe` admits and the host serves. - // - // What it cannot do is NARROW authority. `WithPolicyLoggedNarrowing` refuses - // outright, so uninstalling a solution, closing a withdrawn solution - // target, revoking a scope grant and removing a team membership all refuse - // with ErrPolicyLogUnreachable until a transport exists. That is the - // protocol's own answer — a narrowing nothing witnessed is one a restore - // silently undoes — and it is loud rather than silent, below. - // - // WHY THERE IS NO TRANSPORT. The warehouse the log lives in cannot issue the - // receipt the protocol requires. `PolicyLog.Append` must return a token the - // LOG minted and a monotonic sequence the LOG assigned, because the receipt - // is what separates "the log witnessed this" from "this host decided it". A - // BigQuery dataset assigns neither to a writer: the non-job write paths - // return no ordinal, ingestion time is not readable until the streaming - // buffer flushes, and the writer role deliberately holds no read and no - // job-creation permission, so it cannot read its own row back to learn - // either. Closing that needs a receipt-issuing appender inside the - // warehouse's trust domain, which is infrastructure this service does not - // own. Anything else would be this host minting its own receipt — the exact - // tautology the protocol exists to prevent. - service.SetPolicyLog(nil, nil) - w.Warn("no policy log transport is configured: this host SERVES normally and REFUSES every " + - "narrowing of authority (solution uninstall, solution-target close, scope-grant revocation, " + - "team-membership removal), because a narrowing nothing witnessed is one a restore undoes silently") - - // The serving gate, on the request path. Registered for both transports' - // authorization interceptors; with no log wired it admits, which is what - // MayServe answers for a host that has narrowed nothing through the - // protocol. - adapters.RegisterPolicyLogServingGate(service.RequireServing) + // A narrowing of authority (removing a member, revoking a grant, + // uninstalling, closing a withdrawn target) applies directly, recorded in + // the audit journal in the same transaction (business/narrowing.go). // Enforcement at use. Every module capability path re-reads the live // installation, producer epoch and binding revision through these, and diff --git a/module/services/accounts/generated/api-contract-surfaces.json b/module/services/accounts/generated/api-contract-surfaces.json index 6015f208d..7319d6c90 100644 --- a/module/services/accounts/generated/api-contract-surfaces.json +++ b/module/services/accounts/generated/api-contract-surfaces.json @@ -75,9 +75,13 @@ "name": "InstallationService", "fullName": "host.installation.v1.InstallationService", "procedures": [ - "/host.installation.v1.InstallationService/GrantToTeam", + "/host.installation.v1.InstallationService/Current", + "/host.installation.v1.InstallationService/GrantRole", "/host.installation.v1.InstallationService/Install", - "/host.installation.v1.InstallationService/List" + "/host.installation.v1.InstallationService/List", + "/host.installation.v1.InstallationService/ListRoleGrants", + "/host.installation.v1.InstallationService/Review", + "/host.installation.v1.InstallationService/RevokeRole" ] }, { diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index 8af5c1f44..f17e7caf4 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -21,8 +21,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_attached" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -39,7 +41,7 @@ "service": "accounts" }, "source_proto": "host/authority/v1/authority.proto", - "policy_sha256": "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333", + "policy_sha256": "b2d609e4b91c10163185e16e046a6226dae5dc348389c972de3fd1aa751a65f4", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" }, { @@ -58,8 +60,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_transferred" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -76,7 +80,7 @@ "service": "accounts" }, "source_proto": "host/authority/v1/authority.proto", - "policy_sha256": "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333", + "policy_sha256": "6d1b59e7ade14cdc86f238fe6f01fb2405d199cd40e3bb5d6752baa3de9049f1", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" }, { @@ -171,8 +175,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_detached" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -189,7 +195,7 @@ "service": "accounts" }, "source_proto": "host/authority/v1/authority.proto", - "policy_sha256": "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333", + "policy_sha256": "c68549f47046a8a49dc4984ea824938c1b107e82c5040c5337dca4e861471e57", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" }, { @@ -246,7 +252,8 @@ "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.work_context.task_started" + "saas.work_context.task_started", + "saas.work_context.child_session_started" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -265,7 +272,7 @@ "service": "accounts" }, "source_proto": "host/authority/v1/authority.proto", - "policy_sha256": "e434a6f8983ad37edb91c8557f9303e74805913e5709e860b2c0645f283e1c3e", + "policy_sha256": "6b0b5e127b9cc3312281207bd4f8b70894ddde6b3d52ceceb21eec92d9a6d0ec", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { @@ -458,23 +465,60 @@ "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" }, { - "procedure": "/host.installation.v1.InstallationService/GrantToTeam", + "procedure": "/host.installation.v1.InstallationService/Current", "full_service": "host.installation.v1.InstallationService", - "method": "GrantToTeam", - "input_type": "host.installation.v1.GrantToTeamRequest", + "method": "Current", + "input_type": "host.installation.v1.CurrentInstallationRequest", + "output_type": "host.installation.v1.CurrentInstallation", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_PUBLIC", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", + "request_sensitivity": "SENSITIVITY_SECRET", + "response_sensitivity": "SENSITIVITY_INTERNAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "public", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "host/installation/v1/installation.proto", + "policy_sha256": "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" + }, + { + "procedure": "/host.installation.v1.InstallationService/GrantRole", + "full_service": "host.installation.v1.InstallationService", + "method": "GrantRole", + "input_type": "host.installation.v1.GrantRoleRequest", "output_type": "host.installation.v1.Installation", "client_streaming": false, "server_streaming": false, "policy": { "exposure": "EXPOSURE_AUTHENTICATED", - "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "tenant": "TENANT_REQUIREMENT_NONE", "permissions": [], "scopes": [], "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.scope.granted" + "saas.authority.role_granted" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -487,13 +531,13 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", + "policy_tier": "auth", "owner": { "module": "saas-starter", "service": "accounts" }, "source_proto": "host/installation/v1/installation.proto", - "policy_sha256": "6913e7f4514f63a3c59e03d2fbc07da1176734ab2cfced6f488740e06497927f", + "policy_sha256": "fc01eebe90648f5e096a7d4bbbd8f2aeb3d755bbd808c962352ed78936b12a4a", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { @@ -513,7 +557,8 @@ "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.installation.created" + "saas.authority.installation_requested", + "saas.authority.installation_activated" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -532,7 +577,7 @@ "service": "accounts" }, "source_proto": "host/installation/v1/installation.proto", - "policy_sha256": "1bcc0da1738d725ece80382fc7c3ab4b571c0dbfdf4858377ccf3cb355c3640a", + "policy_sha256": "9848918817951cb839354fccd74257a45731b1ff0ae86aaa3af89dadc0f32a1b", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { @@ -572,6 +617,119 @@ "policy_sha256": "b387739192840b24f771349cb2cded72d5f7eabd9d56c433b5b5937e6f4e58f0", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "procedure": "/host.installation.v1.InstallationService/ListRoleGrants", + "full_service": "host.installation.v1.InstallationService", + "method": "ListRoleGrants", + "input_type": "host.installation.v1.ListRoleGrantsRequest", + "output_type": "host.installation.v1.ListRoleGrantsResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "auth", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "host/installation/v1/installation.proto", + "policy_sha256": "79921072468c5f99274839371a7ac834d857df6e5f0708942165c4b31084de26", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, + { + "procedure": "/host.installation.v1.InstallationService/Review", + "full_service": "host.installation.v1.InstallationService", + "method": "Review", + "input_type": "host.installation.v1.ReviewRequest", + "output_type": "host.installation.v1.ConsentApproval", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "org_admin", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "host/installation/v1/installation.proto", + "policy_sha256": "b387739192840b24f771349cb2cded72d5f7eabd9d56c433b5b5937e6f4e58f0", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, + { + "procedure": "/host.installation.v1.InstallationService/RevokeRole", + "full_service": "host.installation.v1.InstallationService", + "method": "RevokeRole", + "input_type": "host.installation.v1.RevokeRoleRequest", + "output_type": "host.installation.v1.Installation", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [ + "saas.authority.role_revoked" + ], + "emission": "AUDIT_EMISSION_SUCCESS" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_WRITE", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "auth", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "host/installation/v1/installation.proto", + "policy_sha256": "912f1c45666c1a2c3089918be4723973ca4fadf2dc78e5e80253d93499e74c92", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, { "procedure": "/host.session.v1.SessionService/Current", "full_service": "host.session.v1.SessionService", diff --git a/module/services/accounts/generated/gateway-routes.json b/module/services/accounts/generated/gateway-routes.json index 986f9c134..88aef96d0 100644 --- a/module/services/accounts/generated/gateway-routes.json +++ b/module/services/accounts/generated/gateway-routes.json @@ -3572,9 +3572,25 @@ { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", - "path": "/host.installation.v1.InstallationService/GrantToTeam", + "path": "/host.installation.v1.InstallationService/Current", "match": "GATEWAY_MATCH_EXACT", - "procedure": "/host.installation.v1.InstallationService/GrantToTeam", + "procedure": "/host.installation.v1.InstallationService/Current", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_PUBLIC", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/host.installation.v1.InstallationService/GrantRole", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/host.installation.v1.InstallationService/GrantRole", "owner": { "module": "saas-starter", "service": "accounts" @@ -3617,6 +3633,54 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/host.installation.v1.InstallationService/ListRoleGrants", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/host.installation.v1.InstallationService/ListRoleGrants", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/host.installation.v1.InstallationService/Review", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/host.installation.v1.InstallationService/Review", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/host.installation.v1.InstallationService/RevokeRole", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/host.installation.v1.InstallationService/RevokeRole", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 443847dd6..f026abbdd 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -18,6 +18,9 @@ { "name": "InstallableService" }, + { + "name": "OfferService" + }, { "name": "SessionService" }, @@ -1967,7 +1970,7 @@ }, "/v1/installations/{installationId}/teams/{teamId}:revoke": { "post": { - "summary": "RevokeInstallationFromTeam removes one team grant at an installation's\nauthority-root node. It is a narrowing, so it runs under the policy log\nexactly as RevokeScope does: a host that cannot witness the narrowing refuses\nit, and says so, rather than revoking unwitnessed. It removes the one grant\nat the authority root that granted_teams reports; a team that also reaches\nthe installation from a grant above keeps that reach. Organisation-admin,\nwith the same platform super administrator exception.", + "summary": "RevokeInstallationFromTeam removes one team grant at an installation's\nauthority-root node. It is a narrowing, so it applies directly, with its\naudit record in one transaction, exactly as RevokeScope does. It removes the one grant\nat the authority root that granted_teams reports; a team that also reaches\nthe installation from a grant above keeps that reach. Organisation-admin,\nwith the same platform super administrator exception.", "operationId": "InstallationService_RevokeInstallationFromTeam", "responses": { "200": { @@ -2013,7 +2016,7 @@ }, "/v1/installations/{installationId}:narrow": { "post": { - "summary": "Narrowing is witnessed in the policy log before commit, then atomically\nbumps the installation revision, cancels its org's work and records audit.", + "summary": "A narrowing applies directly: it atomically bumps the installation\nrevision, cancels its org's work and records audit.", "operationId": "InstallationService_NarrowInstallation", "responses": { "200": { @@ -8156,9 +8159,13 @@ }, "operationId": { "type": "string" + }, + "digest": { + "type": "string", + "description": "sha256 digest of the reviewed authority offer." } }, - "description": "ConsentApproval binds an explicit approval to the state reviewed by the admin.\nObtain target_id and generation from ListAvailableSolutions, and revision from\nthe installation listing (zero means absent). Keep these values and operation_id\nunchanged on retry; a refusal requires a fresh review and operation." + "description": "ConsentApproval binds consent to an immutable target, its reviewed delivery and\nthe installation revision (zero if absent). Review leaves operation_id empty;\nthe caller supplies a fresh UUID and preserves the entire request on retry." }, "v1ConsentPurpose": { "type": "string", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index b3c21bc9e..52ac85331 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -35,9 +35,13 @@ "name": "InstallationService", "full_name": "host.installation.v1.InstallationService", "procedures": [ - "/host.installation.v1.InstallationService/GrantToTeam", + "/host.installation.v1.InstallationService/Current", + "/host.installation.v1.InstallationService/GrantRole", "/host.installation.v1.InstallationService/Install", - "/host.installation.v1.InstallationService/List" + "/host.installation.v1.InstallationService/List", + "/host.installation.v1.InstallationService/ListRoleGrants", + "/host.installation.v1.InstallationService/Review", + "/host.installation.v1.InstallationService/RevokeRole" ] }, { @@ -562,8 +566,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_attached" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -575,7 +581,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "public", - "description": "Record that one resource belongs to an installation; refused by name until this host records installation-owned resources.", + "description": "Record that one resource created through an installation belongs to it; the kind's serving module asks.", "owner": { "module": "saas-starter", "service": "accounts" @@ -605,8 +611,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_transferred" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -618,7 +626,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "public", - "description": "Decide whether an existing resource may join an installed solution's principal; refused by name until this host records installation-owned resources.", + "description": "Decide whether an existing resource may join an installed solution's principal, and record it.", "owner": { "module": "saas-starter", "service": "accounts" @@ -744,8 +752,10 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [], - "emission": "AUDIT_EMISSION_NONE" + "events": [ + "saas.authority.resource_detached" + ], + "emission": "AUDIT_EMISSION_SUCCESS" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", @@ -757,7 +767,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "public", - "description": "Release one resource from the installation that owns it; refused by name until this host records installation-owned resources.", + "description": "Release one deleted resource from the installation that owns it; the kind's serving module asks.", "owner": { "module": "saas-starter", "service": "accounts" @@ -839,7 +849,8 @@ "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.work_context.task_started" + "saas.work_context.task_started", + "saas.work_context.child_session_started" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -1079,11 +1090,54 @@ } }, { - "procedure": "/host.installation.v1.InstallationService/GrantToTeam", + "procedure": "/host.installation.v1.InstallationService/Current", + "service": "InstallationService", + "full_service": "host.installation.v1.InstallationService", + "method": "Current", + "input_type": "host.installation.v1.CurrentInstallationRequest", + "output_type": "host.installation.v1.CurrentInstallation", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "host/installation/v1/installation.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_PUBLIC", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", + "request_sensitivity": "SENSITIVITY_SECRET", + "response_sensitivity": "SENSITIVITY_INTERNAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "public", + "description": "Read the authenticated module's own active installation in the tenant of a current parent addressed to it.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, + { + "procedure": "/host.installation.v1.InstallationService/GrantRole", "service": "InstallationService", "full_service": "host.installation.v1.InstallationService", - "method": "GrantToTeam", - "input_type": "host.installation.v1.GrantToTeamRequest", + "method": "GrantRole", + "input_type": "host.installation.v1.GrantRoleRequest", "output_type": "host.installation.v1.Installation", "client_streaming": false, "server_streaming": false, @@ -1096,14 +1150,14 @@ "http_bindings": [], "policy": { "exposure": "EXPOSURE_AUTHENTICATED", - "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "tenant": "TENANT_REQUIREMENT_NONE", "permissions": [], "scopes": [], "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.scope.granted" + "saas.authority.role_granted" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -1116,8 +1170,8 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", - "description": "Expose an installation to a team under the installation's role.", + "policy_tier": "auth", + "description": "Grant a person or a team one of an installed solution's roles.", "owner": { "module": "saas-starter", "service": "accounts" @@ -1148,7 +1202,8 @@ "mfa": "MFA_REQUIREMENT_NONE", "audit": { "events": [ - "saas.installation.created" + "saas.authority.installation_requested", + "saas.authority.installation_activated" ], "emission": "AUDIT_EMISSION_SUCCESS" }, @@ -1162,7 +1217,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, "policy_tier": "org_admin", - "description": "Install one target at one organisation under the least role it needs; pending until its install step is acknowledged.", + "description": "Install one solution at one organisation under the consent its administrator reviewed; pending until its install step converges.", "owner": { "module": "saas-starter", "service": "accounts" @@ -1211,6 +1266,137 @@ "service": "accounts" } }, + { + "procedure": "/host.installation.v1.InstallationService/ListRoleGrants", + "service": "InstallationService", + "full_service": "host.installation.v1.InstallationService", + "method": "ListRoleGrants", + "input_type": "host.installation.v1.ListRoleGrantsRequest", + "output_type": "host.installation.v1.ListRoleGrantsResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "host/installation/v1/installation.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "auth", + "description": "List who holds which role on one installation.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, + { + "procedure": "/host.installation.v1.InstallationService/Review", + "service": "InstallationService", + "full_service": "host.installation.v1.InstallationService", + "method": "Review", + "input_type": "host.installation.v1.ReviewRequest", + "output_type": "host.installation.v1.ConsentApproval", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "host/installation/v1/installation.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_OPTIONAL", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "org_admin", + "description": "Read the exact authority offer an administrator consents to, without recording consent.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, + { + "procedure": "/host.installation.v1.InstallationService/RevokeRole", + "service": "InstallationService", + "full_service": "host.installation.v1.InstallationService", + "method": "RevokeRole", + "input_type": "host.installation.v1.RevokeRoleRequest", + "output_type": "host.installation.v1.Installation", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "host/installation/v1/installation.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [ + "saas.authority.role_revoked" + ], + "emission": "AUDIT_EMISSION_SUCCESS" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_WRITE", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" + }, + "policy_tier": "auth", + "description": "Revoke one role grant; its subject's capabilities through the installation end.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/host.session.v1.SessionService/Current", "service": "SessionService", @@ -6249,7 +6435,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, "policy_tier": "org_admin", - "description": "Remove a team's grant at an installation's authority root, under the policy log.", + "description": "Remove a team's grant at an installation's authority root.", "owner": { "module": "saas-starter", "service": "accounts" diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 786a8f5de..fad8b75fd 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -1452,8 +1452,12 @@ "type": "object" }, "v1ConsentApproval": { - "description": "ConsentApproval binds an explicit approval to the state reviewed by the admin.\nObtain target_id and generation from ListAvailableSolutions, and revision from\nthe installation listing (zero means absent). Keep these values and operation_id\nunchanged on retry; a refusal requires a fresh review and operation.", + "description": "ConsentApproval binds consent to an immutable target, its reviewed delivery and\nthe installation revision (zero if absent). Review leaves operation_id empty;\nthe caller supplies a fresh UUID and preserves the entire request on retry.", "properties": { + "digest": { + "description": "sha256 digest of the reviewed authority offer.", + "type": "string" + }, "expectedRevision": { "format": "uint64", "type": "string" @@ -7989,7 +7993,7 @@ } } }, - "summary": "RevokeInstallationFromTeam removes one team grant at an installation's\nauthority-root node. It is a narrowing, so it runs under the policy log\nexactly as RevokeScope does: a host that cannot witness the narrowing refuses\nit, and says so, rather than revoking unwitnessed. It removes the one grant\nat the authority root that granted_teams reports; a team that also reaches\nthe installation from a grant above keeps that reach. Organisation-admin,\nwith the same platform super administrator exception.", + "summary": "RevokeInstallationFromTeam removes one team grant at an installation's\nauthority-root node. It is a narrowing, so it applies directly, with its\naudit record in one transaction, exactly as RevokeScope does. It removes the one grant\nat the authority root that granted_teams reports; a team that also reaches\nthe installation from a grant above keeps that reach. Organisation-admin,\nwith the same platform super administrator exception.", "tags": [ "InstallationService" ] @@ -8029,7 +8033,7 @@ } } }, - "summary": "Narrowing is witnessed in the policy log before commit, then atomically\nbumps the installation revision, cancels its org's work and records audit.", + "summary": "A narrowing applies directly: it atomically bumps the installation\nrevision, cancels its org's work and records audit.", "tags": [ "InstallationService" ] @@ -12525,6 +12529,9 @@ { "name": "InstallableService" }, + { + "name": "OfferService" + }, { "name": "SessionService" }, diff --git a/module/services/accounts/proto/host/DIGESTS.sha256 b/module/services/accounts/proto/host/DIGESTS.sha256 index 2cb4bfbfe..0b7594cea 100644 --- a/module/services/accounts/proto/host/DIGESTS.sha256 +++ b/module/services/accounts/proto/host/DIGESTS.sha256 @@ -1,5 +1,5 @@ -6f4ecb6d3493f29ca79dfcf3ccf8f9ec1cfa3bf8e56c70bcc2055339a6666d1c authority/v1/authority.proto +5f743acb2e21e23eb916092313f54b0737742fe73c77d3f43208987a4aabf4c4 authority/v1/authority.proto f1ea4a9113527f8ed972660c3d2134a39135e96cf212b780ec04d14321ddcab1 authority/v1/work_context.proto -187b07dd29d4d2f082bf5cd60f6d4801c124522a5e93725d4de8004d3368050e composition/v1/composition.proto -2bfc4fa02a2ca5d77908614e31b22da5c7e6ee18a43b58ce387f82806e56fe85 installation/v1/installation.proto +65353da82976f3f00d0a87792b93a723adbfc9deec48a5f7e337ff9526be53f5 composition/v1/composition.proto +d69c70528987ef59180b0ac683ac3c30de9b18077dacc3eece1342465953314e installation/v1/installation.proto 5d1e2f8fb2e7c3a7b8e027d8de23e26e53fa21212831e23fac492372ac53f28b session/v1/session.proto diff --git a/module/services/accounts/proto/host/authority/v1/authority.proto b/module/services/accounts/proto/host/authority/v1/authority.proto index 104828eaa..1cd66fd03 100644 --- a/module/services/accounts/proto/host/authority/v1/authority.proto +++ b/module/services/accounts/proto/host/authority/v1/authority.proto @@ -32,14 +32,36 @@ service AuthorityService { rpc Keys(KeysRequest) returns (KeysResponse); // RegisterAdmissionKey is how the hop tells the authority the public key it // signs call admissions with, so Keys publishes it in admission_keys. The - // caller is the hop, proving it with its workload credential; any other - // workload is refused by name. Only the public half travels: the hop's seed - // never leaves it. Registering the same key again is idempotent and renews - // it, and a registration lapses at expires_at_unix unless renewed, so a hop - // renews while it runs (a restarted authority learns the key again at the - // next renewal) and a key no running hop signs with any more is withdrawn - // without being named. While a rotation overlaps, the retiring and the new - // key are both registered, and both published. + // caller is the hop, proving it with its workload credential, in the + // authority's own trust domain; any other workload is refused by name. + // Only the public half travels: the hop's seed never leaves it. + // + // The authority pins one admission key per hop instance (the instance its + // workload credential names): the first registration for an instance binds + // that key until its lease lapses, the same key renews it, and any other key + // for that instance is refused by name until then. A new key comes from a + // new hop instance, or from the same one once its previous key has lapsed. + // So a captured hop credential cannot replace or add a key for an instance + // that has registered. What it does not stop: a holder of a captured + // credential who registers before that instance's own first registration + // (the hop registers at boot), or in the window after an authority restart + // before the hop's next renewal, pins its own key for that instance; the + // hop's registration is then refused by name, and its log says so. A + // credential binding the key at issuance is what would close that window. + // + // Each request also carries a proof signed by the key it registers (see + // RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + // key's private half and binds the request to its credential, the + // authority's audience and a fresh, single-use nonce, so a registration + // cannot be replayed, retargeted or reused for another credential; it does + // not prove the registrant is the hop, since a registrant chooses its key. + // + // A registration lapses at expires_at_unix unless renewed, so a hop renews + // while it runs (a restarted authority learns the key again at the next + // renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring key (its + // instance still renewing) and the new key (a new instance's) are both + // registered, and both published. rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); // ListResources answers which resources of one kind a checked capability may // act on with one action, decided by the authority at the time of the call @@ -306,6 +328,17 @@ message RegisterAdmissionKeyRequest { WorkloadCredential actor = 1; // The raw Ed25519 public key the hop signs admissions with: 32 bytes. bytes public_key = 2; + // Proof that the caller holds public_key's private half: a compact + // "." in unpadded base64url, the signature an Ed25519 + // signature by public_key over the payload, a JSON array of the domain + // "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + // of actor's credential, the hex public key, a fresh 32-byte hex nonce and + // the Unix second it was signed. The authority refuses a proof that does + // not verify, names another credential, key or audience, is older than 30 + // seconds or ahead of its clock, or repeats a nonce it has seen. It proves + // possession of the key being registered, which the registrant chose; that + // the registrant is the hop rests on actor and the per-instance pin. + string proof = 3; } // RegisterAdmissionKeyResponse is the key as Keys now publishes it. diff --git a/module/services/accounts/proto/host/composition/v1/composition.proto b/module/services/accounts/proto/host/composition/v1/composition.proto index a50859362..a834fd4c3 100644 --- a/module/services/accounts/proto/host/composition/v1/composition.proto +++ b/module/services/accounts/proto/host/composition/v1/composition.proto @@ -62,6 +62,11 @@ message AuthorityManifestV1 { // Core's manifest carries no roles, so they travel here until it does. A // module declares none; a module's delivery listing any is refused. repeated RoleV1 roles = 6; + // The module checks a resource's owner against an owner-selected scope + // (Claims.AllowsOwned): only an audience declaring it receives one, and a + // delivery withdrawing it refuses the owner-selected capabilities issued + // before. Every solution receiving its roles' scopes declares it. + bool owner_aware = 7; } // MethodPolicyV1 is what a call to one method must hold, as the module's @@ -112,7 +117,7 @@ message ModuleManifestV1 { PageV1 page = 7; // The roles a solution declares a person may hold on an installation of it, // each within what the solution serves or explicitly consumes through - // invoke/lookup bindings; one is named installer, held by whoever installs it. + // invoke/lookup bindings; one is named installer, held only by whom it is granted to. // Headless-only scopes cannot supply a role grant. A module declares none. repeated RoleV1 roles = 8; } @@ -121,6 +126,15 @@ message ModuleManifestV1 { message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; + // Scopes the role grants only on the resources the installation owns: those + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; } // BindingV1 is one declared call towards another module. diff --git a/module/services/accounts/proto/host/installation/v1/installation.proto b/module/services/accounts/proto/host/installation/v1/installation.proto index 9661643e9..c1ebefd92 100644 --- a/module/services/accounts/proto/host/installation/v1/installation.proto +++ b/module/services/accounts/proto/host/installation/v1/installation.proto @@ -1,24 +1,55 @@ syntax = "proto3"; // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. package host.installation.v1; option go_package = "gateway/pkg/gen/host/installation/v1;installationv1"; // InstallationService is implemented by the host and by the local authority. service InstallationService { + // Current reads only the authenticated module's own active installation, + // in the tenant of a current parent addressed to that module. It grants no + // artifact approval or content access and exposes no roles or grants. + rpc Current(CurrentInstallationRequest) returns (CurrentInstallation); // Install composes an installation: its agent principal, its root scope and // the row, pending until the target acknowledges its install step. rpc Install(InstallRequest) returns (Installation); - // GrantToTeam exposes an installation to a team under the installation's role. - rpc GrantToTeam(GrantToTeamRequest) returns (Installation); + // Review reads the current offer and installation revision without granting consent. + rpc Review(ReviewRequest) returns (ConsentApproval); + // GrantRole grants a person or a team one of the solution's roles on the + // installation: the access. Idempotent; it leaves existing capabilities + // valid. An administrator of the installation's organization, or a holder + // of the installer role on that installation, grants. + rpc GrantRole(GrantRoleRequest) returns (Installation); + // RevokeRole ends one grant; every capability its subject holds from it is + // refused at once. Idempotent; the same callers as GrantRole. + rpc RevokeRole(RevokeRoleRequest) returns (Installation); + // ListRoleGrants answers who holds which role on the installation; the same + // callers as GrantRole. + rpc ListRoleGrants(ListRoleGrantsRequest) returns (ListRoleGrantsResponse); // List answers what one organisation has installed: an administrator's read. A // member's view, which installed solutions they may use, is their navigation and // belongs to the entitlement read, not here. rpc List(ListRequest) returns (ListResponse); } +message CurrentInstallationRequest { + string parent_token = 1; + string actor_credential = 2; +} + +// CurrentInstallation is request-local metadata, never a lease or consent. +message CurrentInstallation { + string id = 1; + string organization_id = 2; + string target = 3; + uint64 revision = 4; + string namespace = 5; +} + // InstallableService is implemented by a solution; the host calls it once per // installation and retries until it is acknowledged. service InstallableService { @@ -26,6 +57,22 @@ service InstallableService { rpc Install(InstallationNotice) returns (InstallationAck); } +// OfferService is the local authority's setup read, served beside +// InstallationService and not part of codefly.dev/installation: what solutions +// this run delivered and, for one, what installing it would grant. A host +// answers its own product's reads. It grants nothing; Install, GrantRole and +// RevokeRole stay the only changes, each decided by the authority. +service OfferService { + // ListOffers lists the solutions delivered to this run that an administrator + // of the organization may install. A module is installed by composition and + // is never offered. + rpc ListOffers(ListOffersRequest) returns (ListOffersResponse); + // ReviewOffer reads one solution's roles, what each grants and the tools + // those grants make eligible, with the consent Install takes, from one read + // of the authority: the approval names exactly the offer displayed. + rpc ReviewOffer(ReviewRequest) returns (ReviewedOffer); +} + // InstallRequest asks to install one target at one organisation. message InstallRequest { // The organisation installing. @@ -36,31 +83,81 @@ message InstallRequest { string role = 3; // The accountable person; an administrator of the organisation. string owner_person_id = 4; - // The exact catalogue offer and installation state the administrator approved. + // Required: the exact offer reviewed by the administrator; unchanged on retry. ConsentApproval approval = 5; } -// ConsentApproval binds an explicit approval to the state reviewed by the admin. -// Obtain target_id and generation from ListAvailableSolutions, and revision from -// the installation listing (zero means absent). Keep these values and operation_id -// unchanged on retry; a refusal requires a fresh review and operation. +// ReviewRequest names the offer an administrator intends to review. +message ReviewRequest { + string organization_id = 1; + string target = 2; +} + +// ConsentApproval binds consent to an immutable target, its reviewed delivery and +// the installation revision (zero if absent). Review leaves operation_id empty; +// the caller supplies a fresh UUID and preserves the entire request on retry. message ConsentApproval { string target_id = 1; uint64 generation = 2; uint64 expected_revision = 3; string operation_id = 4; + // sha256 digest of the reviewed authority offer. + string digest = 5; } -// GrantToTeamRequest exposes an installation to a team. -message GrantToTeamRequest { +// GrantRoleRequest grants one role to exactly one person or team. +message GrantRoleRequest { + // The organization the installation belongs to; a request naming another + // organization's installation is refused naming the organization, before + // the caller's authority is read. + string organization_id = 1; + // The installation. + string installation_id = 2; + // One of the roles the solution declares. + string role = 3; + // The person granted, a member of the organization; exactly one of + // person_id and team_id is set. + string person_id = 4; + // The team granted, a team of the organization. + string team_id = 5; +} + +// RevokeRoleRequest ends one grant, named as GrantRole named it. +message RevokeRoleRequest { + // The organization the installation belongs to. + string organization_id = 1; // The installation. - string installation_id = 1; - // The team granted the installation's role at its root. - string team_id = 2; - // The organisation the installation belongs to; a grant is an organisation - // administrator's act, and the host checks the actor against it before it - // reads the installation. Learned from the second implementation (the host). - string organization_id = 3; + string installation_id = 2; + // The role. + string role = 3; + // The person; exactly one of person_id and team_id is set. + string person_id = 4; + // The team. + string team_id = 5; +} + +// ListRoleGrantsRequest names one installation. +message ListRoleGrantsRequest { + // The organization the installation belongs to. + string organization_id = 1; + // The installation. + string installation_id = 2; +} + +// ListRoleGrantsResponse is every grant on the installation. +message ListRoleGrantsResponse { + // The grants. + repeated RoleGrant grants = 1; +} + +// RoleGrant is one role held by one person or team. +message RoleGrant { + // The role. + string role = 1; + // The person, when a person holds it. + string person_id = 2; + // The team, when a team holds it. + string team_id = 3; } // ListRequest asks for one organisation's installations. @@ -87,17 +184,22 @@ message Installation { Status status = 4; // Bumped on every change that narrows or widens the installation. uint64 revision = 5; - // The role the installation holds. + // The role the installation holds when it acts for itself (its agent's + // role); no person or team inherits it. string role = 6; - // The teams granted it. - repeated string team_ids = 7; - // The principal the installation acts as for itself. - string agent_principal_id = 8; + reserved 7, 8; + reserved "team_ids", "agent_principal_id"; // The namespace the installation's own authored content belongs to (an // agent's source): the host names it, so a module never chooses which source // an installation may run. A module installed by composition is // ".". string namespace = 9; + // The solution principal: the identity the installation's data is owned by + // and the installation acts as for itself, a stable UUID per organization + // and target, reused by a reinstall. Empty for a module installation. + string principal_id = 10; + // The roles the solution declares, sorted. + repeated string roles = 11; // Status is the installation's lifecycle. enum Status { @@ -120,3 +222,90 @@ message InstallationNotice { // InstallationAck acknowledges the install step. message InstallationAck {} + +// ListOffersRequest names the organization whose administrator reads. +message ListOffersRequest { + string organization_id = 1; +} + +// ListOffersResponse is every delivered solution, by name. +message ListOffersResponse { + repeated Offer offers = 1; +} + +// Offer is one delivered solution. +message Offer { + // The solution's name in the composition: Install's target. + string target = 1; + // The delivery generation an approval binds. + uint64 generation = 2; +} + +// ReviewedOffer is one solution's authority offer and the consent that names it. +message ReviewedOffer { + // What Install takes, with a fresh operation_id; read with the rest. + ConsentApproval approval = 1; + string target = 2; + // Each role the solution declares, the installer role included. Every role + // scope is confined to this installation: to what it owns, or to the + // resources attached to it. + repeated OfferRole roles = 3; + // The solution's own installation at the organization; absent before Install. + Installation installation = 4; + // The permission ceiling: each module the solution calls, and the most it + // may ask there. No role reaches beyond it. + repeated OfferBinding ceiling = 5; + // False when this run delivered no prepared Runnable bindings: no tool is + // shown, which says nothing about what a composed binding would expose. + bool tools_composed = 6; +} + +// OfferBinding is one declared call from the solution to a module. +message OfferBinding { + string binding = 1; + // The module called. + string module = 2; + repeated OfferScope scopes = 3; +} + +// OfferRole is one declared role and what a grant of it reaches. +message OfferRole { + string name = 1; + // The scopes the role declares. + repeated OfferScope scopes = 2; + // Each composed operation the role's scopes cover. + repeated OfferCapability capabilities = 3; +} + +// OfferScope is one declared scope. +message OfferScope { + string resource_kind = 1; + repeated string actions = 2; + // True when the scope reaches only the resources attached to the + // installation, listed in resource_ids (none until one is attached); false + // when it reaches every resource the installation owns. + bool attached_only = 3; + repeated string resource_ids = 4; +} + +// OfferCapability is one module operation a role's scopes cover. +message OfferCapability { + string module = 1; + // The operation as the module's authority document names it. + string operation = 2; + // What a call to it must hold. + repeated OfferScope requires = 3; + // The tools composed for this operation, from its prepared Runnable + // bindings; empty when no binding exposes it as a tool. + repeated OfferTool tools = 4; +} + +// OfferTool is one tool-exposed Runnable of an operation. +message OfferTool { + // The tool's name, as its exposure declares it. + string name = 1; + // The prepared binding a caller names to run it. + string binding = 2; + // True when the tool changes data. + bool write = 3; +} diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index d1ece4677..2457f333d 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -324,8 +324,8 @@ service InstallationService { }; } - // Narrowing is witnessed in the policy log before commit, then atomically - // bumps the installation revision, cancels its org's work and records audit. + // A narrowing applies directly: it atomically bumps the installation + // revision, cancels its org's work and records audit. rpc NarrowInstallation(NarrowInstallationRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED @@ -567,9 +567,8 @@ service InstallationService { } // RevokeInstallationFromTeam removes one team grant at an installation's - // authority-root node. It is a narrowing, so it runs under the policy log - // exactly as RevokeScope does: a host that cannot witness the narrowing refuses - // it, and says so, rather than revoking unwitnessed. It removes the one grant + // authority-root node. It is a narrowing, so it applies directly, with its + // audit record in one transaction, exactly as RevokeScope does. It removes the one grant // at the authority root that granted_teams reports; a team that also reaches // the installation from a grant above keeps that reach. Organisation-admin, // with the same platform super administrator exception. diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index bd5a4083e..a7bbb928a 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -11,21 +11,25 @@ type generatedAuthorizationMetadata struct { } var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadata{ - "/host.authority.v1.AuthorityService/AttachResource": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333"}, - "/host.authority.v1.AuthorityService/AuthorizeTransfer": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333"}, + "/host.authority.v1.AuthorityService/AttachResource": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "b2d609e4b91c10163185e16e046a6226dae5dc348389c972de3fd1aa751a65f4"}, + "/host.authority.v1.AuthorityService/AuthorizeTransfer": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "6d1b59e7ade14cdc86f238fe6f01fb2405d199cd40e3bb5d6752baa3de9049f1"}, "/host.authority.v1.AuthorityService/Check": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "095caeb90e18271e84ac9c463cf7fe9f88d6a52b66818c16ccc48b156468cb58"}, "/host.authority.v1.AuthorityService/Delegate": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "27bb7389b96938acc7b63b815da274331ab4c98c2054b2e0f49ef5ac071c1a00"}, - "/host.authority.v1.AuthorityService/DetachResource": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333"}, + "/host.authority.v1.AuthorityService/DetachResource": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "c68549f47046a8a49dc4984ea824938c1b107e82c5040c5337dca4e861471e57"}, "/host.authority.v1.AuthorityService/GetDelegation": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "79921072468c5f99274839371a7ac834d857df6e5f0708942165c4b31084de26"}, - "/host.authority.v1.AuthorityService/Issue": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "e434a6f8983ad37edb91c8557f9303e74805913e5709e860b2c0645f283e1c3e"}, + "/host.authority.v1.AuthorityService/Issue": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "6b0b5e127b9cc3312281207bd4f8b70894ddde6b3d52ceceb21eec92d9a6d0ec"}, "/host.authority.v1.AuthorityService/Keys": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "7a256fcd03f73b9cb7495e12372e9d421cf3f81d480b111a4ebd64720623d8c3"}, "/host.authority.v1.AuthorityService/ListResources": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "095caeb90e18271e84ac9c463cf7fe9f88d6a52b66818c16ccc48b156468cb58"}, "/host.authority.v1.AuthorityService/RegisterAdmissionKey": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "f8afe4d676ff7ceaaf75c8394887b84432bc908f77a4faa59001df010232a6c3"}, "/host.authority.v1.AuthorityService/RevokeDelegation": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "e2b97a8216831372f7c37a97d1dad68c3e618411244b11ddab4557d2e52d16e0"}, "/host.composition.v1.CompositionService/ApplyManifest": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333"}, - "/host.installation.v1.InstallationService/GrantToTeam": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "6913e7f4514f63a3c59e03d2fbc07da1176734ab2cfced6f488740e06497927f"}, - "/host.installation.v1.InstallationService/Install": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "1bcc0da1738d725ece80382fc7c3ab4b571c0dbfdf4858377ccf3cb355c3640a"}, + "/host.installation.v1.InstallationService/Current": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "90ea62af2032838dca17493b92a392c82e93bbfc2e6a0ee11af81ee203a46333"}, + "/host.installation.v1.InstallationService/GrantRole": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "fc01eebe90648f5e096a7d4bbbd8f2aeb3d755bbd808c962352ed78936b12a4a"}, + "/host.installation.v1.InstallationService/Install": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "9848918817951cb839354fccd74257a45731b1ff0ae86aaa3af89dadc0f32a1b"}, "/host.installation.v1.InstallationService/List": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "b387739192840b24f771349cb2cded72d5f7eabd9d56c433b5b5937e6f4e58f0"}, + "/host.installation.v1.InstallationService/ListRoleGrants": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "79921072468c5f99274839371a7ac834d857df6e5f0708942165c4b31084de26"}, + "/host.installation.v1.InstallationService/Review": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "b387739192840b24f771349cb2cded72d5f7eabd9d56c433b5b5937e6f4e58f0"}, + "/host.installation.v1.InstallationService/RevokeRole": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "912f1c45666c1a2c3089918be4723973ca4fadf2dc78e5e80253d93499e74c92"}, "/host.session.v1.SessionService/Current": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "79921072468c5f99274839371a7ac834d857df6e5f0708942165c4b31084de26"}, "/host.session.v1.SessionService/ListFixturePeople": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "176449621c45228e8492ecaba6548d9d45b16dd416d645abe7898e2ca7ad4697"}, "/host.session.v1.SessionService/SignIn": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "99c6b88deef67aac00d037bac65ddca87cd92721d0061203183604bcd8ffef48"}, diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 48f936d53..b5754c8d9 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -5,7 +5,7 @@ go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda - github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 + github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04 github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/envoyproxy/go-control-plane/envoy v1.39.0 diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 9c9a03077..76176a9d7 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -46,8 +46,8 @@ github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVh github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= -github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= +github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04 h1:I3x8ihrzBMpv9UrLYc/3TmKl+lBvA5+xGcigENG/IR8= +github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261021192505-6fd56dae2e04/go.mod h1:LZl0I41APmLxG3Awdd4gn/pIhMOOgJqLuMuNGyXUJMg= github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= diff --git a/module/services/auth-gateway/code/routing_catalog_gen.go b/module/services/auth-gateway/code/routing_catalog_gen.go index f1abcca3a..be54e4eba 100644 --- a/module/services/auth-gateway/code/routing_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_catalog_gen.go @@ -229,9 +229,13 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/host.authority.v1.AuthorityService/Keys", UpstreamPath: "", Procedure: "/host.authority.v1.AuthorityService/Keys"}, {Service: "accounts_connect", Method: "POST", Path: "/host.authority.v1.AuthorityService/ListResources", UpstreamPath: "", Procedure: "/host.authority.v1.AuthorityService/ListResources"}, {Service: "accounts_connect", Method: "POST", Path: "/host.authority.v1.AuthorityService/RevokeDelegation", UpstreamPath: "", Procedure: "/host.authority.v1.AuthorityService/RevokeDelegation"}, - {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/GrantToTeam", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/GrantToTeam"}, + {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/Current", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/Current"}, + {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/GrantRole", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/GrantRole"}, {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/Install", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/Install"}, {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/List", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/List"}, + {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/ListRoleGrants", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/ListRoleGrants"}, + {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/Review", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/Review"}, + {Service: "accounts_connect", Method: "POST", Path: "/host.installation.v1.InstallationService/RevokeRole", UpstreamPath: "", Procedure: "/host.installation.v1.InstallationService/RevokeRole"}, {Service: "accounts_connect", Method: "POST", Path: "/host.session.v1.SessionService/Current", UpstreamPath: "", Procedure: "/host.session.v1.SessionService/Current"}, {Service: "accounts_connect", Method: "POST", Path: "/host.session.v1.SessionService/ListFixturePeople", UpstreamPath: "", Procedure: "/host.session.v1.SessionService/ListFixturePeople"}, {Service: "accounts_connect", Method: "POST", Path: "/host.session.v1.SessionService/SignIn", UpstreamPath: "", Procedure: "/host.session.v1.SessionService/SignIn"}, diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index c4566b279..f17b61f89 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:01434d2862c0bead380d184d1f9323c6349b5d228447d24be85b8be1b296cde3 + contract-digest: sha256:b818c862dd556e37c7ef597ba2109798a6e6ff957046100ab809575ff165e6a3 services: - AccessibleScopeService - AuditService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/authority/v1/authority_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/authority/v1/authority_pb.ts index 10117a19f..7a2f85624 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJnChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDCJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJ2ChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDBINCgVwcm9vZhgDIAEoCSJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -626,6 +626,22 @@ export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdm * @generated from field: bytes public_key = 2; */ publicKey: Uint8Array; + + /** + * Proof that the caller holds public_key's private half: a compact + * "." in unpadded base64url, the signature an Ed25519 + * signature by public_key over the payload, a JSON array of the domain + * "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + * of actor's credential, the hex public key, a fresh 32-byte hex nonce and + * the Unix second it was signed. The authority refuses a proof that does + * not verify, names another credential, key or audience, is older than 30 + * seconds or ahead of its clock, or repeats a nonce it has seen. It proves + * possession of the key being registered, which the registrant chose; that + * the registrant is the hop rests on actor and the per-instance pin. + * + * @generated from field: string proof = 3; + */ + proof: string; }; /** @@ -1044,14 +1060,36 @@ export const AuthorityService: GenService<{ /** * RegisterAdmissionKey is how the hop tells the authority the public key it * signs call admissions with, so Keys publishes it in admission_keys. The - * caller is the hop, proving it with its workload credential; any other - * workload is refused by name. Only the public half travels: the hop's seed - * never leaves it. Registering the same key again is idempotent and renews - * it, and a registration lapses at expires_at_unix unless renewed, so a hop - * renews while it runs (a restarted authority learns the key again at the - * next renewal) and a key no running hop signs with any more is withdrawn - * without being named. While a rotation overlaps, the retiring and the new - * key are both registered, and both published. + * caller is the hop, proving it with its workload credential, in the + * authority's own trust domain; any other workload is refused by name. + * Only the public half travels: the hop's seed never leaves it. + * + * The authority pins one admission key per hop instance (the instance its + * workload credential names): the first registration for an instance binds + * that key until its lease lapses, the same key renews it, and any other key + * for that instance is refused by name until then. A new key comes from a + * new hop instance, or from the same one once its previous key has lapsed. + * So a captured hop credential cannot replace or add a key for an instance + * that has registered. What it does not stop: a holder of a captured + * credential who registers before that instance's own first registration + * (the hop registers at boot), or in the window after an authority restart + * before the hop's next renewal, pins its own key for that instance; the + * hop's registration is then refused by name, and its log says so. A + * credential binding the key at issuance is what would close that window. + * + * Each request also carries a proof signed by the key it registers (see + * RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + * key's private half and binds the request to its credential, the + * authority's audience and a fresh, single-use nonce, so a registration + * cannot be replayed, retargeted or reused for another credential; it does + * not prove the registrant is the hop, since a registrant chooses its key. + * + * A registration lapses at expires_at_unix unless renewed, so a hop renews + * while it runs (a restarted authority learns the key again at the next + * renewal) and a key no running hop signs with any more is withdrawn + * without being named. While a rotation overlaps, the retiring key (its + * instance still renewing) and the new key (a new instance's) are both + * registered, and both published. * * @generated from rpc host.authority.v1.AuthorityService.RegisterAdmissionKey */ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/composition/v1/composition_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/composition/v1/composition_pb.ts index b9114e72f..8890a069d 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/composition/v1/composition_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/composition/v1/composition_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/composition/v1/composition.proto. */ export const file_host_composition_v1_composition: GenFile = /*@__PURE__*/ - fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIvEBChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJGCgZSb2xlVjESDAoEbmFtZRgBIAEoCRIuCgZzY29wZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSLQAQoJQmluZGluZ1YxEgoKAmlkGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEjUKDWludm9rZV9zY29wZXMYAyADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI3Cg9oZWFkbGVzc19zY29wZXMYBCADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI1Cg1sb29rdXBfc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjEiLwoOUmVzb3VyY2VLaW5kVjESDAoEa2luZBgBIAEoCRIPCgdhY3Rpb25zGAIgAygJIiYKBlBhZ2VWMRINCgV0aXRsZRgBIAEoCRINCgVvcmRlchgCIAEoBTJ8ChJDb21wb3NpdGlvblNlcnZpY2USZgoNQXBwbHlNYW5pZmVzdBIpLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlcXVlc3QaKi5ob3N0LmNvbXBvc2l0aW9uLnYxLkFwcGx5TWFuaWZlc3RSZXNwb25zZUIzWjFnYXRld2F5L3BrZy9nZW4vaG9zdC9jb21wb3NpdGlvbi92MTtjb21wb3NpdGlvbnYxYgZwcm90bzM", [file_host_authority_v1_work_context]); + fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIoYCChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMRITCgtvd25lcl9hd2FyZRgHIAEoCCJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSKJAQoGUm9sZVYxEgwKBG5hbWUYASABKAkSLgoGc2NvcGVzGAIgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESQQoZaW5zdGFsbGF0aW9uX293bmVkX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxItABCglCaW5kaW5nVjESCgoCaWQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSNQoNaW52b2tlX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjcKD2hlYWRsZXNzX3Njb3BlcxgEIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjUKDWxvb2t1cF9zY29wZXMYBSADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSIvCg5SZXNvdXJjZUtpbmRWMRIMCgRraW5kGAEgASgJEg8KB2FjdGlvbnMYAiADKAkiJgoGUGFnZVYxEg0KBXRpdGxlGAEgASgJEg0KBW9yZGVyGAIgASgFMnwKEkNvbXBvc2l0aW9uU2VydmljZRJmCg1BcHBseU1hbmlmZXN0EikuaG9zdC5jb21wb3NpdGlvbi52MS5BcHBseU1hbmlmZXN0UmVxdWVzdBoqLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlc3BvbnNlQjNaMWdhdGV3YXkvcGtnL2dlbi9ob3N0L2NvbXBvc2l0aW9uL3YxO2NvbXBvc2l0aW9udjFiBnByb3RvMw", [file_host_authority_v1_work_context]); /** * ApplyManifestRequest carries the manifest. @@ -129,6 +129,16 @@ export type AuthorityManifestV1 = Message<"host.composition.v1.AuthorityManifest * @generated from field: repeated host.composition.v1.RoleV1 roles = 6; */ roles: RoleV1[]; + + /** + * The module checks a resource's owner against an owner-selected scope + * (Claims.AllowsOwned): only an audience declaring it receives one, and a + * delivery withdrawing it refuses the owner-selected capabilities issued + * before. Every solution receiving its roles' scopes declares it. + * + * @generated from field: bool owner_aware = 7; + */ + ownerAware: boolean; }; /** @@ -294,7 +304,7 @@ export type ModuleManifestV1 = Message<"host.composition.v1.ModuleManifestV1"> & /** * The roles a solution declares a person may hold on an installation of it, * each within what the solution serves or explicitly consumes through - * invoke/lookup bindings; one is named installer, held by whoever installs it. + * invoke/lookup bindings; one is named installer, held only by whom it is granted to. * Headless-only scopes cannot supply a role grant. A module declares none. * * @generated from field: repeated host.composition.v1.RoleV1 roles = 8; @@ -324,6 +334,20 @@ export type RoleV1 = Message<"host.composition.v1.RoleV1"> & { * @generated from field: repeated host.authority.v1.WorkScopeV1 scopes = 2; */ scopes: WorkScopeV1[]; + + /** + * Scopes the role grants only on the resources the installation owns: those + * created through it (AuthorityService.AttachResource) or transferred after + * AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + * names a kind and actions and no resource ids, since ids are per + * installation, and is held to the same ceiling as scopes. The authority + * resolves each, for the installation the role is held on, into the same + * scope limited to the ids that installation owns of the kind; a kind it owns + * nothing of grants nothing, and never the whole kind. + * + * @generated from field: repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; + */ + installationOwnedScopes: WorkScopeV1[]; }; /** diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/installation/v1/installation_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/installation/v1/installation_pb.ts index 64a51d3fa..3ef395d61 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/installation/v1/installation_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/host/installation/v1/installation_pb.ts @@ -3,8 +3,10 @@ /* eslint-disable */ // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; @@ -14,7 +16,68 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/installation/v1/installation.proto. */ export const file_host_installation_v1_installation: GenFile = /*@__PURE__*/ - fileDesc("Cidob3N0L2luc3RhbGxhdGlvbi92MS9pbnN0YWxsYXRpb24ucHJvdG8SFGhvc3QuaW5zdGFsbGF0aW9uLnYxIpkBCg5JbnN0YWxsUmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkSDgoGdGFyZ2V0GAIgASgJEgwKBHJvbGUYAyABKAkSFwoPb3duZXJfcGVyc29uX2lkGAQgASgJEjcKCGFwcHJvdmFsGAUgASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsImkKD0NvbnNlbnRBcHByb3ZhbBIRCgl0YXJnZXRfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIZChFleHBlY3RlZF9yZXZpc2lvbhgDIAEoBBIUCgxvcGVyYXRpb25faWQYBCABKAkiVwoSR3JhbnRUb1RlYW1SZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIPCgd0ZWFtX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCSImCgtMaXN0UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkiSQoMTGlzdFJlc3BvbnNlEjkKDWluc3RhbGxhdGlvbnMYASADKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24ivAIKDEluc3RhbGxhdGlvbhIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEjkKBnN0YXR1cxgEIAEoDjIpLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbi5TdGF0dXMSEAoIcmV2aXNpb24YBSABKAQSDAoEcm9sZRgGIAEoCRIQCgh0ZWFtX2lkcxgHIAMoCRIaChJhZ2VudF9wcmluY2lwYWxfaWQYCCABKAkSEQoJbmFtZXNwYWNlGAkgASgJIlsKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABISCg5TVEFUVVNfUEVORElORxABEhEKDVNUQVRVU19BQ1RJVkUQAhISCg5TVEFUVVNfUkVWT0tFRBADIk4KEkluc3RhbGxhdGlvbk5vdGljZRI4CgxpbnN0YWxsYXRpb24YASABKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24iEQoPSW5zdGFsbGF0aW9uQWNrMpYCChNJbnN0YWxsYXRpb25TZXJ2aWNlElMKB0luc3RhbGwSJC5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJbCgtHcmFudFRvVGVhbRIoLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkdyYW50VG9UZWFtUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJNCgRMaXN0EiEuaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdFJlcXVlc3QaIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5MaXN0UmVzcG9uc2UycAoSSW5zdGFsbGFibGVTZXJ2aWNlEloKB0luc3RhbGwSKC5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb25Ob3RpY2UaJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb25BY2tCNVozZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvaW5zdGFsbGF0aW9uL3YxO2luc3RhbGxhdGlvbnYxYgZwcm90bzM"); + fileDesc("Cidob3N0L2luc3RhbGxhdGlvbi92MS9pbnN0YWxsYXRpb24ucHJvdG8SFGhvc3QuaW5zdGFsbGF0aW9uLnYxIkwKGkN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0EhQKDHBhcmVudF90b2tlbhgBIAEoCRIYChBhY3Rvcl9jcmVkZW50aWFsGAIgASgJIm8KE0N1cnJlbnRJbnN0YWxsYXRpb24SCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg4KBnRhcmdldBgDIAEoCRIQCghyZXZpc2lvbhgEIAEoBBIRCgluYW1lc3BhY2UYBSABKAkimQEKDkluc3RhbGxSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCRIOCgZ0YXJnZXQYAiABKAkSDAoEcm9sZRgDIAEoCRIXCg9vd25lcl9wZXJzb25faWQYBCABKAkSNwoIYXBwcm92YWwYBSABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWwiOAoNUmV2aWV3UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkSDgoGdGFyZ2V0GAIgASgJInkKD0NvbnNlbnRBcHByb3ZhbBIRCgl0YXJnZXRfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIZChFleHBlY3RlZF9yZXZpc2lvbhgDIAEoBBIUCgxvcGVyYXRpb25faWQYBCABKAkSDgoGZGlnZXN0GAUgASgJInYKEEdyYW50Um9sZVJlcXVlc3QSFwoPb3JnYW5pemF0aW9uX2lkGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRIMCgRyb2xlGAMgASgJEhEKCXBlcnNvbl9pZBgEIAEoCRIPCgd0ZWFtX2lkGAUgASgJIncKEVJldm9rZVJvbGVSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCRIXCg9pbnN0YWxsYXRpb25faWQYAiABKAkSDAoEcm9sZRgDIAEoCRIRCglwZXJzb25faWQYBCABKAkSDwoHdGVhbV9pZBgFIAEoCSJJChVMaXN0Um9sZUdyYW50c1JlcXVlc3QSFwoPb3JnYW5pemF0aW9uX2lkGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCSJJChZMaXN0Um9sZUdyYW50c1Jlc3BvbnNlEi8KBmdyYW50cxgBIAMoCzIfLmhvc3QuaW5zdGFsbGF0aW9uLnYxLlJvbGVHcmFudCI9CglSb2xlR3JhbnQSDAoEcm9sZRgBIAEoCRIRCglwZXJzb25faWQYAiABKAkSDwoHdGVhbV9pZBgDIAEoCSImCgtMaXN0UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkiSQoMTGlzdFJlc3BvbnNlEjkKDWluc3RhbGxhdGlvbnMYASADKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24i3QIKDEluc3RhbGxhdGlvbhIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEjkKBnN0YXR1cxgEIAEoDjIpLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbi5TdGF0dXMSEAoIcmV2aXNpb24YBSABKAQSDAoEcm9sZRgGIAEoCRIRCgluYW1lc3BhY2UYCSABKAkSFAoMcHJpbmNpcGFsX2lkGAogASgJEg0KBXJvbGVzGAsgAygJIlsKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABISCg5TVEFUVVNfUEVORElORxABEhEKDVNUQVRVU19BQ1RJVkUQAhISCg5TVEFUVVNfUkVWT0tFRBADSgQIBxAISgQICBAJUgh0ZWFtX2lkc1ISYWdlbnRfcHJpbmNpcGFsX2lkIk4KEkluc3RhbGxhdGlvbk5vdGljZRI4CgxpbnN0YWxsYXRpb24YASABKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24iEQoPSW5zdGFsbGF0aW9uQWNrIiwKEUxpc3RPZmZlcnNSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCSJBChJMaXN0T2ZmZXJzUmVzcG9uc2USKwoGb2ZmZXJzGAEgAygLMhsuaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXIiKwoFT2ZmZXISDgoGdGFyZ2V0GAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQijwIKDVJldmlld2VkT2ZmZXISNwoIYXBwcm92YWwYASABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWwSDgoGdGFyZ2V0GAIgASgJEi4KBXJvbGVzGAMgAygLMh8uaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJSb2xlEjgKDGluc3RhbGxhdGlvbhgEIAEoCzIiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhIzCgdjZWlsaW5nGAUgAygLMiIuaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJCaW5kaW5nEhYKDnRvb2xzX2NvbXBvc2VkGAYgASgIImEKDE9mZmVyQmluZGluZxIPCgdiaW5kaW5nGAEgASgJEg4KBm1vZHVsZRgCIAEoCRIwCgZzY29wZXMYAyADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlIogBCglPZmZlclJvbGUSDAoEbmFtZRgBIAEoCRIwCgZzY29wZXMYAiADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlEjsKDGNhcGFiaWxpdGllcxgDIAMoCzIlLmhvc3QuaW5zdGFsbGF0aW9uLnYxLk9mZmVyQ2FwYWJpbGl0eSJhCgpPZmZlclNjb3BlEhUKDXJlc291cmNlX2tpbmQYASABKAkSDwoHYWN0aW9ucxgCIAMoCRIVCg1hdHRhY2hlZF9vbmx5GAMgASgIEhQKDHJlc291cmNlX2lkcxgEIAMoCSKYAQoPT2ZmZXJDYXBhYmlsaXR5Eg4KBm1vZHVsZRgBIAEoCRIRCglvcGVyYXRpb24YAiABKAkSMgoIcmVxdWlyZXMYAyADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlEi4KBXRvb2xzGAQgAygLMh8uaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJUb29sIjkKCU9mZmVyVG9vbBIMCgRuYW1lGAEgASgJEg8KB2JpbmRpbmcYAiABKAkSDQoFd3JpdGUYAyABKAgymAUKE0luc3RhbGxhdGlvblNlcnZpY2USZgoHQ3VycmVudBIwLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GikuaG9zdC5pbnN0YWxsYXRpb24udjEuQ3VycmVudEluc3RhbGxhdGlvbhJTCgdJbnN0YWxsEiQuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbFJlcXVlc3QaIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24SVAoGUmV2aWV3EiMuaG9zdC5pbnN0YWxsYXRpb24udjEuUmV2aWV3UmVxdWVzdBolLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkNvbnNlbnRBcHByb3ZhbBJXCglHcmFudFJvbGUSJi5ob3N0Lmluc3RhbGxhdGlvbi52MS5HcmFudFJvbGVSZXF1ZXN0GiIuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uElkKClJldm9rZVJvbGUSJy5ob3N0Lmluc3RhbGxhdGlvbi52MS5SZXZva2VSb2xlUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJrCg5MaXN0Um9sZUdyYW50cxIrLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSb2xlR3JhbnRzUmVxdWVzdBosLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSb2xlR3JhbnRzUmVzcG9uc2USTQoETGlzdBIhLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSZXF1ZXN0GiIuaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdFJlc3BvbnNlMnAKEkluc3RhbGxhYmxlU2VydmljZRJaCgdJbnN0YWxsEiguaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uTm90aWNlGiUuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uQWNrMsgBCgxPZmZlclNlcnZpY2USXwoKTGlzdE9mZmVycxInLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RPZmZlcnNSZXF1ZXN0GiguaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdE9mZmVyc1Jlc3BvbnNlElcKC1Jldmlld09mZmVyEiMuaG9zdC5pbnN0YWxsYXRpb24udjEuUmV2aWV3UmVxdWVzdBojLmhvc3QuaW5zdGFsbGF0aW9uLnYxLlJldmlld2VkT2ZmZXJCNVozZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvaW5zdGFsbGF0aW9uL3YxO2luc3RhbGxhdGlvbnYxYgZwcm90bzM"); + +/** + * @generated from message host.installation.v1.CurrentInstallationRequest + */ +export type CurrentInstallationRequest = Message<"host.installation.v1.CurrentInstallationRequest"> & { + /** + * @generated from field: string parent_token = 1; + */ + parentToken: string; + + /** + * @generated from field: string actor_credential = 2; + */ + actorCredential: string; +}; + +/** + * Describes the message host.installation.v1.CurrentInstallationRequest. + * Use `create(CurrentInstallationRequestSchema)` to create a new message. + */ +export const CurrentInstallationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 0); + +/** + * CurrentInstallation is request-local metadata, never a lease or consent. + * + * @generated from message host.installation.v1.CurrentInstallation + */ +export type CurrentInstallation = Message<"host.installation.v1.CurrentInstallation"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string organization_id = 2; + */ + organizationId: string; + + /** + * @generated from field: string target = 3; + */ + target: string; + + /** + * @generated from field: uint64 revision = 4; + */ + revision: bigint; + + /** + * @generated from field: string namespace = 5; + */ + namespace: string; +}; + +/** + * Describes the message host.installation.v1.CurrentInstallation. + * Use `create(CurrentInstallationSchema)` to create a new message. + */ +export const CurrentInstallationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 1); /** * InstallRequest asks to install one target at one organisation. @@ -51,7 +114,7 @@ export type InstallRequest = Message<"host.installation.v1.InstallRequest"> & { ownerPersonId: string; /** - * The exact catalogue offer and installation state the administrator approved. + * Required: the exact offer reviewed by the administrator; unchanged on retry. * * @generated from field: host.installation.v1.ConsentApproval approval = 5; */ @@ -63,13 +126,36 @@ export type InstallRequest = Message<"host.installation.v1.InstallRequest"> & { * Use `create(InstallRequestSchema)` to create a new message. */ export const InstallRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 0); + messageDesc(file_host_installation_v1_installation, 2); /** - * ConsentApproval binds an explicit approval to the state reviewed by the admin. - * Obtain target_id and generation from ListAvailableSolutions, and revision from - * the installation listing (zero means absent). Keep these values and operation_id - * unchanged on retry; a refusal requires a fresh review and operation. + * ReviewRequest names the offer an administrator intends to review. + * + * @generated from message host.installation.v1.ReviewRequest + */ +export type ReviewRequest = Message<"host.installation.v1.ReviewRequest"> & { + /** + * @generated from field: string organization_id = 1; + */ + organizationId: string; + + /** + * @generated from field: string target = 2; + */ + target: string; +}; + +/** + * Describes the message host.installation.v1.ReviewRequest. + * Use `create(ReviewRequestSchema)` to create a new message. + */ +export const ReviewRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 3); + +/** + * ConsentApproval binds consent to an immutable target, its reviewed delivery and + * the installation revision (zero if absent). Review leaves operation_id empty; + * the caller supplies a fresh UUID and preserves the entire request on retry. * * @generated from message host.installation.v1.ConsentApproval */ @@ -93,6 +179,13 @@ export type ConsentApproval = Message<"host.installation.v1.ConsentApproval"> & * @generated from field: string operation_id = 4; */ operationId: string; + + /** + * sha256 digest of the reviewed authority offer. + * + * @generated from field: string digest = 5; + */ + digest: string; }; /** @@ -100,44 +193,192 @@ export type ConsentApproval = Message<"host.installation.v1.ConsentApproval"> & * Use `create(ConsentApprovalSchema)` to create a new message. */ export const ConsentApprovalSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 1); + messageDesc(file_host_installation_v1_installation, 4); /** - * GrantToTeamRequest exposes an installation to a team. + * GrantRoleRequest grants one role to exactly one person or team. * - * @generated from message host.installation.v1.GrantToTeamRequest + * @generated from message host.installation.v1.GrantRoleRequest */ -export type GrantToTeamRequest = Message<"host.installation.v1.GrantToTeamRequest"> & { +export type GrantRoleRequest = Message<"host.installation.v1.GrantRoleRequest"> & { + /** + * The organization the installation belongs to; a request naming another + * organization's installation is refused naming the organization, before + * the caller's authority is read. + * + * @generated from field: string organization_id = 1; + */ + organizationId: string; + /** * The installation. * - * @generated from field: string installation_id = 1; + * @generated from field: string installation_id = 2; */ installationId: string; /** - * The team granted the installation's role at its root. + * One of the roles the solution declares. + * + * @generated from field: string role = 3; + */ + role: string; + + /** + * The person granted, a member of the organization; exactly one of + * person_id and team_id is set. + * + * @generated from field: string person_id = 4; + */ + personId: string; + + /** + * The team granted, a team of the organization. * - * @generated from field: string team_id = 2; + * @generated from field: string team_id = 5; */ teamId: string; +}; +/** + * Describes the message host.installation.v1.GrantRoleRequest. + * Use `create(GrantRoleRequestSchema)` to create a new message. + */ +export const GrantRoleRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 5); + +/** + * RevokeRoleRequest ends one grant, named as GrantRole named it. + * + * @generated from message host.installation.v1.RevokeRoleRequest + */ +export type RevokeRoleRequest = Message<"host.installation.v1.RevokeRoleRequest"> & { /** - * The organisation the installation belongs to; a grant is an organisation - * administrator's act, and the host checks the actor against it before it - * reads the installation. Learned from the second implementation (the host). + * The organization the installation belongs to. * - * @generated from field: string organization_id = 3; + * @generated from field: string organization_id = 1; */ organizationId: string; + + /** + * The installation. + * + * @generated from field: string installation_id = 2; + */ + installationId: string; + + /** + * The role. + * + * @generated from field: string role = 3; + */ + role: string; + + /** + * The person; exactly one of person_id and team_id is set. + * + * @generated from field: string person_id = 4; + */ + personId: string; + + /** + * The team. + * + * @generated from field: string team_id = 5; + */ + teamId: string; }; /** - * Describes the message host.installation.v1.GrantToTeamRequest. - * Use `create(GrantToTeamRequestSchema)` to create a new message. + * Describes the message host.installation.v1.RevokeRoleRequest. + * Use `create(RevokeRoleRequestSchema)` to create a new message. */ -export const GrantToTeamRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 2); +export const RevokeRoleRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 6); + +/** + * ListRoleGrantsRequest names one installation. + * + * @generated from message host.installation.v1.ListRoleGrantsRequest + */ +export type ListRoleGrantsRequest = Message<"host.installation.v1.ListRoleGrantsRequest"> & { + /** + * The organization the installation belongs to. + * + * @generated from field: string organization_id = 1; + */ + organizationId: string; + + /** + * The installation. + * + * @generated from field: string installation_id = 2; + */ + installationId: string; +}; + +/** + * Describes the message host.installation.v1.ListRoleGrantsRequest. + * Use `create(ListRoleGrantsRequestSchema)` to create a new message. + */ +export const ListRoleGrantsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 7); + +/** + * ListRoleGrantsResponse is every grant on the installation. + * + * @generated from message host.installation.v1.ListRoleGrantsResponse + */ +export type ListRoleGrantsResponse = Message<"host.installation.v1.ListRoleGrantsResponse"> & { + /** + * The grants. + * + * @generated from field: repeated host.installation.v1.RoleGrant grants = 1; + */ + grants: RoleGrant[]; +}; + +/** + * Describes the message host.installation.v1.ListRoleGrantsResponse. + * Use `create(ListRoleGrantsResponseSchema)` to create a new message. + */ +export const ListRoleGrantsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 8); + +/** + * RoleGrant is one role held by one person or team. + * + * @generated from message host.installation.v1.RoleGrant + */ +export type RoleGrant = Message<"host.installation.v1.RoleGrant"> & { + /** + * The role. + * + * @generated from field: string role = 1; + */ + role: string; + + /** + * The person, when a person holds it. + * + * @generated from field: string person_id = 2; + */ + personId: string; + + /** + * The team, when a team holds it. + * + * @generated from field: string team_id = 3; + */ + teamId: string; +}; + +/** + * Describes the message host.installation.v1.RoleGrant. + * Use `create(RoleGrantSchema)` to create a new message. + */ +export const RoleGrantSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 9); /** * ListRequest asks for one organisation's installations. @@ -158,7 +399,7 @@ export type ListRequest = Message<"host.installation.v1.ListRequest"> & { * Use `create(ListRequestSchema)` to create a new message. */ export const ListRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 3); + messageDesc(file_host_installation_v1_installation, 10); /** * ListResponse lists installations. @@ -179,7 +420,7 @@ export type ListResponse = Message<"host.installation.v1.ListResponse"> & { * Use `create(ListResponseSchema)` to create a new message. */ export const ListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 4); + messageDesc(file_host_installation_v1_installation, 11); /** * Installation is one organisation's installation of one target. @@ -223,35 +464,38 @@ export type Installation = Message<"host.installation.v1.Installation"> & { revision: bigint; /** - * The role the installation holds. + * The role the installation holds when it acts for itself (its agent's + * role); no person or team inherits it. * * @generated from field: string role = 6; */ role: string; /** - * The teams granted it. + * The namespace the installation's own authored content belongs to (an + * agent's source): the host names it, so a module never chooses which source + * an installation may run. A module installed by composition is + * ".". * - * @generated from field: repeated string team_ids = 7; + * @generated from field: string namespace = 9; */ - teamIds: string[]; + namespace: string; /** - * The principal the installation acts as for itself. + * The solution principal: the identity the installation's data is owned by + * and the installation acts as for itself, a stable UUID per organization + * and target, reused by a reinstall. Empty for a module installation. * - * @generated from field: string agent_principal_id = 8; + * @generated from field: string principal_id = 10; */ - agentPrincipalId: string; + principalId: string; /** - * The namespace the installation's own authored content belongs to (an - * agent's source): the host names it, so a module never chooses which source - * an installation may run. A module installed by composition is - * ".". + * The roles the solution declares, sorted. * - * @generated from field: string namespace = 9; + * @generated from field: repeated string roles = 11; */ - namespace: string; + roles: string[]; }; /** @@ -259,7 +503,7 @@ export type Installation = Message<"host.installation.v1.Installation"> & { * Use `create(InstallationSchema)` to create a new message. */ export const InstallationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 5); + messageDesc(file_host_installation_v1_installation, 12); /** * Status is the installation's lifecycle. @@ -300,7 +544,7 @@ export enum Installation_Status { * Describes the enum host.installation.v1.Installation.Status. */ export const Installation_StatusSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_host_installation_v1_installation, 5, 0); + enumDesc(file_host_installation_v1_installation, 12, 0); /** * InstallationNotice is what the host hands the solution's install step. @@ -321,7 +565,7 @@ export type InstallationNotice = Message<"host.installation.v1.InstallationNotic * Use `create(InstallationNoticeSchema)` to create a new message. */ export const InstallationNoticeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 6); + messageDesc(file_host_installation_v1_installation, 13); /** * InstallationAck acknowledges the install step. @@ -336,7 +580,309 @@ export type InstallationAck = Message<"host.installation.v1.InstallationAck"> & * Use `create(InstallationAckSchema)` to create a new message. */ export const InstallationAckSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 7); + messageDesc(file_host_installation_v1_installation, 14); + +/** + * ListOffersRequest names the organization whose administrator reads. + * + * @generated from message host.installation.v1.ListOffersRequest + */ +export type ListOffersRequest = Message<"host.installation.v1.ListOffersRequest"> & { + /** + * @generated from field: string organization_id = 1; + */ + organizationId: string; +}; + +/** + * Describes the message host.installation.v1.ListOffersRequest. + * Use `create(ListOffersRequestSchema)` to create a new message. + */ +export const ListOffersRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 15); + +/** + * ListOffersResponse is every delivered solution, by name. + * + * @generated from message host.installation.v1.ListOffersResponse + */ +export type ListOffersResponse = Message<"host.installation.v1.ListOffersResponse"> & { + /** + * @generated from field: repeated host.installation.v1.Offer offers = 1; + */ + offers: Offer[]; +}; + +/** + * Describes the message host.installation.v1.ListOffersResponse. + * Use `create(ListOffersResponseSchema)` to create a new message. + */ +export const ListOffersResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 16); + +/** + * Offer is one delivered solution. + * + * @generated from message host.installation.v1.Offer + */ +export type Offer = Message<"host.installation.v1.Offer"> & { + /** + * The solution's name in the composition: Install's target. + * + * @generated from field: string target = 1; + */ + target: string; + + /** + * The delivery generation an approval binds. + * + * @generated from field: uint64 generation = 2; + */ + generation: bigint; +}; + +/** + * Describes the message host.installation.v1.Offer. + * Use `create(OfferSchema)` to create a new message. + */ +export const OfferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 17); + +/** + * ReviewedOffer is one solution's authority offer and the consent that names it. + * + * @generated from message host.installation.v1.ReviewedOffer + */ +export type ReviewedOffer = Message<"host.installation.v1.ReviewedOffer"> & { + /** + * What Install takes, with a fresh operation_id; read with the rest. + * + * @generated from field: host.installation.v1.ConsentApproval approval = 1; + */ + approval?: ConsentApproval; + + /** + * @generated from field: string target = 2; + */ + target: string; + + /** + * Each role the solution declares, the installer role included. Every role + * scope is confined to this installation: to what it owns, or to the + * resources attached to it. + * + * @generated from field: repeated host.installation.v1.OfferRole roles = 3; + */ + roles: OfferRole[]; + + /** + * The solution's own installation at the organization; absent before Install. + * + * @generated from field: host.installation.v1.Installation installation = 4; + */ + installation?: Installation; + + /** + * The permission ceiling: each module the solution calls, and the most it + * may ask there. No role reaches beyond it. + * + * @generated from field: repeated host.installation.v1.OfferBinding ceiling = 5; + */ + ceiling: OfferBinding[]; + + /** + * False when this run delivered no prepared Runnable bindings: no tool is + * shown, which says nothing about what a composed binding would expose. + * + * @generated from field: bool tools_composed = 6; + */ + toolsComposed: boolean; +}; + +/** + * Describes the message host.installation.v1.ReviewedOffer. + * Use `create(ReviewedOfferSchema)` to create a new message. + */ +export const ReviewedOfferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 18); + +/** + * OfferBinding is one declared call from the solution to a module. + * + * @generated from message host.installation.v1.OfferBinding + */ +export type OfferBinding = Message<"host.installation.v1.OfferBinding"> & { + /** + * @generated from field: string binding = 1; + */ + binding: string; + + /** + * The module called. + * + * @generated from field: string module = 2; + */ + module: string; + + /** + * @generated from field: repeated host.installation.v1.OfferScope scopes = 3; + */ + scopes: OfferScope[]; +}; + +/** + * Describes the message host.installation.v1.OfferBinding. + * Use `create(OfferBindingSchema)` to create a new message. + */ +export const OfferBindingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 19); + +/** + * OfferRole is one declared role and what a grant of it reaches. + * + * @generated from message host.installation.v1.OfferRole + */ +export type OfferRole = Message<"host.installation.v1.OfferRole"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * The scopes the role declares. + * + * @generated from field: repeated host.installation.v1.OfferScope scopes = 2; + */ + scopes: OfferScope[]; + + /** + * Each composed operation the role's scopes cover. + * + * @generated from field: repeated host.installation.v1.OfferCapability capabilities = 3; + */ + capabilities: OfferCapability[]; +}; + +/** + * Describes the message host.installation.v1.OfferRole. + * Use `create(OfferRoleSchema)` to create a new message. + */ +export const OfferRoleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 20); + +/** + * OfferScope is one declared scope. + * + * @generated from message host.installation.v1.OfferScope + */ +export type OfferScope = Message<"host.installation.v1.OfferScope"> & { + /** + * @generated from field: string resource_kind = 1; + */ + resourceKind: string; + + /** + * @generated from field: repeated string actions = 2; + */ + actions: string[]; + + /** + * True when the scope reaches only the resources attached to the + * installation, listed in resource_ids (none until one is attached); false + * when it reaches every resource the installation owns. + * + * @generated from field: bool attached_only = 3; + */ + attachedOnly: boolean; + + /** + * @generated from field: repeated string resource_ids = 4; + */ + resourceIds: string[]; +}; + +/** + * Describes the message host.installation.v1.OfferScope. + * Use `create(OfferScopeSchema)` to create a new message. + */ +export const OfferScopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 21); + +/** + * OfferCapability is one module operation a role's scopes cover. + * + * @generated from message host.installation.v1.OfferCapability + */ +export type OfferCapability = Message<"host.installation.v1.OfferCapability"> & { + /** + * @generated from field: string module = 1; + */ + module: string; + + /** + * The operation as the module's authority document names it. + * + * @generated from field: string operation = 2; + */ + operation: string; + + /** + * What a call to it must hold. + * + * @generated from field: repeated host.installation.v1.OfferScope requires = 3; + */ + requires: OfferScope[]; + + /** + * The tools composed for this operation, from its prepared Runnable + * bindings; empty when no binding exposes it as a tool. + * + * @generated from field: repeated host.installation.v1.OfferTool tools = 4; + */ + tools: OfferTool[]; +}; + +/** + * Describes the message host.installation.v1.OfferCapability. + * Use `create(OfferCapabilitySchema)` to create a new message. + */ +export const OfferCapabilitySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 22); + +/** + * OfferTool is one tool-exposed Runnable of an operation. + * + * @generated from message host.installation.v1.OfferTool + */ +export type OfferTool = Message<"host.installation.v1.OfferTool"> & { + /** + * The tool's name, as its exposure declares it. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * The prepared binding a caller names to run it. + * + * @generated from field: string binding = 2; + */ + binding: string; + + /** + * True when the tool changes data. + * + * @generated from field: bool write = 3; + */ + write: boolean; +}; + +/** + * Describes the message host.installation.v1.OfferTool. + * Use `create(OfferToolSchema)` to create a new message. + */ +export const OfferToolSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 23); /** * InstallationService is implemented by the host and by the local authority. @@ -344,6 +890,18 @@ export const InstallationAckSchema: GenMessage = /*@__PURE__*/ * @generated from service host.installation.v1.InstallationService */ export const InstallationService: GenService<{ + /** + * Current reads only the authenticated module's own active installation, + * in the tenant of a current parent addressed to that module. It grants no + * artifact approval or content access and exposes no roles or grants. + * + * @generated from rpc host.installation.v1.InstallationService.Current + */ + current: { + methodKind: "unary"; + input: typeof CurrentInstallationRequestSchema; + output: typeof CurrentInstallationSchema; + }, /** * Install composes an installation: its agent principal, its root scope and * the row, pending until the target acknowledges its install step. @@ -356,15 +914,50 @@ export const InstallationService: GenService<{ output: typeof InstallationSchema; }, /** - * GrantToTeam exposes an installation to a team under the installation's role. + * Review reads the current offer and installation revision without granting consent. + * + * @generated from rpc host.installation.v1.InstallationService.Review + */ + review: { + methodKind: "unary"; + input: typeof ReviewRequestSchema; + output: typeof ConsentApprovalSchema; + }, + /** + * GrantRole grants a person or a team one of the solution's roles on the + * installation: the access. Idempotent; it leaves existing capabilities + * valid. An administrator of the installation's organization, or a holder + * of the installer role on that installation, grants. * - * @generated from rpc host.installation.v1.InstallationService.GrantToTeam + * @generated from rpc host.installation.v1.InstallationService.GrantRole */ - grantToTeam: { + grantRole: { methodKind: "unary"; - input: typeof GrantToTeamRequestSchema; + input: typeof GrantRoleRequestSchema; output: typeof InstallationSchema; }, + /** + * RevokeRole ends one grant; every capability its subject holds from it is + * refused at once. Idempotent; the same callers as GrantRole. + * + * @generated from rpc host.installation.v1.InstallationService.RevokeRole + */ + revokeRole: { + methodKind: "unary"; + input: typeof RevokeRoleRequestSchema; + output: typeof InstallationSchema; + }, + /** + * ListRoleGrants answers who holds which role on the installation; the same + * callers as GrantRole. + * + * @generated from rpc host.installation.v1.InstallationService.ListRoleGrants + */ + listRoleGrants: { + methodKind: "unary"; + input: typeof ListRoleGrantsRequestSchema; + output: typeof ListRoleGrantsResponseSchema; + }, /** * List answers what one organisation has installed: an administrator's read. A * member's view, which installed solutions they may use, is their navigation and @@ -399,3 +992,40 @@ export const InstallableService: GenService<{ }, }> = /*@__PURE__*/ serviceDesc(file_host_installation_v1_installation, 1); + +/** + * OfferService is the local authority's setup read, served beside + * InstallationService and not part of codefly.dev/installation: what solutions + * this run delivered and, for one, what installing it would grant. A host + * answers its own product's reads. It grants nothing; Install, GrantRole and + * RevokeRole stay the only changes, each decided by the authority. + * + * @generated from service host.installation.v1.OfferService + */ +export const OfferService: GenService<{ + /** + * ListOffers lists the solutions delivered to this run that an administrator + * of the organization may install. A module is installed by composition and + * is never offered. + * + * @generated from rpc host.installation.v1.OfferService.ListOffers + */ + listOffers: { + methodKind: "unary"; + input: typeof ListOffersRequestSchema; + output: typeof ListOffersResponseSchema; + }, + /** + * ReviewOffer reads one solution's roles, what each grants and the tools + * those grants make eligible, with the consent Install takes, from one read + * of the authority: the approval names exactly the offer displayed. + * + * @generated from rpc host.installation.v1.OfferService.ReviewOffer + */ + reviewOffer: { + methodKind: "unary"; + input: typeof ReviewRequestSchema; + output: typeof ReviewedOfferSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_host_installation_v1_installation, 2); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index 152ec0485..938db7456 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -1400,8 +1400,8 @@ export const InstallationService: GenService<{ output: typeof EmptySchema; }, /** - * Narrowing is witnessed in the policy log before commit, then atomically - * bumps the installation revision, cancels its org's work and records audit. + * A narrowing applies directly: it atomically bumps the installation + * revision, cancels its org's work and records audit. * * @generated from rpc saas.accounts.v1.InstallationService.NarrowInstallation */ @@ -1532,9 +1532,8 @@ export const InstallationService: GenService<{ }, /** * RevokeInstallationFromTeam removes one team grant at an installation's - * authority-root node. It is a narrowing, so it runs under the policy log - * exactly as RevokeScope does: a host that cannot witness the narrowing refuses - * it, and says so, rather than revoking unwitnessed. It removes the one grant + * authority-root node. It is a narrowing, so it applies directly, with its + * audit record in one transaction, exactly as RevokeScope does. It removes the one grant * at the authority root that granted_teams reports; a team that also reaches * the installation from a grant above keeps that reach. Organisation-admin, * with the same platform super administrator exception. diff --git a/module/services/frontend/code/src/features/solution-installations/service/mutations.ts b/module/services/frontend/code/src/features/solution-installations/service/mutations.ts index 2a13e3353..a29b882fc 100644 --- a/module/services/frontend/code/src/features/solution-installations/service/mutations.ts +++ b/module/services/frontend/code/src/features/solution-installations/service/mutations.ts @@ -43,10 +43,8 @@ export function useInstallSolution(orgId: string) { } /** - * Uninstalls one installation. The host may refuse — today it refuses every - * uninstall, because the policy log that must witness a narrowing has no - * transport — and the caller shows that refusal; nothing here turns it into a - * success. + * Uninstalls one installation. The host may refuse, and the caller shows that + * refusal; nothing here turns it into a success. */ export function useUninstallSolution(orgId: string) { const service = useInstallationService(); @@ -95,9 +93,8 @@ export function useGrantInstallationToTeam(orgId: string) { } /** - * Removes one team's grant at an installation. A narrowing the host may refuse — - * it does when its policy log cannot witness the change — and the caller shows - * that refusal as the host words it. + * Removes one team's grant at an installation. The host may refuse it, and the + * caller shows that refusal as the host words it. */ export function useRevokeInstallationFromTeam(orgId: string) { const service = useInstallationService(); diff --git a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx index 48f4089c8..cc5b656a6 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx @@ -538,7 +538,7 @@ describe("SolutionInstallationsPage admin container", () => { { code: "failed_precondition", message: - "cannot uninstall solution: policy log append is not witnessed: no transport is configured", + "cannot uninstall solution: installation 0190a7c2 is still being installed", }, { status: 400 }, ); @@ -554,7 +554,7 @@ describe("SolutionInstallationsPage admin container", () => { fireEvent.click(within(confirm).getByRole("button", { name: "Uninstall" })); const refusal = await screen.findByText( - "cannot uninstall solution: policy log append is not witnessed: no transport is configured", + "cannot uninstall solution: installation 0190a7c2 is still being installed", ); expect( screen.getByText("The host refused to uninstall expenses"), @@ -689,7 +689,7 @@ describe("SolutionInstallationsPage admin container", () => { { code: "failed_precondition", message: - "cannot revoke installation from team: policy log unreachable: this host has no policy log, so authority cannot be narrowed", + "cannot revoke installation from team: role \"viewer\" is not granted to that team here", }, { status: 400 }, ); @@ -711,7 +711,7 @@ describe("SolutionInstallationsPage admin container", () => { ).toBeTruthy(); expect( screen.getByText( - "cannot revoke installation from team: policy log unreachable: this host has no policy log, so authority cannot be narrowed", + "cannot revoke installation from team: role \"viewer\" is not granted to that team here", ), ).toBeTruthy(); expect(toast.success).not.toHaveBeenCalled(); diff --git a/module/services/frontend/code/src/gen/host/authority/v1/authority_pb.ts b/module/services/frontend/code/src/gen/host/authority/v1/authority_pb.ts index 460f1e582..eda18db7f 100644 --- a/module/services/frontend/code/src/gen/host/authority/v1/authority_pb.ts +++ b/module/services/frontend/code/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJnChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDCJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CvQEKFWNvbS5ob3N0LmF1dGhvcml0eS52MUIOQXV0aG9yaXR5UHJvdG9QAVouYWNjb3VudHMvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MaICA0hBWKoCEUhvc3QuQXV0aG9yaXR5LlYxygIRSG9zdFxBdXRob3JpdHlcVjHiAh1Ib3N0XEF1dGhvcml0eVxWMVxHUEJNZXRhZGF0YeoCE0hvc3Q6OkF1dGhvcml0eTo6VjFiBnByb3RvMw", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCKAAQoMS2V5c1Jlc3BvbnNlEg4KBmlzc3VlchgBIAEoCRIqCgRrZXlzGAIgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5EjQKDmFkbWlzc2lvbl9rZXlzGAMgAygLMhwuaG9zdC5hdXRob3JpdHkudjEuUHVibGljS2V5IlMKCVB1YmxpY0tleRIOCgZrZXlfaWQYASABKAkSEQoJYWxnb3JpdGhtGAIgASgJEhIKCnB1YmxpY19rZXkYAyABKAwSDwoHcHVycG9zZRgEIAEoCSJ2ChtSZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEgoKcHVibGljX2tleRgCIAEoDBINCgVwcm9vZhgDIAEoCSJiChxSZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEikKA2tleRgBIAEoCzIcLmhvc3QuYXV0aG9yaXR5LnYxLlB1YmxpY0tleRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMiXgoUTGlzdFJlc291cmNlc1JlcXVlc3QSDQoFdG9rZW4YASABKAkSEAoIYXVkaWVuY2UYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRIOCgZhY3Rpb24YBCABKAkiSQoVTGlzdFJlc291cmNlc1Jlc3BvbnNlEjAKBmdyYW50cxgBIAMoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLlJlc291cmNlR3JhbnQiiAEKFUF0dGFjaFJlc291cmNlUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJIjEKFkF0dGFjaFJlc291cmNlUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIpIBChVEZXRhY2hSZXNvdXJjZVJlcXVlc3QSNAoFYWN0b3IYASABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWRGV0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiuwEKGEF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBINCgV0b2tlbhgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIVCg1yZXNvdXJjZV9raW5kGAMgASgJEhMKC3Jlc291cmNlX2lkGAQgASgJEhUKDWZyb21fb3duZXJfaWQYBSABKAkSFwoPdG9fcHJpbmNpcGFsX2lkGAYgASgJIkgKGUF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USFAoMcHJpbmNpcGFsX2lkGAEgASgJEhUKDWF1dGhvcml6ZWRfYnkYAiABKAkidQoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIABIZChFyZXNvdXJjZV9vd25lcl9pZBgFIAEoCUIHCgVtYXRjaDLjCAoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USdwoUUmVnaXN0ZXJBZG1pc3Npb25LZXkSLi5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlcXVlc3QaLy5ob3N0LmF1dGhvcml0eS52MS5SZWdpc3RlckFkbWlzc2lvbktleVJlc3BvbnNlEmIKDUxpc3RSZXNvdXJjZXMSJy5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVxdWVzdBooLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXNwb25zZRJlCg5BdHRhY2hSZXNvdXJjZRIoLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVxdWVzdBopLmhvc3QuYXV0aG9yaXR5LnYxLkF0dGFjaFJlc291cmNlUmVzcG9uc2USZQoORGV0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5EZXRhY2hSZXNvdXJjZVJlc3BvbnNlEm4KEUF1dGhvcml6ZVRyYW5zZmVyEisuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0GiwuaG9zdC5hdXRob3JpdHkudjEuQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CvQEKFWNvbS5ob3N0LmF1dGhvcml0eS52MUIOQXV0aG9yaXR5UHJvdG9QAVouYWNjb3VudHMvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MaICA0hBWKoCEUhvc3QuQXV0aG9yaXR5LlYxygIRSG9zdFxBdXRob3JpdHlcVjHiAh1Ib3N0XEF1dGhvcml0eVxWMVxHUEJNZXRhZGF0YeoCE0hvc3Q6OkF1dGhvcml0eTo6VjFiBnByb3RvMw", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -626,6 +626,22 @@ export type RegisterAdmissionKeyRequest = Message<"host.authority.v1.RegisterAdm * @generated from field: bytes public_key = 2; */ publicKey: Uint8Array; + + /** + * Proof that the caller holds public_key's private half: a compact + * "." in unpadded base64url, the signature an Ed25519 + * signature by public_key over the payload, a JSON array of the domain + * "codefly-hop-registration-v1", the authority's audience, the hex SHA-256 + * of actor's credential, the hex public key, a fresh 32-byte hex nonce and + * the Unix second it was signed. The authority refuses a proof that does + * not verify, names another credential, key or audience, is older than 30 + * seconds or ahead of its clock, or repeats a nonce it has seen. It proves + * possession of the key being registered, which the registrant chose; that + * the registrant is the hop rests on actor and the per-instance pin. + * + * @generated from field: string proof = 3; + */ + proof: string; }; /** @@ -1044,14 +1060,36 @@ export const AuthorityService: GenService<{ /** * RegisterAdmissionKey is how the hop tells the authority the public key it * signs call admissions with, so Keys publishes it in admission_keys. The - * caller is the hop, proving it with its workload credential; any other - * workload is refused by name. Only the public half travels: the hop's seed - * never leaves it. Registering the same key again is idempotent and renews - * it, and a registration lapses at expires_at_unix unless renewed, so a hop - * renews while it runs (a restarted authority learns the key again at the - * next renewal) and a key no running hop signs with any more is withdrawn - * without being named. While a rotation overlaps, the retiring and the new - * key are both registered, and both published. + * caller is the hop, proving it with its workload credential, in the + * authority's own trust domain; any other workload is refused by name. + * Only the public half travels: the hop's seed never leaves it. + * + * The authority pins one admission key per hop instance (the instance its + * workload credential names): the first registration for an instance binds + * that key until its lease lapses, the same key renews it, and any other key + * for that instance is refused by name until then. A new key comes from a + * new hop instance, or from the same one once its previous key has lapsed. + * So a captured hop credential cannot replace or add a key for an instance + * that has registered. What it does not stop: a holder of a captured + * credential who registers before that instance's own first registration + * (the hop registers at boot), or in the window after an authority restart + * before the hop's next renewal, pins its own key for that instance; the + * hop's registration is then refused by name, and its log says so. A + * credential binding the key at issuance is what would close that window. + * + * Each request also carries a proof signed by the key it registers (see + * RegisterAdmissionKeyRequest.proof). It shows the registrant holds that + * key's private half and binds the request to its credential, the + * authority's audience and a fresh, single-use nonce, so a registration + * cannot be replayed, retargeted or reused for another credential; it does + * not prove the registrant is the hop, since a registrant chooses its key. + * + * A registration lapses at expires_at_unix unless renewed, so a hop renews + * while it runs (a restarted authority learns the key again at the next + * renewal) and a key no running hop signs with any more is withdrawn + * without being named. While a rotation overlaps, the retiring key (its + * instance still renewing) and the new key (a new instance's) are both + * registered, and both published. * * @generated from rpc host.authority.v1.AuthorityService.RegisterAdmissionKey */ diff --git a/module/services/frontend/code/src/gen/host/composition/v1/composition_pb.ts b/module/services/frontend/code/src/gen/host/composition/v1/composition_pb.ts index 798e42f89..118f0205a 100644 --- a/module/services/frontend/code/src/gen/host/composition/v1/composition_pb.ts +++ b/module/services/frontend/code/src/gen/host/composition/v1/composition_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/composition/v1/composition.proto. */ export const file_host_composition_v1_composition: GenFile = /*@__PURE__*/ - fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIvEBChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJGCgZSb2xlVjESDAoEbmFtZRgBIAEoCRIuCgZzY29wZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSLQAQoJQmluZGluZ1YxEgoKAmlkGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEjUKDWludm9rZV9zY29wZXMYAyADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI3Cg9oZWFkbGVzc19zY29wZXMYBCADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI1Cg1sb29rdXBfc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjEiLwoOUmVzb3VyY2VLaW5kVjESDAoEa2luZBgBIAEoCRIPCgdhY3Rpb25zGAIgAygJIiYKBlBhZ2VWMRINCgV0aXRsZRgBIAEoCRINCgVvcmRlchgCIAEoBTJ8ChJDb21wb3NpdGlvblNlcnZpY2USZgoNQXBwbHlNYW5pZmVzdBIpLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlcXVlc3QaKi5ob3N0LmNvbXBvc2l0aW9uLnYxLkFwcGx5TWFuaWZlc3RSZXNwb25zZULNAQoXY29tLmhvc3QuY29tcG9zaXRpb24udjFCEENvbXBvc2l0aW9uUHJvdG9QAVoyYWNjb3VudHMvcGtnL2dlbi9ob3N0L2NvbXBvc2l0aW9uL3YxO2NvbXBvc2l0aW9udjGiAgNIQ1iqAhNIb3N0LkNvbXBvc2l0aW9uLlYxygITSG9zdFxDb21wb3NpdGlvblxWMeICH0hvc3RcQ29tcG9zaXRpb25cVjFcR1BCTWV0YWRhdGHqAhVIb3N0OjpDb21wb3NpdGlvbjo6VjFiBnByb3RvMw", [file_host_authority_v1_work_context]); + fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIoYCChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMRITCgtvd25lcl9hd2FyZRgHIAEoCCJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSKJAQoGUm9sZVYxEgwKBG5hbWUYASABKAkSLgoGc2NvcGVzGAIgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESQQoZaW5zdGFsbGF0aW9uX293bmVkX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxItABCglCaW5kaW5nVjESCgoCaWQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSNQoNaW52b2tlX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjcKD2hlYWRsZXNzX3Njb3BlcxgEIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjUKDWxvb2t1cF9zY29wZXMYBSADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSIvCg5SZXNvdXJjZUtpbmRWMRIMCgRraW5kGAEgASgJEg8KB2FjdGlvbnMYAiADKAkiJgoGUGFnZVYxEg0KBXRpdGxlGAEgASgJEg0KBW9yZGVyGAIgASgFMnwKEkNvbXBvc2l0aW9uU2VydmljZRJmCg1BcHBseU1hbmlmZXN0EikuaG9zdC5jb21wb3NpdGlvbi52MS5BcHBseU1hbmlmZXN0UmVxdWVzdBoqLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlc3BvbnNlQs0BChdjb20uaG9zdC5jb21wb3NpdGlvbi52MUIQQ29tcG9zaXRpb25Qcm90b1ABWjJhY2NvdW50cy9wa2cvZ2VuL2hvc3QvY29tcG9zaXRpb24vdjE7Y29tcG9zaXRpb252MaICA0hDWKoCE0hvc3QuQ29tcG9zaXRpb24uVjHKAhNIb3N0XENvbXBvc2l0aW9uXFYx4gIfSG9zdFxDb21wb3NpdGlvblxWMVxHUEJNZXRhZGF0YeoCFUhvc3Q6OkNvbXBvc2l0aW9uOjpWMWIGcHJvdG8z", [file_host_authority_v1_work_context]); /** * ApplyManifestRequest carries the manifest. @@ -129,6 +129,16 @@ export type AuthorityManifestV1 = Message<"host.composition.v1.AuthorityManifest * @generated from field: repeated host.composition.v1.RoleV1 roles = 6; */ roles: RoleV1[]; + + /** + * The module checks a resource's owner against an owner-selected scope + * (Claims.AllowsOwned): only an audience declaring it receives one, and a + * delivery withdrawing it refuses the owner-selected capabilities issued + * before. Every solution receiving its roles' scopes declares it. + * + * @generated from field: bool owner_aware = 7; + */ + ownerAware: boolean; }; /** @@ -294,7 +304,7 @@ export type ModuleManifestV1 = Message<"host.composition.v1.ModuleManifestV1"> & /** * The roles a solution declares a person may hold on an installation of it, * each within what the solution serves or explicitly consumes through - * invoke/lookup bindings; one is named installer, held by whoever installs it. + * invoke/lookup bindings; one is named installer, held only by whom it is granted to. * Headless-only scopes cannot supply a role grant. A module declares none. * * @generated from field: repeated host.composition.v1.RoleV1 roles = 8; @@ -324,6 +334,20 @@ export type RoleV1 = Message<"host.composition.v1.RoleV1"> & { * @generated from field: repeated host.authority.v1.WorkScopeV1 scopes = 2; */ scopes: WorkScopeV1[]; + + /** + * Scopes the role grants only on the resources the installation owns: those + * created through it (AuthorityService.AttachResource) or transferred after + * AuthorityService.AuthorizeTransfer authorizes the change of owner. Each + * names a kind and actions and no resource ids, since ids are per + * installation, and is held to the same ceiling as scopes. The authority + * resolves each, for the installation the role is held on, into the same + * scope limited to the ids that installation owns of the kind; a kind it owns + * nothing of grants nothing, and never the whole kind. + * + * @generated from field: repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; + */ + installationOwnedScopes: WorkScopeV1[]; }; /** diff --git a/module/services/frontend/code/src/gen/host/installation/v1/installation_pb.ts b/module/services/frontend/code/src/gen/host/installation/v1/installation_pb.ts index 326694973..f2604fb4c 100644 --- a/module/services/frontend/code/src/gen/host/installation/v1/installation_pb.ts +++ b/module/services/frontend/code/src/gen/host/installation/v1/installation_pb.ts @@ -3,8 +3,10 @@ /* eslint-disable */ // The installation interface: what is installed at an organisation and who may -// use it. Three operations on the host, and one the solution implements, the -// install step the host calls once per installation. +// use it. An installed solution is a principal of its organisation; its roles +// are levels on the data that principal owns, and a grant of a role is the +// access. Operations on the host, and one the solution implements, the install +// step the host calls once per installation. import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; @@ -14,7 +16,68 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/installation/v1/installation.proto. */ export const file_host_installation_v1_installation: GenFile = /*@__PURE__*/ - fileDesc("Cidob3N0L2luc3RhbGxhdGlvbi92MS9pbnN0YWxsYXRpb24ucHJvdG8SFGhvc3QuaW5zdGFsbGF0aW9uLnYxIpkBCg5JbnN0YWxsUmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkSDgoGdGFyZ2V0GAIgASgJEgwKBHJvbGUYAyABKAkSFwoPb3duZXJfcGVyc29uX2lkGAQgASgJEjcKCGFwcHJvdmFsGAUgASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsImkKD0NvbnNlbnRBcHByb3ZhbBIRCgl0YXJnZXRfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIZChFleHBlY3RlZF9yZXZpc2lvbhgDIAEoBBIUCgxvcGVyYXRpb25faWQYBCABKAkiVwoSR3JhbnRUb1RlYW1SZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIPCgd0ZWFtX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCSImCgtMaXN0UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkiSQoMTGlzdFJlc3BvbnNlEjkKDWluc3RhbGxhdGlvbnMYASADKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24ivAIKDEluc3RhbGxhdGlvbhIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEjkKBnN0YXR1cxgEIAEoDjIpLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbi5TdGF0dXMSEAoIcmV2aXNpb24YBSABKAQSDAoEcm9sZRgGIAEoCRIQCgh0ZWFtX2lkcxgHIAMoCRIaChJhZ2VudF9wcmluY2lwYWxfaWQYCCABKAkSEQoJbmFtZXNwYWNlGAkgASgJIlsKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABISCg5TVEFUVVNfUEVORElORxABEhEKDVNUQVRVU19BQ1RJVkUQAhISCg5TVEFUVVNfUkVWT0tFRBADIk4KEkluc3RhbGxhdGlvbk5vdGljZRI4CgxpbnN0YWxsYXRpb24YASABKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24iEQoPSW5zdGFsbGF0aW9uQWNrMpYCChNJbnN0YWxsYXRpb25TZXJ2aWNlElMKB0luc3RhbGwSJC5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJbCgtHcmFudFRvVGVhbRIoLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkdyYW50VG9UZWFtUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJNCgRMaXN0EiEuaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdFJlcXVlc3QaIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5MaXN0UmVzcG9uc2UycAoSSW5zdGFsbGFibGVTZXJ2aWNlEloKB0luc3RhbGwSKC5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb25Ob3RpY2UaJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb25BY2tC1QEKGGNvbS5ob3N0Lmluc3RhbGxhdGlvbi52MUIRSW5zdGFsbGF0aW9uUHJvdG9QAVo0YWNjb3VudHMvcGtnL2dlbi9ob3N0L2luc3RhbGxhdGlvbi92MTtpbnN0YWxsYXRpb252MaICA0hJWKoCFEhvc3QuSW5zdGFsbGF0aW9uLlYxygIUSG9zdFxJbnN0YWxsYXRpb25cVjHiAiBIb3N0XEluc3RhbGxhdGlvblxWMVxHUEJNZXRhZGF0YeoCFkhvc3Q6Okluc3RhbGxhdGlvbjo6VjFiBnByb3RvMw"); + fileDesc("Cidob3N0L2luc3RhbGxhdGlvbi92MS9pbnN0YWxsYXRpb24ucHJvdG8SFGhvc3QuaW5zdGFsbGF0aW9uLnYxIkwKGkN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0EhQKDHBhcmVudF90b2tlbhgBIAEoCRIYChBhY3Rvcl9jcmVkZW50aWFsGAIgASgJIm8KE0N1cnJlbnRJbnN0YWxsYXRpb24SCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg4KBnRhcmdldBgDIAEoCRIQCghyZXZpc2lvbhgEIAEoBBIRCgluYW1lc3BhY2UYBSABKAkimQEKDkluc3RhbGxSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCRIOCgZ0YXJnZXQYAiABKAkSDAoEcm9sZRgDIAEoCRIXCg9vd25lcl9wZXJzb25faWQYBCABKAkSNwoIYXBwcm92YWwYBSABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWwiOAoNUmV2aWV3UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkSDgoGdGFyZ2V0GAIgASgJInkKD0NvbnNlbnRBcHByb3ZhbBIRCgl0YXJnZXRfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIZChFleHBlY3RlZF9yZXZpc2lvbhgDIAEoBBIUCgxvcGVyYXRpb25faWQYBCABKAkSDgoGZGlnZXN0GAUgASgJInYKEEdyYW50Um9sZVJlcXVlc3QSFwoPb3JnYW5pemF0aW9uX2lkGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRIMCgRyb2xlGAMgASgJEhEKCXBlcnNvbl9pZBgEIAEoCRIPCgd0ZWFtX2lkGAUgASgJIncKEVJldm9rZVJvbGVSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCRIXCg9pbnN0YWxsYXRpb25faWQYAiABKAkSDAoEcm9sZRgDIAEoCRIRCglwZXJzb25faWQYBCABKAkSDwoHdGVhbV9pZBgFIAEoCSJJChVMaXN0Um9sZUdyYW50c1JlcXVlc3QSFwoPb3JnYW5pemF0aW9uX2lkGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCSJJChZMaXN0Um9sZUdyYW50c1Jlc3BvbnNlEi8KBmdyYW50cxgBIAMoCzIfLmhvc3QuaW5zdGFsbGF0aW9uLnYxLlJvbGVHcmFudCI9CglSb2xlR3JhbnQSDAoEcm9sZRgBIAEoCRIRCglwZXJzb25faWQYAiABKAkSDwoHdGVhbV9pZBgDIAEoCSImCgtMaXN0UmVxdWVzdBIXCg9vcmdhbml6YXRpb25faWQYASABKAkiSQoMTGlzdFJlc3BvbnNlEjkKDWluc3RhbGxhdGlvbnMYASADKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24i3QIKDEluc3RhbGxhdGlvbhIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEjkKBnN0YXR1cxgEIAEoDjIpLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbi5TdGF0dXMSEAoIcmV2aXNpb24YBSABKAQSDAoEcm9sZRgGIAEoCRIRCgluYW1lc3BhY2UYCSABKAkSFAoMcHJpbmNpcGFsX2lkGAogASgJEg0KBXJvbGVzGAsgAygJIlsKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABISCg5TVEFUVVNfUEVORElORxABEhEKDVNUQVRVU19BQ1RJVkUQAhISCg5TVEFUVVNfUkVWT0tFRBADSgQIBxAISgQICBAJUgh0ZWFtX2lkc1ISYWdlbnRfcHJpbmNpcGFsX2lkIk4KEkluc3RhbGxhdGlvbk5vdGljZRI4CgxpbnN0YWxsYXRpb24YASABKAsyIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24iEQoPSW5zdGFsbGF0aW9uQWNrIiwKEUxpc3RPZmZlcnNSZXF1ZXN0EhcKD29yZ2FuaXphdGlvbl9pZBgBIAEoCSJBChJMaXN0T2ZmZXJzUmVzcG9uc2USKwoGb2ZmZXJzGAEgAygLMhsuaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXIiKwoFT2ZmZXISDgoGdGFyZ2V0GAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQijwIKDVJldmlld2VkT2ZmZXISNwoIYXBwcm92YWwYASABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWwSDgoGdGFyZ2V0GAIgASgJEi4KBXJvbGVzGAMgAygLMh8uaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJSb2xlEjgKDGluc3RhbGxhdGlvbhgEIAEoCzIiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhIzCgdjZWlsaW5nGAUgAygLMiIuaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJCaW5kaW5nEhYKDnRvb2xzX2NvbXBvc2VkGAYgASgIImEKDE9mZmVyQmluZGluZxIPCgdiaW5kaW5nGAEgASgJEg4KBm1vZHVsZRgCIAEoCRIwCgZzY29wZXMYAyADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlIogBCglPZmZlclJvbGUSDAoEbmFtZRgBIAEoCRIwCgZzY29wZXMYAiADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlEjsKDGNhcGFiaWxpdGllcxgDIAMoCzIlLmhvc3QuaW5zdGFsbGF0aW9uLnYxLk9mZmVyQ2FwYWJpbGl0eSJhCgpPZmZlclNjb3BlEhUKDXJlc291cmNlX2tpbmQYASABKAkSDwoHYWN0aW9ucxgCIAMoCRIVCg1hdHRhY2hlZF9vbmx5GAMgASgIEhQKDHJlc291cmNlX2lkcxgEIAMoCSKYAQoPT2ZmZXJDYXBhYmlsaXR5Eg4KBm1vZHVsZRgBIAEoCRIRCglvcGVyYXRpb24YAiABKAkSMgoIcmVxdWlyZXMYAyADKAsyIC5ob3N0Lmluc3RhbGxhdGlvbi52MS5PZmZlclNjb3BlEi4KBXRvb2xzGAQgAygLMh8uaG9zdC5pbnN0YWxsYXRpb24udjEuT2ZmZXJUb29sIjkKCU9mZmVyVG9vbBIMCgRuYW1lGAEgASgJEg8KB2JpbmRpbmcYAiABKAkSDQoFd3JpdGUYAyABKAgymAUKE0luc3RhbGxhdGlvblNlcnZpY2USZgoHQ3VycmVudBIwLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GikuaG9zdC5pbnN0YWxsYXRpb24udjEuQ3VycmVudEluc3RhbGxhdGlvbhJTCgdJbnN0YWxsEiQuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbFJlcXVlc3QaIi5ob3N0Lmluc3RhbGxhdGlvbi52MS5JbnN0YWxsYXRpb24SVAoGUmV2aWV3EiMuaG9zdC5pbnN0YWxsYXRpb24udjEuUmV2aWV3UmVxdWVzdBolLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkNvbnNlbnRBcHByb3ZhbBJXCglHcmFudFJvbGUSJi5ob3N0Lmluc3RhbGxhdGlvbi52MS5HcmFudFJvbGVSZXF1ZXN0GiIuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uElkKClJldm9rZVJvbGUSJy5ob3N0Lmluc3RhbGxhdGlvbi52MS5SZXZva2VSb2xlUmVxdWVzdBoiLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkluc3RhbGxhdGlvbhJrCg5MaXN0Um9sZUdyYW50cxIrLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSb2xlR3JhbnRzUmVxdWVzdBosLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSb2xlR3JhbnRzUmVzcG9uc2USTQoETGlzdBIhLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RSZXF1ZXN0GiIuaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdFJlc3BvbnNlMnAKEkluc3RhbGxhYmxlU2VydmljZRJaCgdJbnN0YWxsEiguaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uTm90aWNlGiUuaG9zdC5pbnN0YWxsYXRpb24udjEuSW5zdGFsbGF0aW9uQWNrMsgBCgxPZmZlclNlcnZpY2USXwoKTGlzdE9mZmVycxInLmhvc3QuaW5zdGFsbGF0aW9uLnYxLkxpc3RPZmZlcnNSZXF1ZXN0GiguaG9zdC5pbnN0YWxsYXRpb24udjEuTGlzdE9mZmVyc1Jlc3BvbnNlElcKC1Jldmlld09mZmVyEiMuaG9zdC5pbnN0YWxsYXRpb24udjEuUmV2aWV3UmVxdWVzdBojLmhvc3QuaW5zdGFsbGF0aW9uLnYxLlJldmlld2VkT2ZmZXJC1QEKGGNvbS5ob3N0Lmluc3RhbGxhdGlvbi52MUIRSW5zdGFsbGF0aW9uUHJvdG9QAVo0YWNjb3VudHMvcGtnL2dlbi9ob3N0L2luc3RhbGxhdGlvbi92MTtpbnN0YWxsYXRpb252MaICA0hJWKoCFEhvc3QuSW5zdGFsbGF0aW9uLlYxygIUSG9zdFxJbnN0YWxsYXRpb25cVjHiAiBIb3N0XEluc3RhbGxhdGlvblxWMVxHUEJNZXRhZGF0YeoCFkhvc3Q6Okluc3RhbGxhdGlvbjo6VjFiBnByb3RvMw"); + +/** + * @generated from message host.installation.v1.CurrentInstallationRequest + */ +export type CurrentInstallationRequest = Message<"host.installation.v1.CurrentInstallationRequest"> & { + /** + * @generated from field: string parent_token = 1; + */ + parentToken: string; + + /** + * @generated from field: string actor_credential = 2; + */ + actorCredential: string; +}; + +/** + * Describes the message host.installation.v1.CurrentInstallationRequest. + * Use `create(CurrentInstallationRequestSchema)` to create a new message. + */ +export const CurrentInstallationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 0); + +/** + * CurrentInstallation is request-local metadata, never a lease or consent. + * + * @generated from message host.installation.v1.CurrentInstallation + */ +export type CurrentInstallation = Message<"host.installation.v1.CurrentInstallation"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string organization_id = 2; + */ + organizationId: string; + + /** + * @generated from field: string target = 3; + */ + target: string; + + /** + * @generated from field: uint64 revision = 4; + */ + revision: bigint; + + /** + * @generated from field: string namespace = 5; + */ + namespace: string; +}; + +/** + * Describes the message host.installation.v1.CurrentInstallation. + * Use `create(CurrentInstallationSchema)` to create a new message. + */ +export const CurrentInstallationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 1); /** * InstallRequest asks to install one target at one organisation. @@ -51,7 +114,7 @@ export type InstallRequest = Message<"host.installation.v1.InstallRequest"> & { ownerPersonId: string; /** - * The exact catalogue offer and installation state the administrator approved. + * Required: the exact offer reviewed by the administrator; unchanged on retry. * * @generated from field: host.installation.v1.ConsentApproval approval = 5; */ @@ -63,13 +126,36 @@ export type InstallRequest = Message<"host.installation.v1.InstallRequest"> & { * Use `create(InstallRequestSchema)` to create a new message. */ export const InstallRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 0); + messageDesc(file_host_installation_v1_installation, 2); /** - * ConsentApproval binds an explicit approval to the state reviewed by the admin. - * Obtain target_id and generation from ListAvailableSolutions, and revision from - * the installation listing (zero means absent). Keep these values and operation_id - * unchanged on retry; a refusal requires a fresh review and operation. + * ReviewRequest names the offer an administrator intends to review. + * + * @generated from message host.installation.v1.ReviewRequest + */ +export type ReviewRequest = Message<"host.installation.v1.ReviewRequest"> & { + /** + * @generated from field: string organization_id = 1; + */ + organizationId: string; + + /** + * @generated from field: string target = 2; + */ + target: string; +}; + +/** + * Describes the message host.installation.v1.ReviewRequest. + * Use `create(ReviewRequestSchema)` to create a new message. + */ +export const ReviewRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 3); + +/** + * ConsentApproval binds consent to an immutable target, its reviewed delivery and + * the installation revision (zero if absent). Review leaves operation_id empty; + * the caller supplies a fresh UUID and preserves the entire request on retry. * * @generated from message host.installation.v1.ConsentApproval */ @@ -93,6 +179,13 @@ export type ConsentApproval = Message<"host.installation.v1.ConsentApproval"> & * @generated from field: string operation_id = 4; */ operationId: string; + + /** + * sha256 digest of the reviewed authority offer. + * + * @generated from field: string digest = 5; + */ + digest: string; }; /** @@ -100,44 +193,192 @@ export type ConsentApproval = Message<"host.installation.v1.ConsentApproval"> & * Use `create(ConsentApprovalSchema)` to create a new message. */ export const ConsentApprovalSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 1); + messageDesc(file_host_installation_v1_installation, 4); /** - * GrantToTeamRequest exposes an installation to a team. + * GrantRoleRequest grants one role to exactly one person or team. * - * @generated from message host.installation.v1.GrantToTeamRequest + * @generated from message host.installation.v1.GrantRoleRequest */ -export type GrantToTeamRequest = Message<"host.installation.v1.GrantToTeamRequest"> & { +export type GrantRoleRequest = Message<"host.installation.v1.GrantRoleRequest"> & { + /** + * The organization the installation belongs to; a request naming another + * organization's installation is refused naming the organization, before + * the caller's authority is read. + * + * @generated from field: string organization_id = 1; + */ + organizationId: string; + /** * The installation. * - * @generated from field: string installation_id = 1; + * @generated from field: string installation_id = 2; */ installationId: string; /** - * The team granted the installation's role at its root. + * One of the roles the solution declares. + * + * @generated from field: string role = 3; + */ + role: string; + + /** + * The person granted, a member of the organization; exactly one of + * person_id and team_id is set. + * + * @generated from field: string person_id = 4; + */ + personId: string; + + /** + * The team granted, a team of the organization. * - * @generated from field: string team_id = 2; + * @generated from field: string team_id = 5; */ teamId: string; +}; +/** + * Describes the message host.installation.v1.GrantRoleRequest. + * Use `create(GrantRoleRequestSchema)` to create a new message. + */ +export const GrantRoleRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 5); + +/** + * RevokeRoleRequest ends one grant, named as GrantRole named it. + * + * @generated from message host.installation.v1.RevokeRoleRequest + */ +export type RevokeRoleRequest = Message<"host.installation.v1.RevokeRoleRequest"> & { /** - * The organisation the installation belongs to; a grant is an organisation - * administrator's act, and the host checks the actor against it before it - * reads the installation. Learned from the second implementation (the host). + * The organization the installation belongs to. * - * @generated from field: string organization_id = 3; + * @generated from field: string organization_id = 1; */ organizationId: string; + + /** + * The installation. + * + * @generated from field: string installation_id = 2; + */ + installationId: string; + + /** + * The role. + * + * @generated from field: string role = 3; + */ + role: string; + + /** + * The person; exactly one of person_id and team_id is set. + * + * @generated from field: string person_id = 4; + */ + personId: string; + + /** + * The team. + * + * @generated from field: string team_id = 5; + */ + teamId: string; }; /** - * Describes the message host.installation.v1.GrantToTeamRequest. - * Use `create(GrantToTeamRequestSchema)` to create a new message. + * Describes the message host.installation.v1.RevokeRoleRequest. + * Use `create(RevokeRoleRequestSchema)` to create a new message. */ -export const GrantToTeamRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 2); +export const RevokeRoleRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 6); + +/** + * ListRoleGrantsRequest names one installation. + * + * @generated from message host.installation.v1.ListRoleGrantsRequest + */ +export type ListRoleGrantsRequest = Message<"host.installation.v1.ListRoleGrantsRequest"> & { + /** + * The organization the installation belongs to. + * + * @generated from field: string organization_id = 1; + */ + organizationId: string; + + /** + * The installation. + * + * @generated from field: string installation_id = 2; + */ + installationId: string; +}; + +/** + * Describes the message host.installation.v1.ListRoleGrantsRequest. + * Use `create(ListRoleGrantsRequestSchema)` to create a new message. + */ +export const ListRoleGrantsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 7); + +/** + * ListRoleGrantsResponse is every grant on the installation. + * + * @generated from message host.installation.v1.ListRoleGrantsResponse + */ +export type ListRoleGrantsResponse = Message<"host.installation.v1.ListRoleGrantsResponse"> & { + /** + * The grants. + * + * @generated from field: repeated host.installation.v1.RoleGrant grants = 1; + */ + grants: RoleGrant[]; +}; + +/** + * Describes the message host.installation.v1.ListRoleGrantsResponse. + * Use `create(ListRoleGrantsResponseSchema)` to create a new message. + */ +export const ListRoleGrantsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 8); + +/** + * RoleGrant is one role held by one person or team. + * + * @generated from message host.installation.v1.RoleGrant + */ +export type RoleGrant = Message<"host.installation.v1.RoleGrant"> & { + /** + * The role. + * + * @generated from field: string role = 1; + */ + role: string; + + /** + * The person, when a person holds it. + * + * @generated from field: string person_id = 2; + */ + personId: string; + + /** + * The team, when a team holds it. + * + * @generated from field: string team_id = 3; + */ + teamId: string; +}; + +/** + * Describes the message host.installation.v1.RoleGrant. + * Use `create(RoleGrantSchema)` to create a new message. + */ +export const RoleGrantSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 9); /** * ListRequest asks for one organisation's installations. @@ -158,7 +399,7 @@ export type ListRequest = Message<"host.installation.v1.ListRequest"> & { * Use `create(ListRequestSchema)` to create a new message. */ export const ListRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 3); + messageDesc(file_host_installation_v1_installation, 10); /** * ListResponse lists installations. @@ -179,7 +420,7 @@ export type ListResponse = Message<"host.installation.v1.ListResponse"> & { * Use `create(ListResponseSchema)` to create a new message. */ export const ListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 4); + messageDesc(file_host_installation_v1_installation, 11); /** * Installation is one organisation's installation of one target. @@ -223,35 +464,38 @@ export type Installation = Message<"host.installation.v1.Installation"> & { revision: bigint; /** - * The role the installation holds. + * The role the installation holds when it acts for itself (its agent's + * role); no person or team inherits it. * * @generated from field: string role = 6; */ role: string; /** - * The teams granted it. + * The namespace the installation's own authored content belongs to (an + * agent's source): the host names it, so a module never chooses which source + * an installation may run. A module installed by composition is + * ".". * - * @generated from field: repeated string team_ids = 7; + * @generated from field: string namespace = 9; */ - teamIds: string[]; + namespace: string; /** - * The principal the installation acts as for itself. + * The solution principal: the identity the installation's data is owned by + * and the installation acts as for itself, a stable UUID per organization + * and target, reused by a reinstall. Empty for a module installation. * - * @generated from field: string agent_principal_id = 8; + * @generated from field: string principal_id = 10; */ - agentPrincipalId: string; + principalId: string; /** - * The namespace the installation's own authored content belongs to (an - * agent's source): the host names it, so a module never chooses which source - * an installation may run. A module installed by composition is - * ".". + * The roles the solution declares, sorted. * - * @generated from field: string namespace = 9; + * @generated from field: repeated string roles = 11; */ - namespace: string; + roles: string[]; }; /** @@ -259,7 +503,7 @@ export type Installation = Message<"host.installation.v1.Installation"> & { * Use `create(InstallationSchema)` to create a new message. */ export const InstallationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 5); + messageDesc(file_host_installation_v1_installation, 12); /** * Status is the installation's lifecycle. @@ -300,7 +544,7 @@ export enum Installation_Status { * Describes the enum host.installation.v1.Installation.Status. */ export const Installation_StatusSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_host_installation_v1_installation, 5, 0); + enumDesc(file_host_installation_v1_installation, 12, 0); /** * InstallationNotice is what the host hands the solution's install step. @@ -321,7 +565,7 @@ export type InstallationNotice = Message<"host.installation.v1.InstallationNotic * Use `create(InstallationNoticeSchema)` to create a new message. */ export const InstallationNoticeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 6); + messageDesc(file_host_installation_v1_installation, 13); /** * InstallationAck acknowledges the install step. @@ -336,7 +580,309 @@ export type InstallationAck = Message<"host.installation.v1.InstallationAck"> & * Use `create(InstallationAckSchema)` to create a new message. */ export const InstallationAckSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_installation_v1_installation, 7); + messageDesc(file_host_installation_v1_installation, 14); + +/** + * ListOffersRequest names the organization whose administrator reads. + * + * @generated from message host.installation.v1.ListOffersRequest + */ +export type ListOffersRequest = Message<"host.installation.v1.ListOffersRequest"> & { + /** + * @generated from field: string organization_id = 1; + */ + organizationId: string; +}; + +/** + * Describes the message host.installation.v1.ListOffersRequest. + * Use `create(ListOffersRequestSchema)` to create a new message. + */ +export const ListOffersRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 15); + +/** + * ListOffersResponse is every delivered solution, by name. + * + * @generated from message host.installation.v1.ListOffersResponse + */ +export type ListOffersResponse = Message<"host.installation.v1.ListOffersResponse"> & { + /** + * @generated from field: repeated host.installation.v1.Offer offers = 1; + */ + offers: Offer[]; +}; + +/** + * Describes the message host.installation.v1.ListOffersResponse. + * Use `create(ListOffersResponseSchema)` to create a new message. + */ +export const ListOffersResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 16); + +/** + * Offer is one delivered solution. + * + * @generated from message host.installation.v1.Offer + */ +export type Offer = Message<"host.installation.v1.Offer"> & { + /** + * The solution's name in the composition: Install's target. + * + * @generated from field: string target = 1; + */ + target: string; + + /** + * The delivery generation an approval binds. + * + * @generated from field: uint64 generation = 2; + */ + generation: bigint; +}; + +/** + * Describes the message host.installation.v1.Offer. + * Use `create(OfferSchema)` to create a new message. + */ +export const OfferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 17); + +/** + * ReviewedOffer is one solution's authority offer and the consent that names it. + * + * @generated from message host.installation.v1.ReviewedOffer + */ +export type ReviewedOffer = Message<"host.installation.v1.ReviewedOffer"> & { + /** + * What Install takes, with a fresh operation_id; read with the rest. + * + * @generated from field: host.installation.v1.ConsentApproval approval = 1; + */ + approval?: ConsentApproval; + + /** + * @generated from field: string target = 2; + */ + target: string; + + /** + * Each role the solution declares, the installer role included. Every role + * scope is confined to this installation: to what it owns, or to the + * resources attached to it. + * + * @generated from field: repeated host.installation.v1.OfferRole roles = 3; + */ + roles: OfferRole[]; + + /** + * The solution's own installation at the organization; absent before Install. + * + * @generated from field: host.installation.v1.Installation installation = 4; + */ + installation?: Installation; + + /** + * The permission ceiling: each module the solution calls, and the most it + * may ask there. No role reaches beyond it. + * + * @generated from field: repeated host.installation.v1.OfferBinding ceiling = 5; + */ + ceiling: OfferBinding[]; + + /** + * False when this run delivered no prepared Runnable bindings: no tool is + * shown, which says nothing about what a composed binding would expose. + * + * @generated from field: bool tools_composed = 6; + */ + toolsComposed: boolean; +}; + +/** + * Describes the message host.installation.v1.ReviewedOffer. + * Use `create(ReviewedOfferSchema)` to create a new message. + */ +export const ReviewedOfferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 18); + +/** + * OfferBinding is one declared call from the solution to a module. + * + * @generated from message host.installation.v1.OfferBinding + */ +export type OfferBinding = Message<"host.installation.v1.OfferBinding"> & { + /** + * @generated from field: string binding = 1; + */ + binding: string; + + /** + * The module called. + * + * @generated from field: string module = 2; + */ + module: string; + + /** + * @generated from field: repeated host.installation.v1.OfferScope scopes = 3; + */ + scopes: OfferScope[]; +}; + +/** + * Describes the message host.installation.v1.OfferBinding. + * Use `create(OfferBindingSchema)` to create a new message. + */ +export const OfferBindingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 19); + +/** + * OfferRole is one declared role and what a grant of it reaches. + * + * @generated from message host.installation.v1.OfferRole + */ +export type OfferRole = Message<"host.installation.v1.OfferRole"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * The scopes the role declares. + * + * @generated from field: repeated host.installation.v1.OfferScope scopes = 2; + */ + scopes: OfferScope[]; + + /** + * Each composed operation the role's scopes cover. + * + * @generated from field: repeated host.installation.v1.OfferCapability capabilities = 3; + */ + capabilities: OfferCapability[]; +}; + +/** + * Describes the message host.installation.v1.OfferRole. + * Use `create(OfferRoleSchema)` to create a new message. + */ +export const OfferRoleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 20); + +/** + * OfferScope is one declared scope. + * + * @generated from message host.installation.v1.OfferScope + */ +export type OfferScope = Message<"host.installation.v1.OfferScope"> & { + /** + * @generated from field: string resource_kind = 1; + */ + resourceKind: string; + + /** + * @generated from field: repeated string actions = 2; + */ + actions: string[]; + + /** + * True when the scope reaches only the resources attached to the + * installation, listed in resource_ids (none until one is attached); false + * when it reaches every resource the installation owns. + * + * @generated from field: bool attached_only = 3; + */ + attachedOnly: boolean; + + /** + * @generated from field: repeated string resource_ids = 4; + */ + resourceIds: string[]; +}; + +/** + * Describes the message host.installation.v1.OfferScope. + * Use `create(OfferScopeSchema)` to create a new message. + */ +export const OfferScopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 21); + +/** + * OfferCapability is one module operation a role's scopes cover. + * + * @generated from message host.installation.v1.OfferCapability + */ +export type OfferCapability = Message<"host.installation.v1.OfferCapability"> & { + /** + * @generated from field: string module = 1; + */ + module: string; + + /** + * The operation as the module's authority document names it. + * + * @generated from field: string operation = 2; + */ + operation: string; + + /** + * What a call to it must hold. + * + * @generated from field: repeated host.installation.v1.OfferScope requires = 3; + */ + requires: OfferScope[]; + + /** + * The tools composed for this operation, from its prepared Runnable + * bindings; empty when no binding exposes it as a tool. + * + * @generated from field: repeated host.installation.v1.OfferTool tools = 4; + */ + tools: OfferTool[]; +}; + +/** + * Describes the message host.installation.v1.OfferCapability. + * Use `create(OfferCapabilitySchema)` to create a new message. + */ +export const OfferCapabilitySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 22); + +/** + * OfferTool is one tool-exposed Runnable of an operation. + * + * @generated from message host.installation.v1.OfferTool + */ +export type OfferTool = Message<"host.installation.v1.OfferTool"> & { + /** + * The tool's name, as its exposure declares it. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * The prepared binding a caller names to run it. + * + * @generated from field: string binding = 2; + */ + binding: string; + + /** + * True when the tool changes data. + * + * @generated from field: bool write = 3; + */ + write: boolean; +}; + +/** + * Describes the message host.installation.v1.OfferTool. + * Use `create(OfferToolSchema)` to create a new message. + */ +export const OfferToolSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_installation_v1_installation, 23); /** * InstallationService is implemented by the host and by the local authority. @@ -344,6 +890,18 @@ export const InstallationAckSchema: GenMessage = /*@__PURE__*/ * @generated from service host.installation.v1.InstallationService */ export const InstallationService: GenService<{ + /** + * Current reads only the authenticated module's own active installation, + * in the tenant of a current parent addressed to that module. It grants no + * artifact approval or content access and exposes no roles or grants. + * + * @generated from rpc host.installation.v1.InstallationService.Current + */ + current: { + methodKind: "unary"; + input: typeof CurrentInstallationRequestSchema; + output: typeof CurrentInstallationSchema; + }, /** * Install composes an installation: its agent principal, its root scope and * the row, pending until the target acknowledges its install step. @@ -356,15 +914,50 @@ export const InstallationService: GenService<{ output: typeof InstallationSchema; }, /** - * GrantToTeam exposes an installation to a team under the installation's role. + * Review reads the current offer and installation revision without granting consent. + * + * @generated from rpc host.installation.v1.InstallationService.Review + */ + review: { + methodKind: "unary"; + input: typeof ReviewRequestSchema; + output: typeof ConsentApprovalSchema; + }, + /** + * GrantRole grants a person or a team one of the solution's roles on the + * installation: the access. Idempotent; it leaves existing capabilities + * valid. An administrator of the installation's organization, or a holder + * of the installer role on that installation, grants. * - * @generated from rpc host.installation.v1.InstallationService.GrantToTeam + * @generated from rpc host.installation.v1.InstallationService.GrantRole */ - grantToTeam: { + grantRole: { methodKind: "unary"; - input: typeof GrantToTeamRequestSchema; + input: typeof GrantRoleRequestSchema; output: typeof InstallationSchema; }, + /** + * RevokeRole ends one grant; every capability its subject holds from it is + * refused at once. Idempotent; the same callers as GrantRole. + * + * @generated from rpc host.installation.v1.InstallationService.RevokeRole + */ + revokeRole: { + methodKind: "unary"; + input: typeof RevokeRoleRequestSchema; + output: typeof InstallationSchema; + }, + /** + * ListRoleGrants answers who holds which role on the installation; the same + * callers as GrantRole. + * + * @generated from rpc host.installation.v1.InstallationService.ListRoleGrants + */ + listRoleGrants: { + methodKind: "unary"; + input: typeof ListRoleGrantsRequestSchema; + output: typeof ListRoleGrantsResponseSchema; + }, /** * List answers what one organisation has installed: an administrator's read. A * member's view, which installed solutions they may use, is their navigation and @@ -400,3 +993,40 @@ export const InstallableService: GenService<{ }> = /*@__PURE__*/ serviceDesc(file_host_installation_v1_installation, 1); +/** + * OfferService is the local authority's setup read, served beside + * InstallationService and not part of codefly.dev/installation: what solutions + * this run delivered and, for one, what installing it would grant. A host + * answers its own product's reads. It grants nothing; Install, GrantRole and + * RevokeRole stay the only changes, each decided by the authority. + * + * @generated from service host.installation.v1.OfferService + */ +export const OfferService: GenService<{ + /** + * ListOffers lists the solutions delivered to this run that an administrator + * of the organization may install. A module is installed by composition and + * is never offered. + * + * @generated from rpc host.installation.v1.OfferService.ListOffers + */ + listOffers: { + methodKind: "unary"; + input: typeof ListOffersRequestSchema; + output: typeof ListOffersResponseSchema; + }, + /** + * ReviewOffer reads one solution's roles, what each grants and the tools + * those grants make eligible, with the consent Install takes, from one read + * of the authority: the approval names exactly the offer displayed. + * + * @generated from rpc host.installation.v1.OfferService.ReviewOffer + */ + reviewOffer: { + methodKind: "unary"; + input: typeof ReviewRequestSchema; + output: typeof ReviewedOfferSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_host_installation_v1_installation, 2); + diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index 3ebece683..d89e5ab82 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -1400,8 +1400,8 @@ export const InstallationService: GenService<{ output: typeof EmptySchema; }, /** - * Narrowing is witnessed in the policy log before commit, then atomically - * bumps the installation revision, cancels its org's work and records audit. + * A narrowing applies directly: it atomically bumps the installation + * revision, cancels its org's work and records audit. * * @generated from rpc saas.accounts.v1.InstallationService.NarrowInstallation */ @@ -1532,9 +1532,8 @@ export const InstallationService: GenService<{ }, /** * RevokeInstallationFromTeam removes one team grant at an installation's - * authority-root node. It is a narrowing, so it runs under the policy log - * exactly as RevokeScope does: a host that cannot witness the narrowing refuses - * it, and says so, rather than revoking unwitnessed. It removes the one grant + * authority-root node. It is a narrowing, so it applies directly, with its + * audit record in one transaction, exactly as RevokeScope does. It removes the one grant * at the authority root that granted_teams reports; a team that also reaches * the installation from a grant above keeps that reach. Organisation-admin, * with the same platform super administrator exception. diff --git a/module/services/policy-log/README.md b/module/services/policy-log/README.md deleted file mode 100644 index bb0150636..000000000 --- a/module/services/policy-log/README.md +++ /dev/null @@ -1,350 +0,0 @@ -# Policy log - -`policy-log` is the independent witness for authority changes. Its contract is -append → signed receipt → commit-with-receipt in the caller. It assigns the -global sequence, owns the signing key, and records the committed prefix in its -own object store. A warehouse insert is only a mirror of that evidence. - -**Implementation status:** the witness, GCS adapter, delivered-secret key parser, -warehouse writer, transport-independent service, protobuf projection, gRPC -server and runnable binary are implemented and tested. The service is declared -in the module inventory and has its own private endpoint, service account, and -deployment declaration. - -Native composition remains incomplete. The `policy-log` group has no provisioned -bucket, mirror or persistent signing key, and Accounts has no verified witness -client. Native caller authentication, retained local storage and bootstrap remain -owner work. Supplying a key alone does not qualify this service or revocation. -No generated code was hand-written: the required no-change Codefly -proto-generation check ran and came back byte-identical on two sibling services, -and this service's own tree regenerates reproducibly. The host's client is -separate work; no host Go code is changed here. Nothing in this directory has -been deployed or booted, and the OpenAPI projection does not exist because the -endpoint is private gRPC and reaches no gateway. - -## Trust domain - -The object store, signing key, and warehouse writer belong to the warehouse's -trust domain, independent of the database whose authority they witness. A -database restore must not restore the witness. Accounts must have no object -write, signing-key read, warehouse writer, or witness administration capability. -The witness holds no accounts database credential or dependency. - -Delivery must provision a dedicated bucket, workload identity, private signing -key, and warehouse ingestion credential. Bucket IAM must confine the workload -to that bucket and forbid deleting or replacing `entries/` objects; enforce the -immutable prefix with retention and IAM, in addition to application checks. -The head's write authority is scoped to `head.json`. Bucket lifecycle deletion, -rewriting an existing sequence, or restoring the head from a database backup -would defeat the trust boundary and are not recovery procedures. Provision -versioning and deletion protection outside the workload's administrative reach. - -The runtime exposes no delete, reset, unconditional overwrite, ambient signing-key -source, insecure warehouse transport, or emulator option. The GCS constructor -refuses a storage emulator setting. The warehouse uses its own mandatory bearer -credential over HTTPS and refuses redirects. - -The listener is private and must stay private. Composition must admit only the -host's declared client through workload-authenticated mesh policy, resolve its -endpoint through Codefly, and deliver credentials exclusively to that client and -this service. A shared credential available to arbitrary composed workloads is -not sufficient authority to append. No caller supplies sequence numbers or -receipt timestamps. `TestTheWitnessListenerStaysPrivate` holds the manifest to -that: a second endpoint, a declared `visibility`, a Connect listener, a REST api -or any service dependency fails it. - -[service.codefly.yaml](service.codefly.yaml) uses the same `go-grpc` agent and -Codefly-allocated endpoint convention as the other Go services. Its `policy-log` -configuration group is delivered by composition; no default bucket, warehouse -credential, endpoint, or development bypass ships here. The persistent signing key -is a secret value in that group, never a file projection. The service has no accounts or -store dependency and is not a public or module-visible interface export. - -## Contract - -The authored Go contract is [code/witness/contract.go](code/witness/contract.go). -The transport adapter exposes these four unary operations through -[code/service/service.go](code/service/service.go): - -| Operation | Request | Response | -| --- | --- | --- | -| `Append` | `entry` | `Receipt` | -| `Cursor` | empty | signed `{head_seq, chain_hash, issued_at, signature}` | -| `Read` | `from_seq`, `limit` | ordered entries with their original receipts | -| `Keys` | empty | `{algorithm, key_id, public_key}` | - -An entry consists of `operation_id`, `kind`, `subject`, `tenant`, -`narrowing_hash` (exactly 32 SHA-256 bytes), and `requested_at`. Identifiers are -opaque UTF-8 strings, with no control characters: operation, subject, and tenant -are at most 1024 bytes, kind at most 128. Tenant may be empty for a global -operation; it grants no authority. The other identifiers and timestamp are -required. The witness stores the hash, not the narrowing payload. Reconciliation -can identify an operation and verify its bytes; the host must retain or recover -the canonical operation bytes to apply it. A hash cannot reconstruct them. - -`operation_id` identifies one logical operation for the lifetime of the log. -All entry fields, including `requested_at`, must stay identical on retry. The -same entry hash returns the exact original receipt, even after a restart or -later appends. Reusing the ID with a different hash is refused. `requested_at` -is the caller's logical timestamp, not a timeout or ordering authority; old -operations can be retried without changing their identity. - -A receipt contains `seq` (nonzero uint64), `entry_hash`, `prev_hash`, -`chain_hash`, `issued_at`, and a 64-byte Ed25519 `signature`. All hashes are -32-byte values. The first sequence is 1; its previous hash is 32 zero bytes. -`issued_at` comes from the witness. Sequence, rather than clock time, orders -operations. `Read` starts at an **inclusive** sequence, at least 1, with a limit -from 1 through 500. Reading at `head_seq + 1` returns an empty page; asking -beyond it refuses, so an ahead-of-log reconciler cannot mistake rollback for -inactivity. A host API using an exclusive cursor maps it to `cursor + 1`. - -### Canonical bytes - -Signatures never depend on JSON serialization or protobuf field ordering. -`U64` and `U32` are unsigned big-endian integers. `S(text)` is its UTF-8 byte -length as U32 followed by those bytes. `T(time)` is Unix seconds as the -two's-complement bits of an int64, followed by nanoseconds as U32. Hashes are -raw bytes. Each domain below ends in one NUL byte, not the two characters -backslash and zero. - -```text -entry_hash = SHA256( - "policy-log.entry.v1\0" || - S(operation_id) || S(kind) || S(subject) || S(tenant) || - narrowing_hash || T(requested_at)) - -chain_hash = SHA256( - "policy-log.chain.v1\0" || U64(seq) || prev_hash || entry_hash) - -receipt_signature = Ed25519.Sign(private_key, - "policy-log.receipt.v1\0" || U64(seq) || - entry_hash || prev_hash || chain_hash || T(issued_at)) - -cursor_signature = Ed25519.Sign(private_key, - "policy-log.cursor.v1\0" || U64(head_seq) || chain_hash || T(issued_at)) -``` - -Clients verify signatures under their independently pinned key, compare the -receipt's entry hash to their requested bytes, verify every chain link, and -persist the highest verified sequence and chain hash. A cursor is a signed -observation, not a receipt or a reset instruction. Never lower a persisted -cursor, accept a different hash at the same sequence, or substitute a fresh -network key for a pinned one. Signature verification alone cannot distinguish -a historical signed cursor from a current one; the host must also enforce its -freshness and reachability policy. - -### Public key delivery - -[identity.ParseSigningKey](code/identity/signing_key.go) validates the -`policy-log/POLICY_LOG_SIGNING_KEY` value read through Codefly's dedicated -`WorkspaceSecret` accessor. The value is canonical standard base64 of exactly -one canonical PKCS#8 DER Ed25519 private key, bounded to 4096 encoded bytes. -Missing, malformed, concatenated, noncanonical or wrong-algorithm values refuse -startup without exposing the value. No path, ambient environment fallback or -boot-time key generation is supported. - -Composition must provision this identity once in persistent secret custody and -redeliver the same value after restart. It must separately deliver the public -pin to Accounts as configuration, without giving Accounts the witness's secret -group. Provisioning, pin delivery and the Accounts client remain unwired. -The public document's schema matches `Keys`: - -```json -{ - "algorithm": "Ed25519", - "key_id": "lowercase SHA-256 hex of the raw public key", - "public_key": "standard base64 of the 32-byte public key" -} -``` - -`Keys` publishes the public half, never private material. It does not authorize -trust-anchor replacement. Key rotation is deliberately not implemented: boot -with a different key refuses when the existing history fails verification. - -## Transport - -[proto/saas/policylog/v1/witness.proto](proto/saas/policylog/v1/witness.proto) -is the only wire projection of the Go contract, and -[code/server/server.go](code/server/server.go) is the only adapter. The server -owns the wire shape and the error translation and no policy of its own: -validation, sequencing, signing and the five-second budget stay behind the -service boundary. Regenerate with the command in -[proto/README.md](proto/README.md); never by hand and never with `buf generate`. - -The proto carries every contract field losslessly, which is a requirement rather -than a convenience: the digests travel as raw bytes and the timestamps as -seconds and nanoseconds, so a reader can recompute `entry_hash` from the entry -it received, and a retry of one operation reproduces the same hash. The one -thing the projection has to add is a **refusal of an absent `requested_at`**: -`AsTime` of a missing Timestamp is 1970-01-01, which `Entry.Validate` accepts, -so without that check the witness would sign a hash over a timestamp the caller -never sent and no retry could reproduce it. - -The five taxonomy errors are not interchangeable and each gets its own code. A -caller told the wrong one either gives up on a state that was about to clear or -retries one that never will: - -| Contract error | Code | What the caller must do | -| --- | --- | --- | -| `ErrInvalid` | `InvalidArgument` | Never retry these bytes; they cannot be made to succeed | -| `ErrConflict` | `AlreadyExists` | Permanent: the operation id names different bytes. Do not retry, and do not rename the operation to get past it | -| `ErrPending` | `FailedPrecondition` | Do not retry this operation. Only an explicit retry of the operation holding the next sequence can clear it — and it does clear, which is why this is not `AlreadyExists` | -| `ErrRollback` | `Aborted` | A sequencer check failed: the head regressed, changed a known hash, or the caller is ahead of the log. Stop serving the authority still held locally | -| `ErrIntegrity` | `DataLoss` | The log's own stored evidence does not verify. Unrecoverable, and distinct from a regression | -| cancellation / deadline | `Canceled` / `DeadlineExceeded` | Genuinely ambiguous: a storage request in flight can have committed. Retry the identical operation and let the head answer | -| anything else | `Unavailable` | No sequence was assigned and no receipt exists; retry the identical operation | - -`Unavailable` deliberately carries a fixed message. A store error can name a -bucket and an endpoint, the caller's correct behaviour does not depend on it, -and the detail goes to this service's own failure reporter instead. - -[code/main.go](code/main.go) is the binary. Every construction happens **before** -`net.Listen`, in this order, and any failure is fatal: resolve the whole -configuration group, read the Codefly-allocated port, parse the delivered signing-key -secret, open the bucket, build the warehouse writer, start the mirror -worker, then `witness.Open`, which verifies the entire committed chain under -that key. A witness that accepted an append it could not sign, store, or verify -the history of would be worse than one that is down, because the caller would -commit against it. - -`POLICY_LOG_BUCKET`, `POLICY_LOG_WAREHOUSE_ENDPOINT` and -`POLICY_LOG_WAREHOUSE_TOKEN` come from the `policy-log` configuration group and -from nowhere else. There is no process-environment fallback, no default bucket -and no development bypass, so an ambient value cannot make this service witness -somewhere nobody provisioned — and the refusal names the missing keys, never the -delivered values, because one of the three is a credential. The native composition cannot yet start this service with its empty group. -Local execution must use the same signing and verification protocol with durable -custody independent of Accounts; missing native storage and bootstrap are owner -tooling gaps, not a reason to create a host-private log or fake receipts. - -The listener registers the four witness operations and nothing else — no -reflection service and no health service — because its surface is the whole of -what a composed workload could reach if mesh policy ever leaked. - -## Storage and failures - -[objectstore.Store](code/objectstore/store.go) has two operations: a strongly -consistent read returning bytes and generation together, and a conditional -write. The production [GCS adapter](code/objectstore/gcs/gcs.go) uses the -official client, JSON reads, CRC32C uploads, no resumable upload, and -`RetryNever`. Tests use the [in-memory fake](code/objectstore/memory/memory.go). - -Infrastructure initializes `head.json` once with the following JSON. The runtime -refuses an absent head instead of treating a deletion as an empty log: - -```json -{"seq":0,"chain_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} -``` - -For each append, the witness reads the head generation and verifies any new -committed prefix. It prepares a receipt candidate, creates -`entries/00000000000000000001.json` (and succeeding fixed-width sequence names) -with `ifGenerationMatch=0`, then advances `head.json` using -`ifGenerationMatch=`. Only a successful head write makes -the entry visible to `Read` and allows `Append` to return its receipt. A -candidate's signature in the private object store is not yet an issued receipt. - -Every conditional write has one attempt. A lost precondition is a refusal with -no receipt, never an internal retry with a newly minted sequence. Two appenders -over a prepared identical entry may race the head; exactly one wins. Two -different entries racing a sequence cannot overwrite one another. - -| Failure | Result | -| --- | --- | -| Immutable entry creation fails | No head advance and no receipt | -| Entry persists but head write fails | Entry is invisible; no receipt | -| Head precondition is lost | Refusal, no retry, no receipt | -| Head commits but response is lost | Exact retry recovers the persisted receipt | -| Warehouse mirror fails or queue fills | Committed receipt remains valid; repair mirror from `Read` | -| Head is unavailable, regresses, or changes a known hash | Append, cursor, and reads refuse | - -An uncommitted entry reserves its immutable sequence. Only an explicit retry of -that same operation and hash can finish it, using the original candidate. A -different operation refuses rather than deleting, overwriting, skipping, or -automatically committing the orphan. This sacrifices availability after an -interrupted append until its original operation is retried. It prevents a -recovery worker from performing a narrowing after its request has expired. - -The in-memory index is rebuilt from the entire committed chain on boot. It is -not a durable authority. Every operation re-reads the head, including idempotent -appends and empty reads; cached evidence never hides an unreachable store. -Within a process the cursor only advances. Across restarts, deletion protection -and the host's persisted verified cursor remain necessary: a signing key alone -cannot detect a privileged operator restoring an older valid head and prefix. - -### Five-second budget - -`Append` bounds the caller's context to five seconds, including queueing and -catching up with another appender. Earlier deadlines win. Storage receives the -same cancellation context, and cancellation is checked before each mutation -and before returning any receipt. There is no detached append goroutine, retry, -or warehouse wait on the request path. - -Cancellation is not a distributed abort. A GCS request already in flight can -have committed even when the response times out. The adapter cannot promise -that GCS itself never completes such a request after the local deadline; GCS -generation preconditions do not include a server-enforced expiration time. The -service withholds the receipt on timeout and reconciles the ambiguity through -the head on a later explicit retry/read. The fake proves that no expired write -is initiated or committed by a context-honoring store; it does not prove a -stronger remote cancellation guarantee. That distinction needs real-GCS -qualification before the transport can claim the full deadline requirement. - -## Warehouse mirror - -The [warehouse writer](code/mirror/warehouse.go) follows the repository's JSON -POST ingestion convention with a dedicated credential. The service offers an -entry only after the head commits. One worker consumes a bounded 64-entry -queue; failed and dropped mirrors are observable and recoverable from `Read`. -The mirror may contain duplicates after explicit retries and must deduplicate -by operation ID. It is best effort, is not backed by the accounts database, -and is never consulted to assign a sequence, sign a receipt, or reconstruct the -authoritative cursor. - -## Verification - -From `code/`: - -```sh -go build ./... -go vet ./... -gofmt -l . -go test -race -v ./... -``` - -From `module/services/policy-log`, after any proto change and before it: - -```sh -codefly generate proto --proto ./proto --output .. --template policy-log/proto/buf.gen.yaml -``` - -The witness tests independently encode the hashes and signatures and exercise -two appender instances over one generation-aware fake. The GCS adapter tests -intercept the official client's HTTP requests and verify its preconditions, -single-attempt failures, immutable entry names, bounded reads, and cancellation. -They do not contact GCS. Key tests refuse absent, malformed, concatenated and wrong-algorithm secret values; -service tests prove that a failed head write never offers an entry to the mirror -and a failed mirror cannot decide whether a receipt exists. - -The transport is tested twice over. `code/server/server_test.go` drives each arm -of the error taxonomy directly, which is the only way to reach all of them, and -asserts that two arms never share a code and that no sentinel is left unmapped. -`code/server/transport_test.go` then runs the whole shipped stack — witness, -service, projection, generated client — over a real loopback gRPC connection, so -the arms are the ones the shipped code actually raises: a receipt that verifies -under the key `Keys` publishes, a retry returning the original receipt, a reused -operation id with different bytes as `AlreadyExists`, an interrupted append as -`FailedPrecondition` until its own retry finishes it, and a read at `head + 1` -empty while `head + 2` refuses. - -Not exercised, by name: real GCS, deployed IAM and retention policy, the -container build, `codefly ci run`, `codefly run` and the Codefly service graph, -a deployed mesh policy, and a running host client. **Nothing here has been -deployed or booted** — the binary's own startup path (`codefly.Init`, the -injected port, the delivered signing-key secret, the GCS client, `witness.Open` against a real -bucket) is covered by no test and has never executed. The signature of -`resolveSettings` is tested; its one caller's use of `WorkspaceValue` is not. - -The fixed key-path literal still requires an explicit repository naming-policy -decision: the existing exemption for the host trust anchor does not cover this -new file. No gate or allowlist was weakened here. diff --git a/module/services/policy-log/buf.gen.local.yaml b/module/services/policy-log/buf.gen.local.yaml deleted file mode 100644 index f1afec55d..000000000 --- a/module/services/policy-log/buf.gen.local.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Not a command to run. `module/tools/generated-pins-gate.mjs` reads the plugin -# versions it checks this service's checked-in generated Go against from here, -# so this file must keep naming the versions the proto companion image runs. -# Regenerate with the command in proto/README.md. -version: v2 -clean: true -managed: - enabled: true - override: - - file_option: go_package_prefix - value: policy-log/pkg/gen -plugins: - - local: [go, run, google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11] - out: code/pkg/gen - opt: paths=source_relative - - local: [go, run, google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1] - out: code/pkg/gen - opt: paths=source_relative diff --git a/module/services/policy-log/builder/Dockerfile b/module/services/policy-log/builder/Dockerfile deleted file mode 100644 index 8022f031b..000000000 --- a/module/services/policy-log/builder/Dockerfile +++ /dev/null @@ -1,103 +0,0 @@ -# syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32 -# The Go modules the recipe declares it downloads (go_module_downloads), fetched -# by the caller on its own machine before any image build and supplied here as -# the build context of this name, in the Go module proxy layout. Without a -# caller supplying it, the stage is empty and the download below uses the -# network as it always did. -FROM scratch AS gomodproxy - -# Build stage -FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder - -ARG TARGETARCH - -# Private Go modules. A service built from its own directory declares its -# module's downloads in the recipe, and the caller fetches them before the build -# and supplies them as the gomodproxy context above: the build reads them from -# there and needs no credential. GOPRIVATE, a build argument the CLI sets from -# the host's own, only matters when nothing was supplied — a workspace build, or -# a plain `docker build` — where those paths bypass the public proxy and are -# fetched with git; the optional netrc secret mount on those download steps is -# for callers that predate the declaration and still pass a credential. -ARG GOPRIVATE - -# Install ca-certificates for HTTPS requests and git for private repos if needed. -# CGO services additionally need a native compiler and libc development files -# in the build stage; none of that toolchain reaches the runtime image. -RUN apk add --no-cache ca-certificates git - -ENV CGO_ENABLED=0 -ENV GOTOOLCHAIN=local - -# Set working directory -WORKDIR /app -# Copy the source code - -COPY service.codefly.yaml service.codefly.yaml - -COPY code code - - -# Download dependencies and build the binary -COPY code/go.mod code/go.sum code/ -# With the caller's prefetched modules, they are the only module source: no -# credential is needed or mounted, a module missing from them fails here rather -# than being fetched, and go.sum alone verifies them (the caller's fetch was -# verified against the checksum database on its side). GOPRIVATE is cleared so -# private paths are read from the proxy like any other. -RUN --mount=type=bind,from=gomodproxy,target=/gomodproxy \ - if [ -n "$(ls -A /gomodproxy)" ]; then export GOPROXY=file:///gomodproxy GOSUMDB=off GOPRIVATE= GONOPROXY= GONOSUMDB=; fi && \ - cd code && sha256sum go.mod go.sum > /tmp/locks.sha256 && go mod download && go list -mod=readonly -m -json all > /tmp/modules.json && go mod verify && sha256sum -c /tmp/locks.sha256 -COPY code code -RUN find . -type f -print0 | sort -z | xargs -0 sha256sum > /tmp/sources.sha256 -RUN --network=none cd code && \ - GOOS=linux GOARCH=${TARGETARCH} go build -mod=readonly -ldflags='-w -s -extldflags "-static"' -o /app/app . - -# Retain the locked graph and metadata of the executables actually compiled. -# These are build evidence, not a scan, approval, publication or installation. -RUN --network=none cd code && \ - sha256sum -c /tmp/locks.sha256 && \ - mkdir -p /app/build-evidence && \ - cp go.mod go.sum /app/build-evidence/ && \ - cp /tmp/sources.sha256 /app/build-evidence/sources.sha256 && \ - cp /tmp/modules.json /app/build-evidence/modules.json && \ - go version -m /app/app > /app/build-evidence/binaries.txt && \ - sha256sum /app/app > /app/build-evidence/binaries.sha256 - -# Final stage -FROM alpine:3.23.5@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 - -# Runtime only needs the public CA bundle. Keep build tooling out of the image. -RUN apk add --no-cache ca-certificates - -# Run from the Go source directory, mirroring the dev runner (which starts the -# binary with its working directory set to the source dir). This is what makes a -# service's runtime path — e.g. "../routing/rest", anchored at the source dir — -# resolve to the same file it does in dev. -WORKDIR /app/code - -# Set environment variables - - -# Create a non-root user -RUN adduser -D appuser - -# Copy the binary from the builder stage -COPY --chown=appuser --from=builder /app/app . -COPY --from=builder /app/build-evidence /usr/share/codefly/build - -# Copy declared runtime assets from the build context, reproducing the source -# tree under /app so a source-relative reference resolves the same as in dev. -# Services that read config or data files at startup rely on a source-relative -# fallback in dev; these COPY lines are what carry those files into the image. -# Inventory the final bytes at their real runtime locations, including assets. -RUN find /app -type f -print0 | sort -z | xargs -0 sha256sum > /usr/share/codefly/build/runtime.sha256 - -# Use the non-root user -USER appuser - -# Expose ports -EXPOSE 8080 9090 - -# Run the binary -CMD ["./app"] diff --git a/module/services/policy-log/builder/dockerignore b/module/services/policy-log/builder/dockerignore deleted file mode 100644 index d7b6bd156..000000000 --- a/module/services/policy-log/builder/dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -code/go.work -code/go.work.sum diff --git a/module/services/policy-log/code/go.mod b/module/services/policy-log/code/go.mod deleted file mode 100644 index 51873f82b..000000000 --- a/module/services/policy-log/code/go.mod +++ /dev/null @@ -1,98 +0,0 @@ -module policy-log - -go 1.27.0 - -require ( - cloud.google.com/go/storage v1.65.0 - github.com/codefly-dev/sdk-go v0.4.1 - google.golang.org/api v0.299.0 - google.golang.org/grpc v1.84.0 - google.golang.org/protobuf v1.36.12 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect - buf.build/go/protovalidate v1.4.0 // indirect - cel.dev/cel-go v0.32.0 // indirect - cel.dev/expr v0.25.3 // indirect - cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.23.3 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.1 // indirect - cloud.google.com/go/iam v1.13.0 // indirect - cloud.google.com/go/logging v1.19.1 // indirect - cloud.google.com/go/monitoring v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect - github.com/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect - github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v1.0.0 // indirect - github.com/go-openapi/errors v0.22.9 // indirect - github.com/go-openapi/jsonpointer v1.0.1 // indirect - github.com/go-openapi/jsonreference v1.0.2 // indirect - github.com/go-openapi/loads v0.25.3 // indirect - github.com/go-openapi/spec v1.0.1 // indirect - github.com/go-openapi/strfmt v0.27.2 // indirect - github.com/go-openapi/swag/conv v0.29.2 // indirect - github.com/go-openapi/swag/jsonutils v0.29.2 // indirect - github.com/go-openapi/swag/loading v0.29.2 // indirect - github.com/go-openapi/swag/mangling v0.29.2 // indirect - github.com/go-openapi/swag/pools v0.29.2 // indirect - github.com/go-openapi/swag/stringutils v0.29.2 // indirect - github.com/go-openapi/swag/typeutils v0.29.2 // indirect - github.com/go-openapi/swag/yamlutils v0.29.2 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/s2a-go v0.1.10 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect - github.com/googleapis/gax-go/v2 v2.24.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/oklog/ulid/v2 v2.1.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect - github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect - go.opentelemetry.io/otel v1.47.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect - go.opentelemetry.io/otel/log v1.47.0 // indirect - go.opentelemetry.io/otel/metric v1.47.0 // indirect - go.opentelemetry.io/otel/sdk v1.47.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.47.0 // indirect - go.opentelemetry.io/otel/trace v1.47.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/net v0.60.0 // indirect - golang.org/x/oauth2 v0.37.0 // indirect - golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.49.0 // indirect - golang.org/x/term v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.16.0 // indirect - google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect -) diff --git a/module/services/policy-log/code/go.sum b/module/services/policy-log/code/go.sum deleted file mode 100644 index 915523541..000000000 --- a/module/services/policy-log/code/go.sum +++ /dev/null @@ -1,326 +0,0 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= -buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= -cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= -cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= -cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= -cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= -cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.23.3 h1:UMK+oBtuNGMCR/6i6mmySUItqjOazpJrbmZyhGbGBWo= -cloud.google.com/go/auth v0.23.3/go.mod h1:fClbry28fo7XkxhSeT6AQtAVAp6Jy0fW9N99PoPNPFM= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.1 h1:CTE1OWBQ0vnF5uHwdFAQJvMQ0Fi/KRcqqKTo9V0F8Ik= -cloud.google.com/go/compute/metadata v0.9.1/go.mod h1:NtnlvB6X3t4R6xSWyVX/ZWk493PCxGQlhI/iqxh4M8I= -cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U= -cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= -cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU= -cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg= -cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= -cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= -cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= -cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= -cloud.google.com/go/storage v1.65.0 h1:McbFt5j+hTNx+dkFuzq7teakIKcpqGp/cJZRxMyfvAc= -cloud.google.com/go/storage v1.65.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= -cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= -cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= -github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= -github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= -github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVhQ7llQl+ls5swIL8JgJu89X0wlDMeqM+o= -github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= -github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= -github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= -github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= -github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= -github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= -github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= -github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= -github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= -github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= -github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= -github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= -github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= -github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= -github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= -github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= -github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= -github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= -github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= -github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= -github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= -github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= -github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= -github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= -github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= -github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= -github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= -github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= -github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= -github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= -github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= -github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= -github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= -github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= -github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= -github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= -github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= -github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= -github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= -github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= -github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= -github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= -github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= -github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= -github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= -github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= -github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= -github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= -github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= -github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= -github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= -github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= -github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0= -github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= -github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= -github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= -github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= -github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= -github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= -github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= -github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= -github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= -github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= -github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= -github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= -github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= -github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.45.0 h1:9jR0ZPRok9ryaOQ2Wx8rg5F7Aon59mxrqbVI60/vlBk= -go.opentelemetry.io/contrib/detectors/gcp v1.45.0/go.mod h1:VSme3o2fvSg5bVg0dRzyHaj4Z5EVhG+g2Fde6LKzmQA= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= -go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= -go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= -go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= -go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= -go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= -go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= -go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= -go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= -go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= -go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= -go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= -go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= -go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= -go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= -golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= -golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.299.0 h1:b3K+ydSMd0kh6TQI6bJyApRQfqQX2MfSOaVkpM59mJw= -google.golang.org/api v0.299.0/go.mod h1:zlR3GVA8b2R5nv5Ij9UWe37StVB3cxDD7DBFi4ZFsHw= -google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= -google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= -google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= -google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= -google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= -k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= -sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= -sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= -sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= -sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/module/services/policy-log/code/identity/signing_key.go b/module/services/policy-log/code/identity/signing_key.go deleted file mode 100644 index c084fcb65..000000000 --- a/module/services/policy-log/code/identity/signing_key.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package identity owns the witness signing-key trust boundary. -package identity - -import ( - "bytes" - "crypto/ed25519" - "crypto/x509" - "encoding/base64" - "errors" -) - -const SigningKeySetting = "POLICY_LOG_SIGNING_KEY" - -// ParseSigningKey accepts the persistent Ed25519 PKCS#8 key delivered as a -// standard-base64 secret value. It never reads a file or generates a key. -// Startup errors contain no supplied value or parser detail. -func ParseSigningKey(value string) (ed25519.PrivateKey, error) { - if len(value) == 0 || len(value) > 4096 { - return nil, errors.New("policy-log: a signing-key secret of at most 4096 bytes is required") - } - body, err := base64.StdEncoding.Strict().DecodeString(value) - if err != nil || base64.StdEncoding.EncodeToString(body) != value { - return nil, errors.New("policy-log: signing-key secret must be canonical standard base64") - } - parsed, err := x509.ParsePKCS8PrivateKey(body) - if err != nil { - return nil, errors.New("policy-log: signing-key secret must contain exactly one PKCS#8 private key") - } - key, ok := parsed.(ed25519.PrivateKey) - if !ok || len(key) != ed25519.PrivateKeySize { - return nil, errors.New("policy-log: signing-key secret is not Ed25519") - } - canonical, err := x509.MarshalPKCS8PrivateKey(key) - if err != nil || !bytes.Equal(canonical, body) { - return nil, errors.New("policy-log: signing-key secret must contain exactly one canonical PKCS#8 key") - } - return key, nil -} diff --git a/module/services/policy-log/code/identity/signing_key_test.go b/module/services/policy-log/code/identity/signing_key_test.go deleted file mode 100644 index 845dc0538..000000000 --- a/module/services/policy-log/code/identity/signing_key_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package identity - -import ( - "bytes" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "encoding/base64" - "strings" - "testing" -) - -func TestSigningKeyAcceptsOnlyDeliveredEd25519PKCS8(t *testing.T) { - want := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32)) - der, err := x509.MarshalPKCS8PrivateKey(want) - if err != nil { - t.Fatal(err) - } - valid := base64.StdEncoding.EncodeToString(der) - other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - otherDER, err := x509.MarshalPKCS8PrivateKey(other) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name, value string - valid bool - }{ - {"valid", valid, true}, - {"missing", "", false}, - {"invalid", "private-invalid-value", false}, - {"oversize", strings.Repeat("x", 4097), false}, - {"wrong_algorithm", base64.StdEncoding.EncodeToString(otherDER), false}, - {"multiple_keys", base64.StdEncoding.EncodeToString(append(bytes.Clone(der), der...)), false}, - {"newline", valid + "\n", false}, - {"raw_seed", base64.StdEncoding.EncodeToString(want.Seed()), false}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := ParseSigningKey(tc.value) - if tc.valid { - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("valid key: %v", err) - } - again, err := ParseSigningKey(tc.value) - if err != nil || !bytes.Equal(again, got) { - t.Fatal("same delivered key changed identity") - } - } else if err == nil || got != nil { - t.Fatal("invalid key accepted") - } else if tc.value != "" && strings.Contains(err.Error(), tc.value) { - t.Fatal("error exposed key value") - } - }) - } -} diff --git a/module/services/policy-log/code/main.go b/module/services/policy-log/code/main.go deleted file mode 100644 index aa3335c2c..000000000 --- a/module/services/policy-log/code/main.go +++ /dev/null @@ -1,190 +0,0 @@ -// policy-log is the independent signed witness for authority changes. This -// binary wires the delivered signing-key secret, the object store, the warehouse -// mirror and the transport-independent service onto the gRPC endpoint Codefly -// allocated, and refuses to start if any one of them is not provisioned. -// -// Nothing here may start a listener it cannot witness with, so every -// construction happens BEFORE net.Listen and any failure is fatal. A witness -// that accepted an append it could not sign, store or verify the history of -// would be worse than one that is down: the caller would commit against it. -package main - -import ( - "context" - "crypto/ed25519" - "errors" - "fmt" - "log" - "net" - "os/signal" - "strconv" - "strings" - "syscall" - - "policy-log/identity" - "policy-log/mirror" - "policy-log/objectstore/gcs" - policylogv1 "policy-log/pkg/gen/saas/policylog/v1" - "policy-log/server" - "policy-log/service" - "policy-log/witness" - - codefly "github.com/codefly-dev/sdk-go" - "google.golang.org/grpc" -) - -// grpcEndpointName is the endpoint this service declares in -// service.codefly.yaml. It is the endpoint's NAME, which is how the port is -// looked up; the port itself is allocated by Codefly and never written here. -const grpcEndpointName = "grpc" - -// policyLogConfiguration is the workspace configuration group composition -// provisions for this service. It is the ONLY source of these settings. -const policyLogConfiguration = "policy-log" - -var settingKeys = struct{ bucket, warehouseEndpoint, warehouseToken string }{ - bucket: "POLICY_LOG_BUCKET", - warehouseEndpoint: "POLICY_LOG_WAREHOUSE_ENDPOINT", - warehouseToken: "POLICY_LOG_WAREHOUSE_TOKEN", -} - -type settings struct{ bucket, warehouseEndpoint, warehouseToken string } - -// resolveSettings reads the whole configuration from the delivered group and -// from nowhere else. -// -// There is deliberately no process-environment fallback, no default bucket and -// no development bypass. A witness that fell back to an ambient value would -// witness somewhere nobody provisioned — and it would do so by starting -// successfully, which is the one failure mode this service cannot have. Every -// missing key is named at once, so a half-provisioned group takes one restart -// to diagnose rather than three. -func resolveSettings(value func(group, key string) (string, error)) (settings, error) { - var resolved settings - var missing []string - for _, field := range []struct { - key string - into *string - }{ - {settingKeys.bucket, &resolved.bucket}, - {settingKeys.warehouseEndpoint, &resolved.warehouseEndpoint}, - {settingKeys.warehouseToken, &resolved.warehouseToken}, - } { - // A group that is absent and a key that is empty are the same thing to - // this service: nothing was provisioned. Neither value is reported back, - // because one of the three is a credential. - got, err := value(policyLogConfiguration, field.key) - if err != nil || got == "" { - missing = append(missing, field.key) - continue - } - *field.into = got - } - if len(missing) > 0 { - return settings{}, fmt.Errorf("configuration group %q delivered no %s: composition must provision the whole group before this service can witness", - policyLogConfiguration, strings.Join(missing, ", ")) - } - return resolved, nil -} - -// resolveSigningKey reads only the declared secret group. The public pin is -// independently delivered to clients; this service cannot choose or replace it. -func resolveSigningKey(secret func(string, string) (string, error)) (ed25519.PrivateKey, error) { - value, err := secret(policyLogConfiguration, identity.SigningKeySetting) - if err != nil { - return nil, fmt.Errorf("policy-log: secret %s/%s is required", policyLogConfiguration, identity.SigningKeySetting) - } - key, err := identity.ParseSigningKey(value) - if err != nil { - return nil, fmt.Errorf("policy-log: secret %s/%s: %w", policyLogConfiguration, identity.SigningKeySetting, err) - } - return key, nil -} - -func run(ctx context.Context, stop func()) error { - resolved, err := resolveSettings(func(group, key string) (string, error) { - return codefly.For(ctx).WorkspaceValue(group, key) - }) - if err != nil { - return err - } - grpcNetwork, err := codefly.For(ctx).Endpoint(grpcEndpointName).API("grpc").ResolveNetworkInstance() - if err != nil { - return fmt.Errorf("resolve Codefly endpoint %q: %w", grpcEndpointName, err) - } - if grpcNetwork == nil || grpcNetwork.Port == 0 { - return fmt.Errorf("Codefly did not inject a port for the %q endpoint", grpcEndpointName) - } - port := grpcNetwork.Port - key, err := resolveSigningKey(codefly.For(ctx).WorkspaceSecret) - if err != nil { - return err - } - store, err := gcs.New(ctx, resolved.bucket) - if err != nil { - return err - } - defer func() { _ = store.Close() }() - warehouse, err := mirror.NewWriter(resolved.warehouseEndpoint, resolved.warehouseToken) - if err != nil { - return err - } - // A dropped or failed mirror is reported and repairable from Read. It never - // changes an append's outcome, so it is logged, not fatal. - worker, err := mirror.Start(ctx, warehouse, func(err error) { - log.Printf("policy-log: warehouse mirror: %v", err) - }) - if err != nil { - return err - } - // Open verifies the entire committed chain under this key. It is deliberately - // ahead of net.Listen: a witness that cannot verify its own history, or whose - // head is absent or regressed, must never reach a state where it can accept - // an append. - witnessLog, err := witness.Open(ctx, store, key) - if err != nil { - return err - } - appendOnly, err := service.New(witnessLog, worker) - if err != nil { - return err - } - handler, err := server.New(appendOnly, func(err error) { - log.Printf("policy-log: witness: %v", err) - }) - if err != nil { - return err - } - listener, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(int(port)))) - if err != nil { - return err - } - // No reflection and no health service: this listener is private to the - // module and its surface is the four witness operations, nothing more. - grpcServer := grpc.NewServer() - policylogv1.RegisterWitnessServiceServer(grpcServer, handler) - go func() { - if err := grpcServer.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { - log.Printf("policy-log: serve: %v", err) - stop() - } - }() - fmt.Printf("policy log witness listening on Codefly gRPC endpoint %d\n", port) - <-ctx.Done() - grpcServer.GracefulStop() - return nil -} - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - provider, err := codefly.Init(ctx) - if err != nil { - log.Fatalf("policy-log: %v", err) - } - ctx = provider.Inject(ctx) - defer codefly.CatchPanic(ctx) - if err := run(ctx, stop); err != nil { - log.Fatalf("policy-log: %v", err) - } -} diff --git a/module/services/policy-log/code/main_test.go b/module/services/policy-log/code/main_test.go deleted file mode 100644 index c224aba11..000000000 --- a/module/services/policy-log/code/main_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package main - -import ( - "bytes" - "crypto/ed25519" - "crypto/x509" - "encoding/base64" - "errors" - "os" - "strings" - "testing" - - "gopkg.in/yaml.v3" -) - -// TestResolveSettingsNamesEveryMissingKeyAtOnce: a half-provisioned group must -// cost one restart to diagnose, not one per key, and it must fail rather than -// start a listener that cannot witness. -func TestResolveSettingsNamesEveryMissingKeyAtOnce(t *testing.T) { - _, err := resolveSettings(func(string, string) (string, error) { return "", nil }) - if err == nil { - t.Fatal("resolveSettings accepted a group that delivered nothing") - } - for _, key := range []string{settingKeys.bucket, settingKeys.warehouseEndpoint, settingKeys.warehouseToken} { - if !strings.Contains(err.Error(), key) { - t.Errorf("the refusal does not name the missing %s: %v", key, err) - } - } -} - -// TestResolveSettingsRefusesEachMissingKeyIndividually proves no single setting -// has a default. A bucket without a warehouse credential, or a warehouse -// without a bucket, is not a startable configuration. -func TestResolveSettingsRefusesEachMissingKeyIndividually(t *testing.T) { - delivered := map[string]string{ - settingKeys.bucket: "example-witness-bucket", - settingKeys.warehouseEndpoint: "https://warehouse.example.com/ingest", - settingKeys.warehouseToken: "example-warehouse-token", - } - for withheld := range delivered { - _, err := resolveSettings(func(_, key string) (string, error) { - if key == withheld { - return "", nil - } - return delivered[key], nil - }) - if err == nil { - t.Errorf("resolveSettings started with %s missing", withheld) - continue - } - if !strings.Contains(err.Error(), withheld) { - t.Errorf("the refusal for a missing %s does not name it: %v", withheld, err) - } - } -} - -// TestResolveSettingsNeverFallsBackToTheProcessEnvironment is the one that -// keeps "no development bypass" true. An ambient bucket or warehouse credential -// would make this service witness somewhere nobody provisioned — and it would -// do so by starting successfully. -func TestResolveSettingsNeverFallsBackToTheProcessEnvironment(t *testing.T) { - t.Setenv(settingKeys.bucket, "ambient-bucket") - t.Setenv(settingKeys.warehouseEndpoint, "https://ambient.example.com/ingest") - t.Setenv(settingKeys.warehouseToken, "ambient-token") - // The group is absent, exactly as it is on a workstation. - resolved, err := resolveSettings(func(string, string) (string, error) { - return "", errors.New("no configuration found for policy-log") - }) - if err == nil { - t.Fatalf("resolveSettings fell back to the process environment and resolved %+v", resolved) - } - for _, ambient := range []string{"ambient-bucket", "ambient.example.com", "ambient-token"} { - if strings.Contains(err.Error(), ambient) { - t.Errorf("the refusal echoed an ambient value %q", ambient) - } - } -} - -// TestResolveSettingsNeverEchoesADeliveredValue: one of the three settings is a -// credential, and a startup refusal is the most widely read log line a service -// has. The refusal names keys, never values. -func TestResolveSettingsNeverEchoesADeliveredValue(t *testing.T) { - const credential = "s3cr3t-warehouse-token" - _, err := resolveSettings(func(_, key string) (string, error) { - switch key { - case settingKeys.warehouseToken: - return credential, nil - case settingKeys.bucket: - return "example-witness-bucket", nil - } - return "", nil - }) - if err == nil { - t.Fatal("resolveSettings accepted a group with no warehouse endpoint") - } - for _, value := range []string{credential, "example-witness-bucket"} { - if strings.Contains(err.Error(), value) { - t.Fatalf("the refusal echoed a delivered value %q: %v", value, err) - } - } -} - -// TestResolveSettingsAcceptsAWholeGroup is the positive half: a fully delivered -// group resolves, and each value lands in its own field rather than being -// transposed. -func TestResolveSettingsAcceptsAWholeGroup(t *testing.T) { - resolved, err := resolveSettings(func(group, key string) (string, error) { - if group != policyLogConfiguration { - t.Fatalf("resolveSettings read group %q, want %q", group, policyLogConfiguration) - } - switch key { - case settingKeys.bucket: - return "example-witness-bucket", nil - case settingKeys.warehouseEndpoint: - return "https://warehouse.example.com/ingest", nil - case settingKeys.warehouseToken: - return "example-warehouse-token", nil - } - t.Fatalf("resolveSettings read an undeclared key %q", key) - return "", nil - }) - if err != nil { - t.Fatalf("resolveSettings: %v", err) - } - if resolved.bucket != "example-witness-bucket" || - resolved.warehouseEndpoint != "https://warehouse.example.com/ingest" || - resolved.warehouseToken != "example-warehouse-token" { - t.Fatalf("resolveSettings transposed a value: %+v", resolved) - } -} - -type manifest struct { - Name string `yaml:"name"` - Endpoints []struct { - Name string `yaml:"name"` - API string `yaml:"api"` - Visibility string `yaml:"visibility"` - } `yaml:"endpoints"` - ServiceDependencies []struct { - Name string `yaml:"name"` - } `yaml:"service-dependencies"` - Spec struct { - ConnectEndpoint bool `yaml:"connect-endpoint"` - ProtocolOutputDir []string `yaml:"protocol-output-dirs"` - } `yaml:"spec"` -} - -func readManifest(t *testing.T) manifest { - t.Helper() - body, err := os.ReadFile("../service.codefly.yaml") - if err != nil { - t.Fatalf("read the service manifest: %v", err) - } - var declared manifest - if err := yaml.Unmarshal(body, &declared); err != nil { - t.Fatalf("parse the service manifest: %v", err) - } - return declared -} - -// TestTheWitnessListenerStaysPrivate is a gate, not a description. The witness -// must be reachable only by the host's declared client through -// workload-authenticated mesh policy: a `visibility` of module or public, a -// second endpoint, a Connect listener or a REST api would each put it in front -// of composed workloads or the public gateway, where a shared credential would -// be enough authority to append. -func TestTheWitnessListenerStaysPrivate(t *testing.T) { - declared := readManifest(t) - if len(declared.Endpoints) != 1 { - t.Fatalf("the witness declares %d endpoints; it must declare exactly the one private listener", len(declared.Endpoints)) - } - endpoint := declared.Endpoints[0] - if endpoint.Name != grpcEndpointName { - t.Errorf("the declared endpoint is %q but the binary looks up %q, so it would bind no port", endpoint.Name, grpcEndpointName) - } - if endpoint.Visibility != "" { - t.Errorf("the witness endpoint declares visibility %q; it must declare none, which is private", endpoint.Visibility) - } - if endpoint.API != "" && endpoint.API != "grpc" { - t.Errorf("the witness endpoint declares api %q; only grpc may be projected", endpoint.API) - } - if declared.Spec.ConnectEndpoint { - t.Error("the witness declares a Connect endpoint, which is browser-reachable through the gateway") - } - for _, dependency := range declared.ServiceDependencies { - t.Errorf("the witness declares a dependency on %q; it depends on nothing in the graph and must not be routed through one", dependency.Name) - } -} - -// TestEveryDeclaredProtocolOutputDirIsGenerated: a declared generated directory -// that no generator writes is a claim nobody can reproduce, and the inverse — -// generated bytes under a directory the manifest does not declare — is output -// Codefly's sync-drift phase does not know it owns. -func TestEveryDeclaredProtocolOutputDirIsGenerated(t *testing.T) { - declared := readManifest(t) - if len(declared.Spec.ProtocolOutputDir) == 0 { - t.Fatal("the manifest declares no protocol output directory, so the agent has no protobuf sync input") - } - for _, dir := range declared.Spec.ProtocolOutputDir { - entries, err := os.ReadDir("../" + dir) - if err != nil { - t.Errorf("declared protocol output dir %q does not exist: %v", dir, err) - continue - } - if len(entries) == 0 { - t.Errorf("declared protocol output dir %q is empty; nothing generates into it", dir) - } - } -} - -func TestSigningIdentityComesOnlyFromDeliveredSecret(t *testing.T) { - t.Setenv("POLICY_LOG_SIGNING_KEY", "ambient-secret-must-not-be-read") - t.Setenv("POLICY_LOG_SIGNING_KEY_PATH", "/tmp/unused-key") - _, err := resolveSigningKey(func(group, key string) (string, error) { - if group != policyLogConfiguration || key != "POLICY_LOG_SIGNING_KEY" { - t.Fatalf("unexpected secret selection: %s/%s", group, key) - } - return "", errors.New("provider error containing private value") - }) - if err == nil || !strings.Contains(err.Error(), "POLICY_LOG_SIGNING_KEY") || strings.Contains(err.Error(), "private value") { - t.Fatalf("missing secret refusal: %v", err) - } - want := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{19}, 32)) - der, err := x509.MarshalPKCS8PrivateKey(want) - if err != nil { - t.Fatal(err) - } - got, err := resolveSigningKey(func(string, string) (string, error) { return base64.StdEncoding.EncodeToString(der), nil }) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("delivered signing identity: %v", err) - } -} - -func TestInvalidDeliveredSigningSecretNamesItsSetting(t *testing.T) { - for _, value := range []string{"", "malformed-private-value"} { - key, err := resolveSigningKey(func(string, string) (string, error) { return value, nil }) - if err == nil || key != nil || !strings.Contains(err.Error(), "policy-log/POLICY_LOG_SIGNING_KEY") { - t.Fatalf("unnamed refusal: %v", err) - } - if value != "" && strings.Contains(err.Error(), value) { - t.Fatal("refusal exposed secret") - } - } -} diff --git a/module/services/policy-log/code/mirror/warehouse.go b/module/services/policy-log/code/mirror/warehouse.go deleted file mode 100644 index edc9cb54e..000000000 --- a/module/services/policy-log/code/mirror/warehouse.go +++ /dev/null @@ -1,126 +0,0 @@ -// Package mirror implements the best-effort warehouse projection. The warehouse -// receives receipts; it never produces them or participates in the head commit. -package mirror - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "sync/atomic" - "time" - - "policy-log/witness" -) - -// Writer follows the repository's warehouse convention: one JSON object per -// POST with a dedicated bearer credential. No host sink or credential is reused. -type Writer struct { - endpoint, token string - client *http.Client -} - -func NewWriter(endpoint, token string) (*Writer, error) { - u, err := url.Parse(endpoint) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || token == "" { - return nil, errors.New("policy-log: warehouse HTTPS endpoint and dedicated writer token are required") - } - return &Writer{endpoint: endpoint, token: token, client: &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, - }}, nil -} - -func (w *Writer) Write(ctx context.Context, record witness.Record) error { - body, err := json.Marshal(record) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.endpoint, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+w.token) - response, err := w.client.Do(req) - if err != nil { - return err - } - defer func() { _ = response.Body.Close() }() - // A broken sink cannot make its response an unbounded read either. - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("policy-log: warehouse HTTP %d", response.StatusCode) - } - return nil -} - -type Sink interface { - Write(context.Context, witness.Record) error -} - -// Worker has bounded memory and concurrency. Dropped or failed mirrors are -// reported and repairable from Read. It has no durable authority of its own. -type Worker struct { - queue chan witness.Record - failed func(error) - done <-chan struct{} - dropped atomic.Uint64 -} - -func Start(ctx context.Context, sink Sink, failed func(error)) (*Worker, error) { - if sink == nil || failed == nil { - return nil, errors.New("policy-log: mirror sink and failure reporter required") - } - w := &Worker{queue: make(chan witness.Record, 64), failed: failed, done: ctx.Done()} - go func() { - var reported uint64 - for { - select { - case <-ctx.Done(): - return - case record := <-w.queue: - if dropped := w.dropped.Load(); dropped > reported { - failed(fmt.Errorf("policy-log: dropped %d warehouse mirrors; repair from Read", dropped-reported)) - reported = dropped - } - attempt, cancel := context.WithTimeout(ctx, 5*time.Second) - err := sink.Write(attempt, record) - cancel() - if err != nil { - failed(err) - } - } - } - }() - return w, nil -} - -// Offer must be called only after Append returned a committed receipt. It never -// waits for warehouse I/O and never changes an append's outcome. -func (w *Worker) Offer(record witness.Record) { - select { - case <-w.done: - w.dropped.Add(1) - return - default: - } - record.Entry.NarrowingHash = bytes.Clone(record.Entry.NarrowingHash) - record.Receipt.EntryHash = bytes.Clone(record.Receipt.EntryHash) - record.Receipt.PrevHash = bytes.Clone(record.Receipt.PrevHash) - record.Receipt.ChainHash = bytes.Clone(record.Receipt.ChainHash) - record.Receipt.Signature = bytes.Clone(record.Receipt.Signature) - select { - case w.queue <- record: - default: - // The reporting callback may itself block on logging. It must never - // run on the append path; expose the monotonic drop count instead. - w.dropped.Add(1) - } -} - -func (w *Worker) Dropped() uint64 { return w.dropped.Load() } diff --git a/module/services/policy-log/code/mirror/warehouse_test.go b/module/services/policy-log/code/mirror/warehouse_test.go deleted file mode 100644 index b43029e21..000000000 --- a/module/services/policy-log/code/mirror/warehouse_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package mirror - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "sync/atomic" - "testing" - "time" - - "policy-log/witness" -) - -type transportFunc func(*http.Request) (*http.Response, error) - -func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -type sinkFunc func(context.Context, witness.Record) error - -func (f sinkFunc) Write(ctx context.Context, r witness.Record) error { return f(ctx, r) } - -func TestWarehouseWriterUsesDedicatedCredentialAndCommittedRecord(t *testing.T) { - w, err := NewWriter("https://warehouse.example/policy-log", "example-writer-token") - if err != nil { - t.Fatal(err) - } - w.client.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { - if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer example-writer-token" || r.Header.Get("Content-Type") != "application/json" { - t.Errorf("wrong warehouse request: %s, %v", r.Method, r.Header) - } - var record witness.Record - if err := json.NewDecoder(r.Body).Decode(&record); err != nil || record.Receipt.Seq != 7 { - t.Errorf("lost receipt: %+v, %v", record, err) - } - return &http.Response{StatusCode: 204, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil - }) - if err := w.Write(context.Background(), witness.Record{Receipt: witness.Receipt{Seq: 7}}); err != nil { - t.Fatal(err) - } -} - -func TestWarehouseWriterRefusesRedirectsAndUnsafeConfiguration(t *testing.T) { - for _, endpoint := range []string{"", "http://warehouse.example/log", "https://user:password@warehouse.example/log", "https://warehouse.example/log#fragment"} { - if _, err := NewWriter(endpoint, "example-token"); err == nil { - t.Fatalf("accepted %q", endpoint) - } - } - if _, err := NewWriter("https://warehouse.example/log", ""); err == nil { - t.Fatal("accepted absent credential") - } - w, err := NewWriter("https://warehouse.example/log", "example-token") - if err != nil { - t.Fatal(err) - } - var calls atomic.Int32 - w.client.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { - calls.Add(1) - return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{"https://other.example/steal"}}, Body: io.NopCloser(strings.NewReader(""))}, nil - }) - if err := w.Write(context.Background(), witness.Record{}); err == nil || calls.Load() != 1 { - t.Fatalf("redirect followed: calls=%d, err=%v", calls.Load(), err) - } -} - -func TestSlowFailingMirrorNeverBlocksOffer(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - entered := make(chan struct{}) - sink := sinkFunc(func(ctx context.Context, _ witness.Record) error { - select { - case <-entered: - default: - close(entered) - } - <-ctx.Done() - return ctx.Err() - }) - w, err := Start(ctx, sink, func(error) {}) - if err != nil { - t.Fatal(err) - } - w.Offer(witness.Record{}) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("mirror not called") - } - done := make(chan struct{}) - go func() { - for range 1000 { - w.Offer(witness.Record{}) - } - close(done) - }() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("mirror blocked append path") - } - if w.Dropped() == 0 { - t.Fatal("queue was not bounded or losses hidden") - } -} - -func TestWarehouseFailureIsReportedOutsideAppendPath(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - failure := make(chan error, 1) - w, err := Start(ctx, sinkFunc(func(context.Context, witness.Record) error { return errors.New("warehouse unavailable") }), func(err error) { failure <- err }) - if err != nil { - t.Fatal(err) - } - w.Offer(witness.Record{}) - select { - case err := <-failure: - if err == nil { - t.Fatal("failure hidden") - } - case <-time.After(time.Second): - t.Fatal("failure not reported") - } -} diff --git a/module/services/policy-log/code/objectstore/gcs/gcs.go b/module/services/policy-log/code/objectstore/gcs/gcs.go deleted file mode 100644 index 1c56f42a0..000000000 --- a/module/services/policy-log/code/objectstore/gcs/gcs.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package gcs implements the witness's conditional object-store seam. -package gcs - -import ( - "context" - "errors" - "fmt" - "hash/crc32" - "io" - "os" - "regexp" - - "policy-log/objectstore" - - "cloud.google.com/go/storage" - "google.golang.org/api/googleapi" -) - -var entryName = regexp.MustCompile(`^entries/[0-9]{20}\.json$`) - -// GCS owns a bucket in the witness trust domain, never a host database or a -// host-controlled bucket. It performs one attempt per write, even for 5xx. -type GCS struct { - client *storage.Client - bucket *storage.BucketHandle -} - -// New uses the service's workload identity and the official GCS endpoint. There -// is no endpoint or emulator fallback in production. Bucket identity is delivery -// data; IAM and the service's own workload identity enforce its trust domain. -func New(ctx context.Context, bucket string) (*GCS, error) { - if bucket == "" { - return nil, errors.New("policy-log: GCS bucket is required") - } - if os.Getenv("STORAGE_EMULATOR_HOST") != "" { - return nil, errors.New("policy-log: storage emulator is forbidden") - } - client, err := storage.NewClient(ctx, storage.WithJSONReads()) - if err != nil { - return nil, err - } - return newGCS(client, bucket), nil -} - -func newGCS(client *storage.Client, bucket string) *GCS { - return &GCS{client: client, bucket: client.Bucket(bucket).Retryer(storage.WithPolicy(storage.RetryNever))} -} - -func (s *GCS) Close() error { return s.client.Close() } - -func (s *GCS) Read(ctx context.Context, name string) (objectstore.Object, error) { - if err := ctx.Err(); err != nil { - return objectstore.Object{}, err - } - if name != objectstore.HeadObject && !entryName.MatchString(name) { - return objectstore.Object{}, errors.New("invalid policy-log object name") - } - reader, err := s.bucket.Object(name).NewReader(ctx) - if err != nil { - return objectstore.Object{}, classify(err) - } - defer func() { _ = reader.Close() }() - data, err := io.ReadAll(io.LimitReader(reader, objectstore.MaxObjectBytes+1)) - if err != nil { - return objectstore.Object{}, classify(err) - } - if err := ctx.Err(); err != nil { - return objectstore.Object{}, err - } - if len(data) > objectstore.MaxObjectBytes || reader.Attrs.Generation <= 0 { - return objectstore.Object{}, errors.New("invalid policy-log object size or generation") - } - // Data and generation come from ONE response, never an Attrs/read race. - return objectstore.Object{Data: data, Generation: reader.Attrs.Generation}, nil -} - -func (s *GCS) Write(ctx context.Context, name string, data []byte, generation int64) (int64, error) { - if err := ctx.Err(); err != nil { - return 0, err - } - if len(data) > objectstore.MaxObjectBytes || generation < 0 { - return 0, errors.New("invalid policy-log object write") - } - if name == objectstore.HeadObject { - // Genesis is provisioning, not a runtime reset path. - if generation == 0 { - return 0, errors.New("policy-log head must be provisioned before boot") - } - } else if !entryName.MatchString(name) || generation != 0 { - return 0, errors.New("entries must be immutable, sequence-named objects") - } - condition := storage.Conditions{GenerationMatch: generation} - if generation == 0 { - condition = storage.Conditions{DoesNotExist: true} - } - writer := s.bucket.Object(name).If(condition).NewWriter(ctx) - writer.ChunkSize = 0 // one bounded upload; no resumable session or retry - writer.ContentType = "application/json" - writer.CacheControl = "no-store" - writer.CRC32C = crc32.Checksum(data, crc32.MakeTable(crc32.Castagnoli)) - writer.SendCRC32C = true - if _, err := writer.Write(data); err != nil { - _ = writer.CloseWithError(err) - return 0, classify(err) - } - if err := ctx.Err(); err != nil { - _ = writer.CloseWithError(err) - return 0, err - } - if err := writer.Close(); err != nil { - return 0, classify(err) - } - if err := ctx.Err(); err != nil { - return 0, err - } - if writer.Attrs().Generation <= generation { - return 0, errors.New("policy-log write did not advance object generation") - } - return writer.Attrs().Generation, nil -} - -func classify(err error) error { - if errors.Is(err, storage.ErrObjectNotExist) { - return objectstore.ErrNotFound - } - var apiError *googleapi.Error - if errors.As(err, &apiError) && apiError.Code == 412 { - return objectstore.ErrPrecondition - } - return fmt.Errorf("policy-log GCS: %w", err) -} diff --git a/module/services/policy-log/code/objectstore/gcs/gcs_test.go b/module/services/policy-log/code/objectstore/gcs/gcs_test.go deleted file mode 100644 index 1d48fa7c8..000000000 --- a/module/services/policy-log/code/objectstore/gcs/gcs_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package gcs - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - "sync/atomic" - "testing" - "time" - - "policy-log/objectstore" - - "cloud.google.com/go/storage" - "google.golang.org/api/googleapi" - "google.golang.org/api/option" -) - -type transportFunc func(*http.Request) (*http.Response, error) - -func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } - -func response(code int, body string) *http.Response { - return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), ContentLength: int64(len(body))} -} - -func fakeGCS(t *testing.T, transport transportFunc) *GCS { - t.Helper() - client, err := storage.NewClient(context.Background(), option.WithHTTPClient(&http.Client{Transport: transport}), - option.WithoutAuthentication(), storage.WithJSONReads(), storage.WithDisabledClientMetrics()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = client.Close() }) - return newGCS(client, "example-policy-log") -} - -func TestGCSWritesUseExactGenerationPreconditions(t *testing.T) { - for _, tc := range []struct { - name string - generation int64 - want string - }{{objectstore.HeadObject, 41, "41"}, {"entries/00000000000000000001.json", 0, "0"}} { - t.Run(tc.name, func(t *testing.T) { - var calls atomic.Int32 - s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { - calls.Add(1) - if r.Method != http.MethodPost || r.URL.Query().Get("ifGenerationMatch") != tc.want || r.URL.Query().Get("uploadType") != "multipart" { - t.Errorf("conditional upload = %s %s", r.Method, r.URL) - } - b, err := io.ReadAll(r.Body) - if err != nil { - t.Error(err) - } - if !strings.Contains(string(b), `"crc32c"`) || !strings.Contains(string(b), `"cacheControl":"no-store"`) || !strings.Contains(string(b), `{"seq":1}`) { - t.Errorf("upload omitted checksum, cache policy or bytes: %s", b) - } - return response(200, `{"generation":"42"}`), nil - }) - generation, err := s.Write(context.Background(), tc.name, []byte(`{"seq":1}`), tc.generation) - if err != nil || generation != 42 || calls.Load() != 1 { - t.Fatalf("Write: generation=%d calls=%d err=%v", generation, calls.Load(), err) - } - }) - } -} - -func TestGCSLostPreconditionAndServerFailureNeverRetry(t *testing.T) { - for _, code := range []int{412, 503} { - t.Run(fmt.Sprint(code), func(t *testing.T) { - var calls atomic.Int32 - s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { - calls.Add(1) - _, _ = io.Copy(io.Discard, r.Body) - return response(code, fmt.Sprintf(`{"error":{"code":%d,"message":"refused"}}`, code)), nil - }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - generation, err := s.Write(ctx, objectstore.HeadObject, []byte(`{}`), 7) - if err == nil || generation != 0 || calls.Load() != 1 { - t.Fatalf("Write retried or succeeded: generation=%d calls=%d err=%v", generation, calls.Load(), err) - } - if code == 412 && !errors.Is(err, objectstore.ErrPrecondition) { - t.Fatalf("lost generation was not classified: %v", err) - } - var apiError *googleapi.Error - if code == 503 && (!errors.As(err, &apiError) || apiError.Code != 503) { - t.Fatalf("server failure retried until deadline: %v", err) - } - }) - } -} - -func TestGCSReadReturnsBytesAndGenerationFromOneResponse(t *testing.T) { - var calls atomic.Int32 - s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { - calls.Add(1) - if r.Method != http.MethodGet || r.URL.Query().Get("alt") != "media" { - t.Errorf("not one object read: %s %s", r.Method, r.URL) - } - rsp := response(200, `{"seq":1}`) - rsp.Header.Set("X-Goog-Generation", "19") - rsp.Header.Set("Content-Type", "application/json") - rsp.Header.Set("Content-Length", fmt.Sprint(rsp.ContentLength)) - return rsp, nil - }) - obj, err := s.Read(context.Background(), objectstore.HeadObject) - if err != nil || obj.Generation != 19 || string(obj.Data) != `{"seq":1}` || calls.Load() != 1 { - t.Fatalf("Read: %+v calls=%d err=%v", obj, calls.Load(), err) - } -} - -func TestGCSRefusesUnconditionalHeadAndEntryOverwrite(t *testing.T) { - s := fakeGCS(t, func(*http.Request) (*http.Response, error) { - t.Error("invalid write reached GCS") - return nil, errors.New("unexpected") - }) - for _, tc := range []struct { - name string - generation int64 - }{{objectstore.HeadObject, 0}, {objectstore.HeadObject, -1}, {"entries/00000000000000000001.json", 9}, {"other.json", 0}, {"entries/../../head.json", 0}} { - if _, err := s.Write(context.Background(), tc.name, []byte(`{}`), tc.generation); err == nil { - t.Fatalf("accepted %+v", tc) - } - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := s.Write(ctx, objectstore.HeadObject, []byte(`{}`), 1); !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled write: %v", err) - } - if _, err := s.Read(ctx, objectstore.HeadObject); !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled read: %v", err) - } -} - -func TestGCSRejectsMissingOversizedAndGenerationlessObjects(t *testing.T) { - for _, tc := range []struct { - name string - code int - body, generation string - }{{"missing", 404, `{"error":{"code":404}}`, ""}, {"oversized", 200, strings.Repeat("x", objectstore.MaxObjectBytes+1), "2"}, {"generationless", 200, `{}`, ""}} { - t.Run(tc.name, func(t *testing.T) { - s := fakeGCS(t, func(*http.Request) (*http.Response, error) { - r := response(tc.code, tc.body) - r.Header.Set("Content-Length", fmt.Sprint(r.ContentLength)) - r.Header.Set("X-Goog-Generation", tc.generation) - return r, nil - }) - _, err := s.Read(context.Background(), objectstore.HeadObject) - if err == nil { - t.Fatal("invalid read accepted") - } - if tc.code == 404 && !errors.Is(err, objectstore.ErrNotFound) { - t.Fatalf("missing classification: %v", err) - } - }) - } -} - -func TestGCSBootRefusesEmulatorOverride(t *testing.T) { - t.Setenv("STORAGE_EMULATOR_HOST", "http://example.invalid") - if s, err := New(context.Background(), "example-policy-log"); s != nil || err == nil { - t.Fatal("emulator override accepted") - } -} diff --git a/module/services/policy-log/code/objectstore/memory/memory.go b/module/services/policy-log/code/objectstore/memory/memory.go deleted file mode 100644 index d7ecf8aff..000000000 --- a/module/services/policy-log/code/objectstore/memory/memory.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package memory supplies the generation-aware fake used by policy-log tests. -// The served process never imports it. -package memory - -import ( - "bytes" - "context" - "fmt" - "sync" - - "policy-log/objectstore" -) - -type Store struct { - mu sync.Mutex - objects map[string]objectstore.Object - generation int64 -} - -func New() *Store { return &Store{objects: make(map[string]objectstore.Object)} } - -func (s *Store) Read(ctx context.Context, name string) (objectstore.Object, error) { - s.mu.Lock() - defer s.mu.Unlock() - if err := ctx.Err(); err != nil { - return objectstore.Object{}, err - } - obj, ok := s.objects[name] - if !ok { - return objectstore.Object{}, objectstore.ErrNotFound - } - obj.Data = bytes.Clone(obj.Data) - return obj, nil -} - -func (s *Store) Write(ctx context.Context, name string, data []byte, generation int64) (int64, error) { - s.mu.Lock() - defer s.mu.Unlock() - if err := ctx.Err(); err != nil { - return 0, err - } - if generation < 0 || len(data) > objectstore.MaxObjectBytes { - return 0, fmt.Errorf("invalid object write") - } - obj, exists := s.objects[name] - if (generation == 0 && exists) || (generation > 0 && (!exists || obj.Generation != generation)) { - return 0, objectstore.ErrPrecondition - } - s.generation++ - s.objects[name] = objectstore.Object{Data: bytes.Clone(data), Generation: s.generation} - return s.generation, nil -} diff --git a/module/services/policy-log/code/objectstore/store.go b/module/services/policy-log/code/objectstore/store.go deleted file mode 100644 index c6083277d..000000000 --- a/module/services/policy-log/code/objectstore/store.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package objectstore is the policy log's only persistence capability. -package objectstore - -import ( - "context" - "errors" -) - -var ( - ErrNotFound = errors.New("object not found") - ErrPrecondition = errors.New("object generation precondition lost") -) - -const MaxObjectBytes = 64 << 10 - -const HeadObject = "head.json" - -type Object struct { - Data []byte - Generation int64 -} - -// Store must provide strongly consistent reads and atomic generation checks. -// Write with generation zero creates an absent object; a positive generation -// replaces exactly that generation. Neither method retries a write. Both must -// honor cancellation, including immediately before a mutation. There is no -// delete, unconditional overwrite, listing, or database capability. -type Store interface { - Read(context.Context, string) (Object, error) - Write(context.Context, string, []byte, int64) (int64, error) -} diff --git a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go deleted file mode 100644 index a267023d6..000000000 --- a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go +++ /dev/null @@ -1,917 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: saas/policylog/v1/witness.proto - -package policylogv1 - -import ( - reflect "reflect" - sync "sync" - unsafe "unsafe" - - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Entry is one operation offered to the witness. -// -// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: -// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and -// the witness stores that digest rather than the operation. Reconciliation can -// identify an operation and verify its bytes; it cannot reconstruct them, so -// the caller must retain or recover the canonical bytes to apply it. -// -// Every field takes part in entry_hash, so every field must stay IDENTICAL on a -// retry of the same operation_id — requested_at included. The witness refuses a -// reused operation_id that names different bytes. -type Entry struct { - state protoimpl.MessageState `protogen:"open.v1"` - // operation_id identifies one logical operation for the lifetime of the log, - // and it is the caller's: only the caller knows that two attempts are the - // same operation. At most 1024 bytes of control-character-free UTF-8. - OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` - // kind is what the operation is about, at most 128 bytes. - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - // subject is what the operation acts on, at most 1024 bytes. - Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` - // tenant may be empty for a global operation. It grants no authority: this - // log performs no tenant scoping and the field is an identifier, not a claim. - Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` - // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical - // operation bytes, carried raw rather than hex or base64 so the digest the - // signature covers is the digest on the wire. - NarrowingHash []byte `protobuf:"bytes,5,opt,name=narrowing_hash,json=narrowingHash,proto3" json:"narrowing_hash,omitempty"` - // requested_at is the CALLER'S logical timestamp for the operation, not a - // deadline and not an ordering authority. It is covered by entry_hash, so an - // old operation can be retried without changing its identity only as long as - // this value is sent back unchanged. - RequestedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=requested_at,json=requestedAt,proto3" json:"requested_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Entry) Reset() { - *x = Entry{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Entry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Entry) ProtoMessage() {} - -func (x *Entry) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Entry.ProtoReflect.Descriptor instead. -func (*Entry) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{0} -} - -func (x *Entry) GetOperationId() string { - if x != nil { - return x.OperationId - } - return "" -} - -func (x *Entry) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *Entry) GetSubject() string { - if x != nil { - return x.Subject - } - return "" -} - -func (x *Entry) GetTenant() string { - if x != nil { - return x.Tenant - } - return "" -} - -func (x *Entry) GetNarrowingHash() []byte { - if x != nil { - return x.NarrowingHash - } - return nil -} - -func (x *Entry) GetRequestedAt() *timestamppb.Timestamp { - if x != nil { - return x.RequestedAt - } - return nil -} - -// Receipt is the witness's signed acknowledgement of one committed entry. -// -// Only a committed head makes a receipt exist. A signature over a prepared -// candidate that never reached the head is not a receipt and is never returned. -type Receipt struct { - state protoimpl.MessageState `protogen:"open.v1"` - // seq is the global sequence the witness assigned, never zero and never - // supplied by a caller. The first sequence is 1. Sequence, not clock time, - // orders operations. - Seq uint64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` - // entry_hash is the 32-byte digest of the appended entry. A caller compares - // it against the bytes it asked to append. - EntryHash []byte `protobuf:"bytes,2,opt,name=entry_hash,json=entryHash,proto3" json:"entry_hash,omitempty"` - // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. - PrevHash []byte `protobuf:"bytes,3,opt,name=prev_hash,json=prevHash,proto3" json:"prev_hash,omitempty"` - // chain_hash is the 32-byte chain hash at seq. - ChainHash []byte `protobuf:"bytes,4,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` - // issued_at comes from the witness, never from a caller. - IssuedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` - // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which - // depends on no protobuf field ordering. - Signature []byte `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Receipt) Reset() { - *x = Receipt{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Receipt) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Receipt) ProtoMessage() {} - -func (x *Receipt) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Receipt.ProtoReflect.Descriptor instead. -func (*Receipt) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{1} -} - -func (x *Receipt) GetSeq() uint64 { - if x != nil { - return x.Seq - } - return 0 -} - -func (x *Receipt) GetEntryHash() []byte { - if x != nil { - return x.EntryHash - } - return nil -} - -func (x *Receipt) GetPrevHash() []byte { - if x != nil { - return x.PrevHash - } - return nil -} - -func (x *Receipt) GetChainHash() []byte { - if x != nil { - return x.ChainHash - } - return nil -} - -func (x *Receipt) GetIssuedAt() *timestamppb.Timestamp { - if x != nil { - return x.IssuedAt - } - return nil -} - -func (x *Receipt) GetSignature() []byte { - if x != nil { - return x.Signature - } - return nil -} - -// Record is one entry together with the original receipt the witness issued for -// it, which is what makes a read independently verifiable. -type Record struct { - state protoimpl.MessageState `protogen:"open.v1"` - Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` - Receipt *Receipt `protobuf:"bytes,2,opt,name=receipt,proto3" json:"receipt,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Record) Reset() { - *x = Record{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Record) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Record) ProtoMessage() {} - -func (x *Record) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Record.ProtoReflect.Descriptor instead. -func (*Record) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{2} -} - -func (x *Record) GetEntry() *Entry { - if x != nil { - return x.Entry - } - return nil -} - -func (x *Record) GetReceipt() *Receipt { - if x != nil { - return x.Receipt - } - return nil -} - -// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt -// nor a reset instruction: a verifying client never lowers a persisted cursor, -// and a valid signature cannot by itself distinguish a historical cursor from a -// current one, so the client also enforces its own freshness policy. -type Cursor struct { - state protoimpl.MessageState `protogen:"open.v1"` - HeadSeq uint64 `protobuf:"varint,1,opt,name=head_seq,json=headSeq,proto3" json:"head_seq,omitempty"` - ChainHash []byte `protobuf:"bytes,2,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` - IssuedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` - Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Cursor) Reset() { - *x = Cursor{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Cursor) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Cursor) ProtoMessage() {} - -func (x *Cursor) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Cursor.ProtoReflect.Descriptor instead. -func (*Cursor) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{3} -} - -func (x *Cursor) GetHeadSeq() uint64 { - if x != nil { - return x.HeadSeq - } - return 0 -} - -func (x *Cursor) GetChainHash() []byte { - if x != nil { - return x.ChainHash - } - return nil -} - -func (x *Cursor) GetIssuedAt() *timestamppb.Timestamp { - if x != nil { - return x.IssuedAt - } - return nil -} - -func (x *Cursor) GetSignature() []byte { - if x != nil { - return x.Signature - } - return nil -} - -// Key is the public half of the witness signing key, and the same schema as the -// trust-anchor document composition delivers to the host. Publishing it is -// discovery, never authority to replace a caller's pinned key. -type Key struct { - state protoimpl.MessageState `protogen:"open.v1"` - // algorithm is "Ed25519". - Algorithm string `protobuf:"bytes,1,opt,name=algorithm,proto3" json:"algorithm,omitempty"` - // key_id is the lowercase SHA-256 hex of the raw public key. - KeyId string `protobuf:"bytes,2,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` - // public_key is the raw 32-byte public key. - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Key) Reset() { - *x = Key{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Key) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Key) ProtoMessage() {} - -func (x *Key) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Key.ProtoReflect.Descriptor instead. -func (*Key) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{4} -} - -func (x *Key) GetAlgorithm() string { - if x != nil { - return x.Algorithm - } - return "" -} - -func (x *Key) GetKeyId() string { - if x != nil { - return x.KeyId - } - return "" -} - -func (x *Key) GetPublicKey() []byte { - if x != nil { - return x.PublicKey - } - return nil -} - -// AppendRequest offers one entry. -type AppendRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AppendRequest) Reset() { - *x = AppendRequest{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AppendRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AppendRequest) ProtoMessage() {} - -func (x *AppendRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AppendRequest.ProtoReflect.Descriptor instead. -func (*AppendRequest) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{5} -} - -func (x *AppendRequest) GetEntry() *Entry { - if x != nil { - return x.Entry - } - return nil -} - -// AppendResponse carries the committed receipt. A retry of the identical entry -// returns the exact original receipt rather than appending again. -type AppendResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Receipt *Receipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AppendResponse) Reset() { - *x = AppendResponse{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AppendResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AppendResponse) ProtoMessage() {} - -func (x *AppendResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AppendResponse.ProtoReflect.Descriptor instead. -func (*AppendResponse) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{6} -} - -func (x *AppendResponse) GetReceipt() *Receipt { - if x != nil { - return x.Receipt - } - return nil -} - -// CursorRequest takes no argument: there is one log and one head. -type CursorRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CursorRequest) Reset() { - *x = CursorRequest{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CursorRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CursorRequest) ProtoMessage() {} - -func (x *CursorRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CursorRequest.ProtoReflect.Descriptor instead. -func (*CursorRequest) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{7} -} - -// CursorResponse carries the signed head observation. -type CursorResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Cursor *Cursor `protobuf:"bytes,1,opt,name=cursor,proto3" json:"cursor,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CursorResponse) Reset() { - *x = CursorResponse{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CursorResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CursorResponse) ProtoMessage() {} - -func (x *CursorResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CursorResponse.ProtoReflect.Descriptor instead. -func (*CursorResponse) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{8} -} - -func (x *CursorResponse) GetCursor() *Cursor { - if x != nil { - return x.Cursor - } - return nil -} - -// ReadRequest asks for a bounded page of committed records. -type ReadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an - // empty page; asking beyond it is refused, so a reconciler that is ahead of - // the log cannot mistake a rollback for inactivity. A caller holding an - // exclusive cursor sends cursor + 1. - FromSeq uint64 `protobuf:"varint,1,opt,name=from_seq,json=fromSeq,proto3" json:"from_seq,omitempty"` - // limit is 1 through 500. - Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReadRequest) Reset() { - *x = ReadRequest{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReadRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReadRequest) ProtoMessage() {} - -func (x *ReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. -func (*ReadRequest) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{9} -} - -func (x *ReadRequest) GetFromSeq() uint64 { - if x != nil { - return x.FromSeq - } - return 0 -} - -func (x *ReadRequest) GetLimit() uint32 { - if x != nil { - return x.Limit - } - return 0 -} - -// ReadResponse carries the page in ascending sequence order. -type ReadResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReadResponse) Reset() { - *x = ReadResponse{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReadResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReadResponse) ProtoMessage() {} - -func (x *ReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. -func (*ReadResponse) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{10} -} - -func (x *ReadResponse) GetRecords() []*Record { - if x != nil { - return x.Records - } - return nil -} - -// KeysRequest takes no argument. -type KeysRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *KeysRequest) Reset() { - *x = KeysRequest{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *KeysRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*KeysRequest) ProtoMessage() {} - -func (x *KeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use KeysRequest.ProtoReflect.Descriptor instead. -func (*KeysRequest) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{11} -} - -// KeysResponse carries the public signing key document. -type KeysResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Key *Key `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *KeysResponse) Reset() { - *x = KeysResponse{} - mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *KeysResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*KeysResponse) ProtoMessage() {} - -func (x *KeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. -func (*KeysResponse) Descriptor() ([]byte, []int) { - return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{12} -} - -func (x *KeysResponse) GetKey() *Key { - if x != nil { - return x.Key - } - return nil -} - -var File_saas_policylog_v1_witness_proto protoreflect.FileDescriptor - -const file_saas_policylog_v1_witness_proto_rawDesc = "" + - "\n" + - "\x1fsaas/policylog/v1/witness.proto\x12\x11saas.policylog.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd6\x01\n" + - "\x05Entry\x12!\n" + - "\foperation_id\x18\x01 \x01(\tR\voperationId\x12\x12\n" + - "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + - "\asubject\x18\x03 \x01(\tR\asubject\x12\x16\n" + - "\x06tenant\x18\x04 \x01(\tR\x06tenant\x12%\n" + - "\x0enarrowing_hash\x18\x05 \x01(\fR\rnarrowingHash\x12=\n" + - "\frequested_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vrequestedAt\"\xcd\x01\n" + - "\aReceipt\x12\x10\n" + - "\x03seq\x18\x01 \x01(\x04R\x03seq\x12\x1d\n" + - "\n" + - "entry_hash\x18\x02 \x01(\fR\tentryHash\x12\x1b\n" + - "\tprev_hash\x18\x03 \x01(\fR\bprevHash\x12\x1d\n" + - "\n" + - "chain_hash\x18\x04 \x01(\fR\tchainHash\x127\n" + - "\tissued_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + - "\tsignature\x18\x06 \x01(\fR\tsignature\"n\n" + - "\x06Record\x12.\n" + - "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\x124\n" + - "\areceipt\x18\x02 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x99\x01\n" + - "\x06Cursor\x12\x19\n" + - "\bhead_seq\x18\x01 \x01(\x04R\aheadSeq\x12\x1d\n" + - "\n" + - "chain_hash\x18\x02 \x01(\fR\tchainHash\x127\n" + - "\tissued_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + - "\tsignature\x18\x04 \x01(\fR\tsignature\"Y\n" + - "\x03Key\x12\x1c\n" + - "\talgorithm\x18\x01 \x01(\tR\talgorithm\x12\x15\n" + - "\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x1d\n" + - "\n" + - "public_key\x18\x03 \x01(\fR\tpublicKey\"?\n" + - "\rAppendRequest\x12.\n" + - "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\"F\n" + - "\x0eAppendResponse\x124\n" + - "\areceipt\x18\x01 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x0f\n" + - "\rCursorRequest\"C\n" + - "\x0eCursorResponse\x121\n" + - "\x06cursor\x18\x01 \x01(\v2\x19.saas.policylog.v1.CursorR\x06cursor\">\n" + - "\vReadRequest\x12\x19\n" + - "\bfrom_seq\x18\x01 \x01(\x04R\afromSeq\x12\x14\n" + - "\x05limit\x18\x02 \x01(\rR\x05limit\"C\n" + - "\fReadResponse\x123\n" + - "\arecords\x18\x01 \x03(\v2\x19.saas.policylog.v1.RecordR\arecords\"\r\n" + - "\vKeysRequest\"8\n" + - "\fKeysResponse\x12(\n" + - "\x03key\x18\x01 \x01(\v2\x16.saas.policylog.v1.KeyR\x03key2\xc0\x02\n" + - "\x0eWitnessService\x12M\n" + - "\x06Append\x12 .saas.policylog.v1.AppendRequest\x1a!.saas.policylog.v1.AppendResponse\x12M\n" + - "\x06Cursor\x12 .saas.policylog.v1.CursorRequest\x1a!.saas.policylog.v1.CursorResponse\x12G\n" + - "\x04Read\x12\x1e.saas.policylog.v1.ReadRequest\x1a\x1f.saas.policylog.v1.ReadResponse\x12G\n" + - "\x04Keys\x12\x1e.saas.policylog.v1.KeysRequest\x1a\x1f.saas.policylog.v1.KeysResponseB\xbd\x01\n" + - "\x15com.saas.policylog.v1B\fWitnessProtoP\x01Z0policy-log/pkg/gen/saas/policylog/v1;policylogv1\xa2\x02\x03SPX\xaa\x02\x11Saas.Policylog.V1\xca\x02\x11Saas\\Policylog\\V1\xe2\x02\x1dSaas\\Policylog\\V1\\GPBMetadata\xea\x02\x13Saas::Policylog::V1b\x06proto3" - -var ( - file_saas_policylog_v1_witness_proto_rawDescOnce sync.Once - file_saas_policylog_v1_witness_proto_rawDescData []byte -) - -func file_saas_policylog_v1_witness_proto_rawDescGZIP() []byte { - file_saas_policylog_v1_witness_proto_rawDescOnce.Do(func() { - file_saas_policylog_v1_witness_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc))) - }) - return file_saas_policylog_v1_witness_proto_rawDescData -} - -var file_saas_policylog_v1_witness_proto_msgTypes = make([]protoimpl.MessageInfo, 13) -var file_saas_policylog_v1_witness_proto_goTypes = []any{ - (*Entry)(nil), // 0: saas.policylog.v1.Entry - (*Receipt)(nil), // 1: saas.policylog.v1.Receipt - (*Record)(nil), // 2: saas.policylog.v1.Record - (*Cursor)(nil), // 3: saas.policylog.v1.Cursor - (*Key)(nil), // 4: saas.policylog.v1.Key - (*AppendRequest)(nil), // 5: saas.policylog.v1.AppendRequest - (*AppendResponse)(nil), // 6: saas.policylog.v1.AppendResponse - (*CursorRequest)(nil), // 7: saas.policylog.v1.CursorRequest - (*CursorResponse)(nil), // 8: saas.policylog.v1.CursorResponse - (*ReadRequest)(nil), // 9: saas.policylog.v1.ReadRequest - (*ReadResponse)(nil), // 10: saas.policylog.v1.ReadResponse - (*KeysRequest)(nil), // 11: saas.policylog.v1.KeysRequest - (*KeysResponse)(nil), // 12: saas.policylog.v1.KeysResponse - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp -} -var file_saas_policylog_v1_witness_proto_depIdxs = []int32{ - 13, // 0: saas.policylog.v1.Entry.requested_at:type_name -> google.protobuf.Timestamp - 13, // 1: saas.policylog.v1.Receipt.issued_at:type_name -> google.protobuf.Timestamp - 0, // 2: saas.policylog.v1.Record.entry:type_name -> saas.policylog.v1.Entry - 1, // 3: saas.policylog.v1.Record.receipt:type_name -> saas.policylog.v1.Receipt - 13, // 4: saas.policylog.v1.Cursor.issued_at:type_name -> google.protobuf.Timestamp - 0, // 5: saas.policylog.v1.AppendRequest.entry:type_name -> saas.policylog.v1.Entry - 1, // 6: saas.policylog.v1.AppendResponse.receipt:type_name -> saas.policylog.v1.Receipt - 3, // 7: saas.policylog.v1.CursorResponse.cursor:type_name -> saas.policylog.v1.Cursor - 2, // 8: saas.policylog.v1.ReadResponse.records:type_name -> saas.policylog.v1.Record - 4, // 9: saas.policylog.v1.KeysResponse.key:type_name -> saas.policylog.v1.Key - 5, // 10: saas.policylog.v1.WitnessService.Append:input_type -> saas.policylog.v1.AppendRequest - 7, // 11: saas.policylog.v1.WitnessService.Cursor:input_type -> saas.policylog.v1.CursorRequest - 9, // 12: saas.policylog.v1.WitnessService.Read:input_type -> saas.policylog.v1.ReadRequest - 11, // 13: saas.policylog.v1.WitnessService.Keys:input_type -> saas.policylog.v1.KeysRequest - 6, // 14: saas.policylog.v1.WitnessService.Append:output_type -> saas.policylog.v1.AppendResponse - 8, // 15: saas.policylog.v1.WitnessService.Cursor:output_type -> saas.policylog.v1.CursorResponse - 10, // 16: saas.policylog.v1.WitnessService.Read:output_type -> saas.policylog.v1.ReadResponse - 12, // 17: saas.policylog.v1.WitnessService.Keys:output_type -> saas.policylog.v1.KeysResponse - 14, // [14:18] is the sub-list for method output_type - 10, // [10:14] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name -} - -func init() { file_saas_policylog_v1_witness_proto_init() } -func file_saas_policylog_v1_witness_proto_init() { - if File_saas_policylog_v1_witness_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc)), - NumEnums: 0, - NumMessages: 13, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_saas_policylog_v1_witness_proto_goTypes, - DependencyIndexes: file_saas_policylog_v1_witness_proto_depIdxs, - MessageInfos: file_saas_policylog_v1_witness_proto_msgTypes, - }.Build() - File_saas_policylog_v1_witness_proto = out.File - file_saas_policylog_v1_witness_proto_goTypes = nil - file_saas_policylog_v1_witness_proto_depIdxs = nil -} diff --git a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go deleted file mode 100644 index e0d957df2..000000000 --- a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go +++ /dev/null @@ -1,266 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc (unknown) -// source: saas/policylog/v1/witness.proto - -package policylogv1 - -import ( - context "context" - - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - WitnessService_Append_FullMethodName = "/saas.policylog.v1.WitnessService/Append" - WitnessService_Cursor_FullMethodName = "/saas.policylog.v1.WitnessService/Cursor" - WitnessService_Read_FullMethodName = "/saas.policylog.v1.WitnessService/Read" - WitnessService_Keys_FullMethodName = "/saas.policylog.v1.WitnessService/Keys" -) - -// WitnessServiceClient is the client API for WitnessService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// WitnessService is the independent append-only witness for authority changes. -// -// Its protocol is append -> signed receipt -> the CALLER commits with the -// receipt in the same transaction. This listener is PRIVATE to the module: it is -// never published through the public gateway and never offered to a composed -// module. Composition admits only the host's declared client, through -// workload-authenticated mesh policy. -type WitnessServiceClient interface { - // Append commits one entry and returns its receipt, within a five-second - // budget that includes queueing. A refusal carries no receipt and never - // mints a new sequence for a retried operation. - Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) - // Cursor returns the signed committed head. - Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) - // Read returns committed records with their original receipts. - Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) - // Keys returns the public signing key. It never returns private material and - // does not authorize trust-anchor replacement. - Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) -} - -type witnessServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewWitnessServiceClient(cc grpc.ClientConnInterface) WitnessServiceClient { - return &witnessServiceClient{cc} -} - -func (c *witnessServiceClient) Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AppendResponse) - err := c.cc.Invoke(ctx, WitnessService_Append_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *witnessServiceClient) Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CursorResponse) - err := c.cc.Invoke(ctx, WitnessService_Cursor_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *witnessServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ReadResponse) - err := c.cc.Invoke(ctx, WitnessService_Read_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *witnessServiceClient) Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(KeysResponse) - err := c.cc.Invoke(ctx, WitnessService_Keys_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// WitnessServiceServer is the server API for WitnessService service. -// All implementations must embed UnimplementedWitnessServiceServer -// for forward compatibility. -// -// WitnessService is the independent append-only witness for authority changes. -// -// Its protocol is append -> signed receipt -> the CALLER commits with the -// receipt in the same transaction. This listener is PRIVATE to the module: it is -// never published through the public gateway and never offered to a composed -// module. Composition admits only the host's declared client, through -// workload-authenticated mesh policy. -type WitnessServiceServer interface { - // Append commits one entry and returns its receipt, within a five-second - // budget that includes queueing. A refusal carries no receipt and never - // mints a new sequence for a retried operation. - Append(context.Context, *AppendRequest) (*AppendResponse, error) - // Cursor returns the signed committed head. - Cursor(context.Context, *CursorRequest) (*CursorResponse, error) - // Read returns committed records with their original receipts. - Read(context.Context, *ReadRequest) (*ReadResponse, error) - // Keys returns the public signing key. It never returns private material and - // does not authorize trust-anchor replacement. - Keys(context.Context, *KeysRequest) (*KeysResponse, error) - mustEmbedUnimplementedWitnessServiceServer() -} - -// UnimplementedWitnessServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedWitnessServiceServer struct{} - -func (UnimplementedWitnessServiceServer) Append(context.Context, *AppendRequest) (*AppendResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Append not implemented") -} -func (UnimplementedWitnessServiceServer) Cursor(context.Context, *CursorRequest) (*CursorResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Cursor not implemented") -} -func (UnimplementedWitnessServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Read not implemented") -} -func (UnimplementedWitnessServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Keys not implemented") -} -func (UnimplementedWitnessServiceServer) mustEmbedUnimplementedWitnessServiceServer() {} -func (UnimplementedWitnessServiceServer) testEmbeddedByValue() {} - -// UnsafeWitnessServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to WitnessServiceServer will -// result in compilation errors. -type UnsafeWitnessServiceServer interface { - mustEmbedUnimplementedWitnessServiceServer() -} - -func RegisterWitnessServiceServer(s grpc.ServiceRegistrar, srv WitnessServiceServer) { - // If the following call panics, it indicates UnimplementedWitnessServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&WitnessService_ServiceDesc, srv) -} - -func _WitnessService_Append_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AppendRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WitnessServiceServer).Append(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WitnessService_Append_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WitnessServiceServer).Append(ctx, req.(*AppendRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _WitnessService_Cursor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CursorRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WitnessServiceServer).Cursor(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WitnessService_Cursor_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WitnessServiceServer).Cursor(ctx, req.(*CursorRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _WitnessService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReadRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WitnessServiceServer).Read(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WitnessService_Read_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WitnessServiceServer).Read(ctx, req.(*ReadRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _WitnessService_Keys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(KeysRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(WitnessServiceServer).Keys(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: WitnessService_Keys_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(WitnessServiceServer).Keys(ctx, req.(*KeysRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// WitnessService_ServiceDesc is the grpc.ServiceDesc for WitnessService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var WitnessService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "saas.policylog.v1.WitnessService", - HandlerType: (*WitnessServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Append", - Handler: _WitnessService_Append_Handler, - }, - { - MethodName: "Cursor", - Handler: _WitnessService_Cursor_Handler, - }, - { - MethodName: "Read", - Handler: _WitnessService_Read_Handler, - }, - { - MethodName: "Keys", - Handler: _WitnessService_Keys_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "saas/policylog/v1/witness.proto", -} diff --git a/module/services/policy-log/code/server/server.go b/module/services/policy-log/code/server/server.go deleted file mode 100644 index fe4e2d0bc..000000000 --- a/module/services/policy-log/code/server/server.go +++ /dev/null @@ -1,206 +0,0 @@ -// Package server is the gRPC projection of the witness. It owns the wire shape -// and the translation of the witness error taxonomy, and no policy of its own: -// validation, sequencing, signing and the five-second budget all stay behind -// the transport-independent service boundary. -package server - -import ( - "context" - "errors" - "fmt" - - policylogv1 "policy-log/pkg/gen/saas/policylog/v1" - "policy-log/service" - "policy-log/witness" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// Witness is the boundary this server projects. It is an interface so that each -// arm of the error taxonomy can be exercised exactly, rather than approximated -// by corrupting a store; *service.Service is the only implementation that ships. -type Witness interface { - Append(context.Context, witness.Entry) (*witness.Receipt, error) - Cursor(context.Context) (*witness.Cursor, error) - Read(context.Context, uint64, uint32) ([]witness.Record, error) - Keys() witness.Key -} - -// The production implementation must keep satisfying the boundary. -var _ Witness = (*service.Service)(nil) - -type Server struct { - policylogv1.UnimplementedWitnessServiceServer - witness Witness - failed func(error) -} - -// New requires a failure reporter because an error outside the taxonomy is -// deliberately not echoed to the caller: a store error can carry bucket -// identity and endpoint detail, and the caller's correct behaviour does not -// depend on it. The detail goes to this service's own log instead. -func New(w Witness, failed func(error)) (*Server, error) { - if w == nil || failed == nil { - return nil, errors.New("policy-log: witness boundary and failure reporter are required") - } - return &Server{witness: w, failed: failed}, nil -} - -// unreachable is the only thing a caller learns about an error the taxonomy does -// not name. It says what the caller needs: no sequence was assigned and no -// receipt exists, so the identical operation may be retried. -const unreachable = "policy log could not reach its authority store; no sequence was assigned and no receipt was issued" - -// statusFor maps the witness taxonomy onto gRPC codes. The five taxonomy errors -// are NOT interchangeable and must never collapse into one code: -// -// - ErrInvalid -> InvalidArgument. The request is malformed. Retrying -// the same bytes can never succeed. -// - ErrConflict -> AlreadyExists. The operation id already names a -// DIFFERENT entry. Permanent: the caller -// must not retry, and must not rename the -// operation to get past it. -// - ErrPending -> FailedPrecondition. Another operation holds the next -// sequence uncommitted. Only an explicit -// retry of THAT operation can clear it, so -// this caller must not retry until the log's -// state is repaired. Distinct from -// ErrConflict because this one does clear. -// - ErrRollback -> Aborted. The head moved backwards, changed a -// known hash, or the caller is ahead of the -// log. A sequencer check failed; the caller -// must stop serving the authority it holds, -// not retry. -// - ErrIntegrity -> DataLoss. The log's own stored evidence does -// not verify. Unrecoverable corruption, -// distinct from a regression. -// -// Cancellation and the deadline keep their own codes, because both leave the -// outcome genuinely ambiguous: a storage request already in flight can have -// committed. The caller reconciles by retrying the identical operation, whose -// receipt the head will return if it did. -func (s *Server) statusFor(err error) error { - switch { - case err == nil: - return nil - case errors.Is(err, context.Canceled): - return status.Error(codes.Canceled, err.Error()) - case errors.Is(err, context.DeadlineExceeded): - return status.Error(codes.DeadlineExceeded, err.Error()) - case errors.Is(err, witness.ErrInvalid): - return status.Error(codes.InvalidArgument, err.Error()) - case errors.Is(err, witness.ErrConflict): - return status.Error(codes.AlreadyExists, err.Error()) - case errors.Is(err, witness.ErrPending): - return status.Error(codes.FailedPrecondition, err.Error()) - case errors.Is(err, witness.ErrRollback): - return status.Error(codes.Aborted, err.Error()) - case errors.Is(err, witness.ErrIntegrity): - return status.Error(codes.DataLoss, err.Error()) - default: - s.failed(err) - return status.Error(codes.Unavailable, unreachable) - } -} - -// entryFrom carries the wire entry into the contract without inventing any part -// of it. -// -// A missing or out-of-range requested_at is refused here rather than passed on: -// AsTime of an absent Timestamp is 1970-01-01, which Entry.Validate accepts, so -// the log would sign an entry hash over a timestamp the caller never sent — and -// no retry of that operation could ever reproduce it. -func entryFrom(wire *policylogv1.Entry) (witness.Entry, error) { - if wire == nil { - return witness.Entry{}, fmt.Errorf("%w: entry is absent", witness.ErrInvalid) - } - if err := wire.GetRequestedAt().CheckValid(); err != nil { - return witness.Entry{}, fmt.Errorf("%w: requested_at is required and must be a valid timestamp", witness.ErrInvalid) - } - return witness.Entry{ - OperationID: wire.GetOperationId(), - Kind: wire.GetKind(), - Subject: wire.GetSubject(), - Tenant: wire.GetTenant(), - NarrowingHash: wire.GetNarrowingHash(), - RequestedAt: wire.GetRequestedAt().AsTime(), - }, nil -} - -// entryTo and receiptTo carry raw digests as bytes and the timestamps as -// seconds and nanoseconds, which is exactly what the canonical encodings hash. -// A reader can therefore recompute entry_hash from the wire entry it received. -func entryTo(entry witness.Entry) *policylogv1.Entry { - return &policylogv1.Entry{ - OperationId: entry.OperationID, - Kind: entry.Kind, - Subject: entry.Subject, - Tenant: entry.Tenant, - NarrowingHash: entry.NarrowingHash, - RequestedAt: timestamppb.New(entry.RequestedAt), - } -} - -func receiptTo(receipt witness.Receipt) *policylogv1.Receipt { - return &policylogv1.Receipt{ - Seq: receipt.Seq, - EntryHash: receipt.EntryHash, - PrevHash: receipt.PrevHash, - ChainHash: receipt.ChainHash, - IssuedAt: timestamppb.New(receipt.IssuedAt), - Signature: receipt.Signature, - } -} - -func (s *Server) Append(ctx context.Context, request *policylogv1.AppendRequest) (*policylogv1.AppendResponse, error) { - entry, err := entryFrom(request.GetEntry()) - if err != nil { - return nil, s.statusFor(err) - } - receipt, err := s.witness.Append(ctx, entry) - if err != nil { - return nil, s.statusFor(err) - } - return &policylogv1.AppendResponse{Receipt: receiptTo(*receipt)}, nil -} - -func (s *Server) Cursor(ctx context.Context, _ *policylogv1.CursorRequest) (*policylogv1.CursorResponse, error) { - cursor, err := s.witness.Cursor(ctx) - if err != nil { - return nil, s.statusFor(err) - } - return &policylogv1.CursorResponse{Cursor: &policylogv1.Cursor{ - HeadSeq: cursor.HeadSeq, - ChainHash: cursor.ChainHash, - IssuedAt: timestamppb.New(cursor.IssuedAt), - Signature: cursor.Signature, - }}, nil -} - -func (s *Server) Read(ctx context.Context, request *policylogv1.ReadRequest) (*policylogv1.ReadResponse, error) { - records, err := s.witness.Read(ctx, request.GetFromSeq(), request.GetLimit()) - if err != nil { - return nil, s.statusFor(err) - } - // An empty page is a page: reading at head+1 returns one, and a nil slice - // here must not read as a different answer from an explicitly empty list. - response := &policylogv1.ReadResponse{Records: make([]*policylogv1.Record, 0, len(records))} - for _, record := range records { - response.Records = append(response.Records, &policylogv1.Record{ - Entry: entryTo(record.Entry), - Receipt: receiptTo(record.Receipt), - }) - } - return response, nil -} - -func (s *Server) Keys(_ context.Context, _ *policylogv1.KeysRequest) (*policylogv1.KeysResponse, error) { - key := s.witness.Keys() - return &policylogv1.KeysResponse{Key: &policylogv1.Key{ - Algorithm: key.Algorithm, - KeyId: key.KeyID, - PublicKey: key.PublicKey, - }}, nil -} diff --git a/module/services/policy-log/code/server/server_test.go b/module/services/policy-log/code/server/server_test.go deleted file mode 100644 index e55b79b65..000000000 --- a/module/services/policy-log/code/server/server_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package server - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "errors" - "fmt" - "testing" - "time" - - policylogv1 "policy-log/pkg/gen/saas/policylog/v1" - "policy-log/witness" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// stub answers each operation with whatever one case needs. The error taxonomy -// is the thing under test, so it must be injectable directly rather than -// provoked by corrupting a store, which can only produce some of the arms. -type stub struct { - err error - receipt *witness.Receipt - cursor *witness.Cursor - records []witness.Record - key witness.Key - seen witness.Entry - calls int -} - -func (s *stub) Append(_ context.Context, entry witness.Entry) (*witness.Receipt, error) { - s.calls++ - s.seen = entry - if s.err != nil { - return nil, s.err - } - return s.receipt, nil -} - -func (s *stub) Cursor(context.Context) (*witness.Cursor, error) { - if s.err != nil { - return nil, s.err - } - return s.cursor, nil -} - -func (s *stub) Read(_ context.Context, _ uint64, _ uint32) ([]witness.Record, error) { - if s.err != nil { - return nil, s.err - } - return s.records, nil -} - -func (s *stub) Keys() witness.Key { return s.key } - -func newServer(t *testing.T, w Witness) (*Server, *[]error) { - t.Helper() - reported := new([]error) - s, err := New(w, func(err error) { *reported = append(*reported, err) }) - if err != nil { - t.Fatalf("New: %v", err) - } - return s, reported -} - -func digest(b byte) []byte { - h := make([]byte, sha256.Size) - for i := range h { - h[i] = b - } - return h -} - -func wireEntry() *policylogv1.Entry { - return &policylogv1.Entry{ - OperationId: "operation-1", - Kind: "binding", - Subject: "subject-1", - Tenant: "tenant-1", - NarrowingHash: digest(0x11), - RequestedAt: timestamppb.New(time.Unix(1700000000, 123456789).UTC()), - } -} - -// TestAppendMapsEachTaxonomyErrorToItsOwnCode is the point of the mapping: a -// retrying caller reads the code, and ErrConflict (permanent) and ErrPending -// (clears when the original operation is retried) must never arrive as the -// same answer. Collapsing any pair here makes a retryable state look permanent -// or the reverse. -func TestAppendMapsEachTaxonomyErrorToItsOwnCode(t *testing.T) { - cases := []struct { - name string - // arm is the condition this case stands for. Two cases sharing a code - // are only acceptable when they stand for the same arm, which is how a - // wrapped sentinel differs from a second, distinct condition. - arm error - err error - want codes.Code - }{ - {"invalid", witness.ErrInvalid, witness.ErrInvalid, codes.InvalidArgument}, - {"conflict", witness.ErrConflict, witness.ErrConflict, codes.AlreadyExists}, - {"pending", witness.ErrPending, witness.ErrPending, codes.FailedPrecondition}, - {"rollback", witness.ErrRollback, witness.ErrRollback, codes.Aborted}, - {"integrity", witness.ErrIntegrity, witness.ErrIntegrity, codes.DataLoss}, - {"canceled", context.Canceled, context.Canceled, codes.Canceled}, - {"deadline", context.DeadlineExceeded, context.DeadlineExceeded, codes.DeadlineExceeded}, - // Wrapped, which is how the witness actually returns most of them. - {"wrapped invalid", witness.ErrInvalid, fmt.Errorf("%w: identifier", witness.ErrInvalid), codes.InvalidArgument}, - {"wrapped integrity", witness.ErrIntegrity, fmt.Errorf("%w: sequence 7", witness.ErrIntegrity), codes.DataLoss}, - {"wrapped rollback", witness.ErrRollback, fmt.Errorf("read committed sequence 3: %w", witness.ErrRollback), codes.Aborted}, - } - seen := make(map[codes.Code]error) - for _, tc := range cases { - s, reported := newServer(t, &stub{err: tc.err}) - _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) - if got := status.Code(err); got != tc.want { - t.Errorf("%s: Append returned code %v, want %v (err %v)", tc.name, got, tc.want, err) - } - if len(*reported) != 0 { - t.Errorf("%s: a taxonomy error must not be reported as an unreachable store: %v", tc.name, *reported) - } - if previous, ok := seen[tc.want]; ok && previous != tc.arm { - t.Errorf("%q and %q are different conditions that both map to %v; the taxonomy arms must stay distinguishable", previous, tc.arm, tc.want) - } - seen[tc.want] = tc.arm - } - // Every arm of the taxonomy must be covered, so adding a sentinel to - // witness/contract.go without mapping it fails here instead of silently - // arriving as Unavailable. - for _, sentinel := range []error{witness.ErrInvalid, witness.ErrConflict, witness.ErrPending, witness.ErrRollback, witness.ErrIntegrity} { - covered := false - for _, tc := range cases { - if tc.arm == sentinel { - covered = true - } - } - if !covered { - t.Errorf("witness taxonomy error %v has no mapping case", sentinel) - } - } -} - -// TestAppendWithholdsStoreDetailAndReportsIt covers the one error class that is -// NOT in the taxonomy: a store failure. The caller learns only that no sequence -// was assigned, and the detail — which can carry bucket identity — goes to the -// service's own reporter. -func TestAppendWithholdsStoreDetailAndReportsIt(t *testing.T) { - underlying := errors.New("dial gs://example-witness-bucket: connection refused") - s, reported := newServer(t, &stub{err: underlying}) - _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) - if got := status.Code(err); got != codes.Unavailable { - t.Fatalf("Append returned code %v, want %v", got, codes.Unavailable) - } - if message := status.Convert(err).Message(); message != unreachable { - t.Errorf("Append message = %q, want the fixed unreachable message", message) - } - if len(*reported) != 1 || !errors.Is((*reported)[0], underlying) { - t.Fatalf("the underlying error must reach the reporter exactly once, got %v", *reported) - } -} - -// TestAppendRefusesAnAbsentRequestedAtInsteadOfSigning1970 is the trap the -// projection has to close. AsTime of an absent Timestamp is 1970-01-01, which -// Entry.Validate accepts, so without this check the witness would sign an entry -// hash over a timestamp the caller never sent — and no retry of that operation -// could reproduce it. -func TestAppendRefusesAnAbsentRequestedAtInsteadOfSigning1970(t *testing.T) { - for _, tc := range []struct { - name string - entry *policylogv1.Entry - }{ - {"absent requested_at", &policylogv1.Entry{ - OperationId: "operation-1", Kind: "binding", Subject: "subject-1", - NarrowingHash: digest(0x11), - }}, - {"out of range requested_at", &policylogv1.Entry{ - OperationId: "operation-1", Kind: "binding", Subject: "subject-1", - NarrowingHash: digest(0x11), - RequestedAt: ×tamppb.Timestamp{Seconds: 1, Nanos: -5}, - }}, - {"absent entry", nil}, - } { - backend := &stub{receipt: &witness.Receipt{Seq: 1}} - s, _ := newServer(t, backend) - _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: tc.entry}) - if got := status.Code(err); got != codes.InvalidArgument { - t.Errorf("%s: Append returned code %v, want %v", tc.name, got, codes.InvalidArgument) - } - if backend.calls != 0 { - t.Errorf("%s: the witness must not be reached at all, got %d calls", tc.name, backend.calls) - } - } -} - -// TestAppendCarriesTheEntryLosslessly is the contract the proto exists to keep: -// the entry hash computed from what the server handed the witness must equal the -// hash of the entry the caller described on the wire, including the exact -// nanosecond of requested_at and the raw 32 digest bytes. If it did not, a retry -// of the same operation would produce a different hash and be refused. -func TestAppendCarriesTheEntryLosslessly(t *testing.T) { - wire := wireEntry() - backend := &stub{receipt: &witness.Receipt{Seq: 1}} - s, _ := newServer(t, backend) - if _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wire}); err != nil { - t.Fatalf("Append: %v", err) - } - want := witness.Entry{ - OperationID: wire.GetOperationId(), Kind: wire.GetKind(), Subject: wire.GetSubject(), - Tenant: wire.GetTenant(), NarrowingHash: wire.GetNarrowingHash(), - RequestedAt: time.Unix(1700000000, 123456789).UTC(), - } - wantHash, err := witness.EntryHash(want) - if err != nil { - t.Fatalf("EntryHash: %v", err) - } - gotHash, err := witness.EntryHash(backend.seen) - if err != nil { - t.Fatalf("EntryHash of the carried entry: %v", err) - } - if string(gotHash) != string(wantHash) { - t.Fatalf("the carried entry hashes differently from the wire entry:\n got %x\nwant %x\ncarried %+v", gotHash, wantHash, backend.seen) - } - // The round trip back out must reproduce the same bytes, so a reader can - // recompute the hash from a Read response. - round, err := entryFrom(entryTo(backend.seen)) - if err != nil { - t.Fatalf("entryFrom(entryTo(...)): %v", err) - } - roundHash, err := witness.EntryHash(round) - if err != nil { - t.Fatalf("EntryHash of the round trip: %v", err) - } - if string(roundHash) != string(wantHash) { - t.Fatalf("round trip hashes differently:\n got %x\nwant %x", roundHash, wantHash) - } -} - -// TestAppendProjectsTheWholeReceipt proves no field of the receipt is dropped: -// a caller that cannot see prev_hash or the signature cannot verify the chain, -// and a receipt it cannot verify is not evidence. -func TestAppendProjectsTheWholeReceipt(t *testing.T) { - issued := time.Unix(1700000001, 42).UTC() - receipt := &witness.Receipt{ - Seq: 9, EntryHash: digest(0x01), PrevHash: digest(0x02), ChainHash: digest(0x03), - IssuedAt: issued, Signature: make([]byte, ed25519.SignatureSize), - } - s, _ := newServer(t, &stub{receipt: receipt}) - response, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) - if err != nil { - t.Fatalf("Append: %v", err) - } - got := response.GetReceipt() - if got.GetSeq() != 9 || - string(got.GetEntryHash()) != string(digest(0x01)) || - string(got.GetPrevHash()) != string(digest(0x02)) || - string(got.GetChainHash()) != string(digest(0x03)) || - len(got.GetSignature()) != ed25519.SignatureSize || - !got.GetIssuedAt().AsTime().Equal(issued) { - t.Fatalf("receipt projection lost a field: %+v", got) - } -} - -// TestCursorAndKeysProjectTheSignedObservation keeps the two discovery -// operations honest: an unsigned cursor or a key document without its id is not -// usable by a verifying client. -func TestCursorAndKeysProjectTheSignedObservation(t *testing.T) { - issued := time.Unix(1700000002, 7).UTC() - backend := &stub{ - cursor: &witness.Cursor{HeadSeq: 4, ChainHash: digest(0x05), IssuedAt: issued, Signature: make([]byte, ed25519.SignatureSize)}, - key: witness.Key{Algorithm: "Ed25519", KeyID: "abc123", PublicKey: make([]byte, ed25519.PublicKeySize)}, - } - s, _ := newServer(t, backend) - cursor, err := s.Cursor(context.Background(), &policylogv1.CursorRequest{}) - if err != nil { - t.Fatalf("Cursor: %v", err) - } - if cursor.GetCursor().GetHeadSeq() != 4 || - string(cursor.GetCursor().GetChainHash()) != string(digest(0x05)) || - len(cursor.GetCursor().GetSignature()) != ed25519.SignatureSize || - !cursor.GetCursor().GetIssuedAt().AsTime().Equal(issued) { - t.Fatalf("cursor projection lost a field: %+v", cursor.GetCursor()) - } - keys, err := s.Keys(context.Background(), &policylogv1.KeysRequest{}) - if err != nil { - t.Fatalf("Keys: %v", err) - } - if keys.GetKey().GetAlgorithm() != "Ed25519" || keys.GetKey().GetKeyId() != "abc123" || - len(keys.GetKey().GetPublicKey()) != ed25519.PublicKeySize { - t.Fatalf("key projection lost a field: %+v", keys.GetKey()) - } -} - -// TestReadReturnsAnEmptyPageRatherThanNothing matters because reading at -// head_seq + 1 is a legitimate empty page, and a reconciler must be able to -// tell it from a refusal. It must also carry each record's ORIGINAL receipt. -func TestReadReturnsAnEmptyPageRatherThanNothing(t *testing.T) { - s, _ := newServer(t, &stub{records: nil}) - empty, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 1, Limit: 10}) - if err != nil { - t.Fatalf("Read: %v", err) - } - if empty.GetRecords() == nil || len(empty.GetRecords()) != 0 { - t.Fatalf("an empty page must be an empty list, got %v", empty.GetRecords()) - } - - requested := time.Unix(1700000003, 321).UTC() - backend := &stub{records: []witness.Record{{ - Entry: witness.Entry{OperationID: "op-2", Kind: "scope_grant", Subject: "s", Tenant: "", NarrowingHash: digest(0x07), RequestedAt: requested}, - Receipt: witness.Receipt{Seq: 2, EntryHash: digest(0x08), PrevHash: digest(0x09), ChainHash: digest(0x0a), IssuedAt: requested, Signature: make([]byte, ed25519.SignatureSize)}, - }}} - s, _ = newServer(t, backend) - page, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 2, Limit: 1}) - if err != nil { - t.Fatalf("Read: %v", err) - } - if len(page.GetRecords()) != 1 { - t.Fatalf("Read returned %d records, want 1", len(page.GetRecords())) - } - record := page.GetRecords()[0] - if record.GetEntry().GetOperationId() != "op-2" || record.GetReceipt().GetSeq() != 2 || - string(record.GetEntry().GetNarrowingHash()) != string(digest(0x07)) || - !record.GetEntry().GetRequestedAt().AsTime().Equal(requested) || - len(record.GetReceipt().GetSignature()) != ed25519.SignatureSize { - t.Fatalf("record projection lost a field: %+v", record) - } -} - -// TestReadPassesTheBoundsThroughUnchanged: the limit ceiling and the inclusive -// floor are the witness's to enforce, and a transport that clamped or defaulted -// them would turn a refusal into a silently different page. -func TestReadPassesTheBoundsThroughUnchanged(t *testing.T) { - var gotFrom uint64 - var gotLimit uint32 - s, _ := newServer(t, &boundsStub{from: &gotFrom, limit: &gotLimit}) - if _, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 0, Limit: witness.MaxReadLimit + 1}); err != nil { - t.Fatalf("Read: %v", err) - } - if gotFrom != 0 || gotLimit != witness.MaxReadLimit+1 { - t.Fatalf("Read passed from=%d limit=%d, want them unchanged (0, %d)", gotFrom, gotLimit, witness.MaxReadLimit+1) - } -} - -type boundsStub struct { - stub - from *uint64 - limit *uint32 -} - -func (b *boundsStub) Read(_ context.Context, from uint64, limit uint32) ([]witness.Record, error) { - *b.from, *b.limit = from, limit - return nil, nil -} - -// TestNewRefusesAnIncompleteConstruction keeps the server from being built -// without a reporter, which is what keeps a withheld store error observable. -func TestNewRefusesAnIncompleteConstruction(t *testing.T) { - if _, err := New(nil, func(error) {}); err == nil { - t.Error("New accepted a nil witness") - } - if _, err := New(&stub{}, nil); err == nil { - t.Error("New accepted a nil failure reporter") - } -} diff --git a/module/services/policy-log/code/server/transport_test.go b/module/services/policy-log/code/server/transport_test.go deleted file mode 100644 index 4bfd97017..000000000 --- a/module/services/policy-log/code/server/transport_test.go +++ /dev/null @@ -1,380 +0,0 @@ -package server_test - -// These exercise the projection over a REAL gRPC connection, a real -// service.Service, a real witness and the generated client — not a stub. The -// stubbed tests in the package next door prove the mapping of each taxonomy -// arm; these prove the arms actually arise from the shipped code and survive -// the wire, which is the part a hand-written adapter gets wrong. - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/json" - "net" - "testing" - "time" - - "policy-log/mirror" - "policy-log/objectstore" - "policy-log/objectstore/memory" - policylogv1 "policy-log/pkg/gen/saas/policylog/v1" - "policy-log/server" - "policy-log/service" - "policy-log/witness" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -type discard struct{} - -func (discard) Write(context.Context, witness.Record) error { return nil } - -func genesisStore(t *testing.T) *memory.Store { - t.Helper() - store := memory.New() - genesis, err := json.Marshal(struct { - Seq uint64 `json:"seq"` - ChainHash []byte `json:"chain_hash"` - }{ChainHash: make([]byte, sha256.Size)}) - if err != nil { - t.Fatal(err) - } - if _, err := store.Write(context.Background(), witness.HeadObject, genesis, 0); err != nil { - t.Fatal(err) - } - return store -} - -// serve stands up the whole shipped stack — witness, service, projection — on a -// loopback listener and returns the generated client for it. No fixed port is -// written here any more than one is written in the binary. -func serve(t *testing.T, objects objectstore.Store, key ed25519.PrivateKey) policylogv1.WitnessServiceClient { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - log, err := witness.Open(ctx, objects, key) - if err != nil { - t.Fatal(err) - } - worker, err := mirror.Start(ctx, discard{}, func(error) {}) - if err != nil { - t.Fatal(err) - } - witnessService, err := service.New(log, worker) - if err != nil { - t.Fatal(err) - } - handler, err := server.New(witnessService, func(error) {}) - if err != nil { - t.Fatal(err) - } - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - grpcServer := grpc.NewServer() - policylogv1.RegisterWitnessServiceServer(grpcServer, handler) - go func() { _ = grpcServer.Serve(listener) }() - t.Cleanup(grpcServer.Stop) - - connection, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = connection.Close() }) - return policylogv1.NewWitnessServiceClient(connection) -} - -func dial(t *testing.T) (policylogv1.WitnessServiceClient, ed25519.PublicKey) { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return serve(t, genesisStore(t), private), public -} - -// headFailure persists an entry object and then loses the head write, which is -// the interrupted append the ErrPending arm exists for. -type headFailure struct{ objectstore.Store } - -func (s headFailure) Write(ctx context.Context, name string, body []byte, generation int64) (int64, error) { - if name == witness.HeadObject { - return 0, objectstore.ErrPrecondition - } - return s.Store.Write(ctx, name, body, generation) -} - -func digest(b byte) []byte { - h := make([]byte, sha256.Size) - for i := range h { - h[i] = b - } - return h -} - -func entry(operation string, hash byte, requested time.Time) *policylogv1.Entry { - return &policylogv1.Entry{ - OperationId: operation, - Kind: "binding", - Subject: "subject-1", - Tenant: "tenant-1", - NarrowingHash: digest(hash), - RequestedAt: timestamppb.New(requested), - } -} - -func receiptFrom(wire *policylogv1.Receipt) witness.Receipt { - return witness.Receipt{ - Seq: wire.GetSeq(), EntryHash: wire.GetEntryHash(), PrevHash: wire.GetPrevHash(), - ChainHash: wire.GetChainHash(), IssuedAt: wire.GetIssuedAt().AsTime(), Signature: wire.GetSignature(), - } -} - -// TestAppendedReceiptVerifiesUnderThePublishedKey is the whole point of the -// transport: what comes back over the wire must verify under the key Keys -// publishes, and its entry hash must be the hash of the bytes the caller asked -// to append. A projection that lost a byte of any digest would pass a shape -// check and fail this. -func TestAppendedReceiptVerifiesUnderThePublishedKey(t *testing.T) { - client, public := dial(t) - ctx := context.Background() - requested := time.Unix(1700000000, 123456789).UTC() - - appended, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) - if err != nil { - t.Fatalf("Append: %v", err) - } - receipt := receiptFrom(appended.GetReceipt()) - if receipt.Seq != 1 { - t.Fatalf("first sequence = %d, want 1", receipt.Seq) - } - if !witness.VerifyReceipt(public, receipt) { - t.Fatal("the receipt that crossed the wire does not verify under the published key") - } - wantHash, err := witness.EntryHash(witness.Entry{ - OperationID: "operation-1", Kind: "binding", Subject: "subject-1", Tenant: "tenant-1", - NarrowingHash: digest(0x11), RequestedAt: requested, - }) - if err != nil { - t.Fatal(err) - } - if string(receipt.EntryHash) != string(wantHash) { - t.Fatalf("receipt entry hash %x does not match the appended bytes %x", receipt.EntryHash, wantHash) - } - - keys, err := client.Keys(ctx, &policylogv1.KeysRequest{}) - if err != nil { - t.Fatalf("Keys: %v", err) - } - if string(keys.GetKey().GetPublicKey()) != string(public) { - t.Fatal("Keys published a different public key than the one the log signs with") - } -} - -// TestRetryOfTheIdenticalEntryReturnsTheOriginalReceipt proves the idempotency -// the whole protocol rests on survives the projection — including requested_at, -// which is covered by the entry hash, so a transport that rounded it would turn -// every retry into a conflict. -func TestRetryOfTheIdenticalEntryReturnsTheOriginalReceipt(t *testing.T) { - client, _ := dial(t) - ctx := context.Background() - requested := time.Unix(1700000000, 987654321).UTC() - - first, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) - if err != nil { - t.Fatalf("first Append: %v", err) - } - second, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) - if err != nil { - t.Fatalf("retried Append: %v", err) - } - if first.GetReceipt().GetSeq() != second.GetReceipt().GetSeq() || - string(first.GetReceipt().GetSignature()) != string(second.GetReceipt().GetSignature()) { - t.Fatalf("a retry minted a new receipt: %v then %v", first.GetReceipt(), second.GetReceipt()) - } -} - -// TestReusedOperationIDWithDifferentBytesIsAlreadyExists: the conflict arm, -// raised by the shipped witness rather than injected. -func TestReusedOperationIDWithDifferentBytesIsAlreadyExists(t *testing.T) { - client, _ := dial(t) - ctx := context.Background() - requested := time.Unix(1700000000, 0).UTC() - if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}); err != nil { - t.Fatalf("Append: %v", err) - } - _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x22, requested)}) - if got := status.Code(err); got != codes.AlreadyExists { - t.Fatalf("reusing an operation id with different bytes returned %v, want %v (err %v)", got, codes.AlreadyExists, err) - } -} - -// TestAnInvalidEntryIsRefusedWithoutASequence: the invalid arm, and the proof -// that a refusal consumes nothing — the next valid append is still sequence 1. -func TestAnInvalidEntryIsRefusedWithoutASequence(t *testing.T) { - client, _ := dial(t) - ctx := context.Background() - short := entry("operation-1", 0x11, time.Unix(1700000000, 0).UTC()) - short.NarrowingHash = []byte{0x11} - _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: short}) - if got := status.Code(err); got != codes.InvalidArgument { - t.Fatalf("a 1-byte narrowing hash returned %v, want %v (err %v)", got, codes.InvalidArgument, err) - } - good, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-2", 0x11, time.Unix(1700000000, 0).UTC())}) - if err != nil { - t.Fatalf("Append after a refusal: %v", err) - } - if good.GetReceipt().GetSeq() != 1 { - t.Fatalf("a refused append consumed sequence 1; next append got %d", good.GetReceipt().GetSeq()) - } -} - -// TestReadAtTheHeadIsEmptyAndBeyondItIsAborted is the distinction the README -// calls out: an ahead-of-log reconciler must not be able to mistake a rollback -// for inactivity, so head+1 is an empty page and head+2 is a refusal. -func TestReadAtTheHeadIsEmptyAndBeyondItIsAborted(t *testing.T) { - client, public := dial(t) - ctx := context.Background() - requested := time.Unix(1700000000, 5).UTC() - if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}); err != nil { - t.Fatalf("Append: %v", err) - } - - page, err := client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 1, Limit: witness.MaxReadLimit}) - if err != nil { - t.Fatalf("Read: %v", err) - } - if len(page.GetRecords()) != 1 { - t.Fatalf("Read(1) returned %d records, want 1", len(page.GetRecords())) - } - record := page.GetRecords()[0] - if !witness.VerifyReceipt(public, receiptFrom(record.GetReceipt())) { - t.Fatal("a read record's original receipt does not verify") - } - if !record.GetEntry().GetRequestedAt().AsTime().Equal(requested) { - t.Fatalf("read requested_at = %v, want %v", record.GetEntry().GetRequestedAt().AsTime(), requested) - } - - atHead, err := client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 2, Limit: 1}) - if err != nil { - t.Fatalf("Read at head+1 must be an empty page, got %v", err) - } - if len(atHead.GetRecords()) != 0 { - t.Fatalf("Read at head+1 returned %d records, want 0", len(atHead.GetRecords())) - } - - _, err = client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 3, Limit: 1}) - if got := status.Code(err); got != codes.Aborted { - t.Fatalf("Read beyond head+1 returned %v, want %v (err %v)", got, codes.Aborted, err) - } -} - -// TestReadBoundsAreRefusedNotClamped: from_seq 0 and an over-ceiling limit are -// invalid requests, not requests to be quietly adjusted. -func TestReadBoundsAreRefusedNotClamped(t *testing.T) { - client, _ := dial(t) - ctx := context.Background() - for _, tc := range []struct { - name string - request *policylogv1.ReadRequest - }{ - {"from_seq 0", &policylogv1.ReadRequest{FromSeq: 0, Limit: 1}}, - {"limit 0", &policylogv1.ReadRequest{FromSeq: 1, Limit: 0}}, - {"limit over the ceiling", &policylogv1.ReadRequest{FromSeq: 1, Limit: witness.MaxReadLimit + 1}}, - } { - _, err := client.Read(ctx, tc.request) - if got := status.Code(err); got != codes.InvalidArgument { - t.Errorf("%s returned %v, want %v (err %v)", tc.name, got, codes.InvalidArgument, err) - } - } -} - -// TestCursorVerifiesAndTracksTheHead keeps the signed observation usable: an -// unsigned or stale-shaped cursor is not something a client can pin against. -func TestCursorVerifiesAndTracksTheHead(t *testing.T) { - client, public := dial(t) - ctx := context.Background() - - empty, err := client.Cursor(ctx, &policylogv1.CursorRequest{}) - if err != nil { - t.Fatalf("Cursor: %v", err) - } - genesis := witness.Cursor{ - HeadSeq: empty.GetCursor().GetHeadSeq(), ChainHash: empty.GetCursor().GetChainHash(), - IssuedAt: empty.GetCursor().GetIssuedAt().AsTime(), Signature: empty.GetCursor().GetSignature(), - } - if genesis.HeadSeq != 0 || !witness.VerifyCursor(public, genesis) { - t.Fatalf("the genesis cursor does not verify: %+v", genesis) - } - - if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, time.Unix(1700000000, 0).UTC())}); err != nil { - t.Fatalf("Append: %v", err) - } - advanced, err := client.Cursor(ctx, &policylogv1.CursorRequest{}) - if err != nil { - t.Fatalf("Cursor: %v", err) - } - moved := witness.Cursor{ - HeadSeq: advanced.GetCursor().GetHeadSeq(), ChainHash: advanced.GetCursor().GetChainHash(), - IssuedAt: advanced.GetCursor().GetIssuedAt().AsTime(), Signature: advanced.GetCursor().GetSignature(), - } - if moved.HeadSeq != 1 || !witness.VerifyCursor(public, moved) { - t.Fatalf("the cursor after one append does not verify at sequence 1: %+v", moved) - } -} - -// TestAnInterruptedAppendIsFailedPreconditionUntilItsOwnRetry is the arm that -// must never read like ErrConflict. An append that persisted its immutable -// entry and then lost the head write reserves that sequence: a DIFFERENT -// operation is refused with FailedPrecondition, which clears — and only the -// identical operation can finish it. A caller told AlreadyExists here would -// give up on a state that was about to resolve; a caller told nothing would -// mint a second sequence for one narrowing. -func TestAnInterruptedAppendIsFailedPreconditionUntilItsOwnRetry(t *testing.T) { - _, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - store := genesisStore(t) - ctx := context.Background() - requested := time.Unix(1700000000, 11).UTC() - - // The same log, reached once through a store that loses the head write and - // once through the healthy one, which is how an interrupted append is - // observed after a restart. - interrupted := serve(t, headFailure{store}, private) - healthy := serve(t, store, private) - - _, err = interrupted.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) - if got := status.Code(err); got != codes.Unavailable { - t.Fatalf("an append whose head write is lost returned %v, want %v (err %v)", got, codes.Unavailable, err) - } - - _, err = healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-2", 0x22, requested)}) - if got := status.Code(err); got != codes.FailedPrecondition { - t.Fatalf("a different operation over the reserved sequence returned %v, want %v (err %v)", got, codes.FailedPrecondition, err) - } - - finished, err := healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) - if err != nil { - t.Fatalf("the identical operation must be able to finish its own reserved sequence: %v", err) - } - if finished.GetReceipt().GetSeq() != 1 { - t.Fatalf("the finished retry got sequence %d, want the reserved 1", finished.GetReceipt().GetSeq()) - } - - // And the same operation id with different bytes is still the permanent - // answer, so the two arms stay distinguishable end to end. - _, err = healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x33, requested)}) - if got := status.Code(err); got != codes.AlreadyExists { - t.Fatalf("reusing the committed operation id with different bytes returned %v, want %v (err %v)", got, codes.AlreadyExists, err) - } -} diff --git a/module/services/policy-log/code/service/service.go b/module/services/policy-log/code/service/service.go deleted file mode 100644 index 638777d36..000000000 --- a/module/services/policy-log/code/service/service.go +++ /dev/null @@ -1,55 +0,0 @@ -// Package service joins the witness and its best-effort warehouse mirror. -// Transport adapters must use this boundary rather than offering candidates to -// the mirror themselves: only a committed Append result may be mirrored. -package service - -import ( - "bytes" - "context" - "errors" - - "policy-log/witness" -) - -type Service struct { - log *witness.Log - mirror Mirror -} - -// Mirror must offer without blocking; the production mirror.Worker has a -// bounded queue and drops a full queue instead of waiting on warehouse I/O. -type Mirror interface{ Offer(witness.Record) } - -func New(log *witness.Log, worker Mirror) (*Service, error) { - if log == nil || worker == nil { - return nil, errors.New("policy-log: witness and warehouse mirror are required") - } - return &Service{log: log, mirror: worker}, nil -} - -func (s *Service) Append(ctx context.Context, entry witness.Entry) (*witness.Receipt, error) { - ctx, cancel := context.WithTimeout(ctx, witness.AppendDeadline) - defer cancel() - if err := ctx.Err(); err != nil { - return nil, err - } - if err := entry.Validate(); err != nil { - return nil, err - } - entry.NarrowingHash = bytes.Clone(entry.NarrowingHash) - receipt, err := s.log.Append(ctx, entry) - if err != nil { - return nil, err - } - s.mirror.Offer(witness.Record{Entry: entry, Receipt: *receipt}) - if err := ctx.Err(); err != nil { - return nil, err - } - return receipt, nil -} - -func (s *Service) Cursor(ctx context.Context) (*witness.Cursor, error) { return s.log.Cursor(ctx) } -func (s *Service) Read(ctx context.Context, fromSeq uint64, limit uint32) ([]witness.Record, error) { - return s.log.Read(ctx, fromSeq, limit) -} -func (s *Service) Keys() witness.Key { return s.log.Keys() } diff --git a/module/services/policy-log/code/service/service_test.go b/module/services/policy-log/code/service/service_test.go deleted file mode 100644 index e495b2a69..000000000 --- a/module/services/policy-log/code/service/service_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package service - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/json" - "errors" - "testing" - "time" - - "policy-log/mirror" - "policy-log/objectstore" - "policy-log/objectstore/memory" - "policy-log/witness" -) - -type sinkFunc func(context.Context, witness.Record) error - -func (f sinkFunc) Write(ctx context.Context, r witness.Record) error { return f(ctx, r) } - -type headFailure struct{ objectstore.Store } - -type countingMirror struct{ offers int } - -func (m *countingMirror) Offer(witness.Record) { m.offers++ } - -func (s headFailure) Write(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == witness.HeadObject { - return 0, objectstore.ErrPrecondition - } - return s.Store.Write(ctx, name, b, g) -} - -func newService(t *testing.T, fail bool, sink mirror.Sink) (*Service, *memory.Store, context.CancelFunc) { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - store := memory.New() - genesis, _ := json.Marshal(struct { - Seq uint64 `json:"seq"` - ChainHash []byte `json:"chain_hash"` - }{ChainHash: make([]byte, 32)}) - if _, err := store.Write(ctx, witness.HeadObject, genesis, 0); err != nil { - t.Fatal(err) - } - _, key, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - var objects objectstore.Store = store - if fail { - objects = headFailure{store} - } - log, err := witness.Open(ctx, objects, key) - if err != nil { - t.Fatal(err) - } - worker, err := mirror.Start(ctx, sink, func(error) {}) - if err != nil { - t.Fatal(err) - } - s, err := New(log, worker) - if err != nil { - t.Fatal(err) - } - return s, store, cancel -} - -func operation() witness.Entry { - h := sha256.Sum256([]byte("narrowing bytes")) - return witness.Entry{OperationID: "example-operation", Kind: "narrowed", Subject: "example-principal", Tenant: "example-tenant", NarrowingHash: h[:], RequestedAt: time.Now().UTC()} -} - -func TestCommittedEntryIsMirroredWithTheSameReceipt(t *testing.T) { - mirrored := make(chan witness.Record, 1) - s, _, _ := newService(t, false, sinkFunc(func(_ context.Context, r witness.Record) error { mirrored <- r; return nil })) - e := operation() - r, err := s.Append(context.Background(), e) - if err != nil { - t.Fatal(err) - } - select { - case row := <-mirrored: - if row.Entry.OperationID != e.OperationID || row.Receipt.Seq != r.Seq || !ed25519.Verify(s.Keys().PublicKey, witness.ReceiptBytes(row.Receipt), row.Receipt.Signature) { - t.Fatal("mirror did not receive the committed signed record") - } - cursor, err := s.Cursor(context.Background()) - if err != nil || cursor.HeadSeq != row.Receipt.Seq { - t.Fatalf("mirrored before head commit: %v, %v", cursor, err) - } - case <-time.After(time.Second): - t.Fatal("committed entry was not offered to warehouse") - } -} - -func TestFailedHeadCommitNeverOffersWarehouseRecord(t *testing.T) { - s, _, _ := newService(t, true, sinkFunc(func(context.Context, witness.Record) error { return nil })) - mirrored := &countingMirror{} - s.mirror = mirrored - r, err := s.Append(context.Background(), operation()) - if r != nil || !errors.Is(err, objectstore.ErrPrecondition) { - t.Fatalf("head loss returned receipt: %v, %v", r, err) - } - if mirrored.offers != 0 { - t.Fatal("uncommitted entry offered to warehouse") - } -} - -func TestWarehouseFailureCannotWithholdOrMintAReceipt(t *testing.T) { - entered := make(chan struct{}) - s, _, _ := newService(t, false, sinkFunc(func(ctx context.Context, _ witness.Record) error { - close(entered) - <-ctx.Done() - return errors.New("warehouse unavailable") - })) - start := time.Now() - r, err := s.Append(context.Background(), operation()) - if err != nil || r == nil || time.Since(start) > time.Second || !witness.VerifyReceipt(s.Keys().PublicKey, *r) { - t.Fatalf("warehouse controlled receipt: %v, %v", r, err) - } - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("mirror was never attempted") - } -} - -func TestExpiredAndInvalidRequestsNeverReachMirror(t *testing.T) { - s, _, _ := newService(t, false, sinkFunc(func(context.Context, witness.Record) error { return nil })) - mirrored := &countingMirror{} - s.mirror = mirrored - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) - defer cancel() - if receipt, err := s.Append(ctx, operation()); receipt != nil || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("expired request: %v, %v", receipt, err) - } - bad := operation() - bad.NarrowingHash = make([]byte, objectstore.MaxObjectBytes+1) - if receipt, err := s.Append(context.Background(), bad); receipt != nil || !errors.Is(err, witness.ErrInvalid) { - t.Fatalf("oversized hash: %v, %v", receipt, err) - } - if mirrored.offers != 0 { - t.Fatal("refused request reached mirror") - } - cursor, err := s.Cursor(context.Background()) - if err != nil || cursor.HeadSeq != 0 { - t.Fatalf("refused request advanced head: %v, %v", cursor, err) - } -} diff --git a/module/services/policy-log/code/witness/contract.go b/module/services/policy-log/code/witness/contract.go deleted file mode 100644 index aeda00d66..000000000 --- a/module/services/policy-log/code/witness/contract.go +++ /dev/null @@ -1,168 +0,0 @@ -// Package witness implements an external, signed, append-only policy log. -package witness - -import ( - "bytes" - "crypto/ed25519" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "time" - "unicode" - "unicode/utf8" - - "policy-log/objectstore" -) - -const ( - AppendDeadline = 5 * time.Second - MaxReadLimit = 500 - HeadObject = objectstore.HeadObject -) - -var ( - ErrInvalid = errors.New("invalid policy log request") - ErrConflict = errors.New("operation id already names a different entry") - ErrIntegrity = errors.New("policy log integrity check failed") - ErrRollback = errors.New("policy log head moved backwards or changed history") - ErrPending = errors.New("next sequence holds an uncommitted operation; retry that exact operation") -) - -// Entry carries opaque identifiers, never domain content. NarrowingHash is the -// SHA-256 of the caller's canonical operation bytes. RequestedAt belongs to that -// operation and must remain unchanged on a retry; it is not a request deadline. -type Entry struct { - OperationID string `json:"operation_id"` - Kind string `json:"kind"` - Subject string `json:"subject"` - Tenant string `json:"tenant"` - NarrowingHash []byte `json:"narrowing_hash"` - RequestedAt time.Time `json:"requested_at"` -} - -type Receipt struct { - Seq uint64 `json:"seq"` - EntryHash []byte `json:"entry_hash"` - PrevHash []byte `json:"prev_hash"` - ChainHash []byte `json:"chain_hash"` - IssuedAt time.Time `json:"issued_at"` - Signature []byte `json:"signature"` -} - -type Record struct { - Entry Entry `json:"entry"` - Receipt Receipt `json:"receipt"` -} - -type Cursor struct { - HeadSeq uint64 `json:"head_seq"` - ChainHash []byte `json:"chain_hash"` - IssuedAt time.Time `json:"issued_at"` - Signature []byte `json:"signature"` -} - -// Key is also the schema of the public trust-anchor document. A network key -// response is discovery, never authority to replace a caller's pinned key. -type Key struct { - Algorithm string `json:"algorithm"` - KeyID string `json:"key_id"` - PublicKey []byte `json:"public_key"` -} - -func validTime(t time.Time) bool { return !t.IsZero() && t.Year() >= 1 && t.Year() <= 9999 } - -func (e Entry) Validate() error { - for _, field := range []struct { - value string - required bool - max int - }{ - {e.OperationID, true, 1024}, {e.Kind, true, 128}, {e.Subject, true, 1024}, {e.Tenant, false, 1024}, - } { - if (field.required && field.value == "") || len(field.value) > field.max || !utf8.ValidString(field.value) { - return fmt.Errorf("%w: identifier is missing, oversized or not UTF-8", ErrInvalid) - } - for _, r := range field.value { - if unicode.IsControl(r) { - return fmt.Errorf("%w: control character in identifier", ErrInvalid) - } - } - } - if len(e.NarrowingHash) != sha256.Size || !validTime(e.RequestedAt) { - return fmt.Errorf("%w: SHA-256 digest and requested_at are required", ErrInvalid) - } - return nil -} - -// Canonical encodings use domain separation, big-endian unsigned integers, -// u32-length-prefixed UTF-8 strings, raw 32-byte digests, and a timestamp encoded -// as Unix seconds (int64's two's-complement bits) then u32 nanoseconds. There is -// no JSON serialization or protobuf field-order dependency in a signature. -func put64(b *bytes.Buffer, n uint64) { _ = binary.Write(b, binary.BigEndian, n) } -func put32(b *bytes.Buffer, n uint32) { _ = binary.Write(b, binary.BigEndian, n) } -func putString(b *bytes.Buffer, s string) { put32(b, uint32(len(s))); b.WriteString(s) } -func putTime(b *bytes.Buffer, t time.Time) { - put64(b, uint64(t.Unix())) - put32(b, uint32(t.Nanosecond())) -} - -func EntryHash(e Entry) ([]byte, error) { - if err := e.Validate(); err != nil { - return nil, err - } - b := bytes.NewBufferString("policy-log.entry.v1\x00") - for _, s := range []string{e.OperationID, e.Kind, e.Subject, e.Tenant} { - putString(b, s) - } - b.Write(e.NarrowingHash) - putTime(b, e.RequestedAt) - h := sha256.Sum256(b.Bytes()) - return h[:], nil -} - -func ChainHash(seq uint64, prev, entry []byte) []byte { - b := bytes.NewBufferString("policy-log.chain.v1\x00") - put64(b, seq) - b.Write(prev) - b.Write(entry) - h := sha256.Sum256(b.Bytes()) - return h[:] -} - -func ReceiptBytes(r Receipt) []byte { - b := bytes.NewBufferString("policy-log.receipt.v1\x00") - put64(b, r.Seq) - b.Write(r.EntryHash) - b.Write(r.PrevHash) - b.Write(r.ChainHash) - putTime(b, r.IssuedAt) - return b.Bytes() -} - -func CursorBytes(c Cursor) []byte { - b := bytes.NewBufferString("policy-log.cursor.v1\x00") - put64(b, c.HeadSeq) - b.Write(c.ChainHash) - putTime(b, c.IssuedAt) - return b.Bytes() -} - -func VerifyReceipt(key ed25519.PublicKey, r Receipt) bool { - return len(key) == ed25519.PublicKeySize && r.Seq != 0 && validTime(r.IssuedAt) && - len(r.EntryHash) == sha256.Size && len(r.PrevHash) == sha256.Size && len(r.ChainHash) == sha256.Size && - bytes.Equal(r.ChainHash, ChainHash(r.Seq, r.PrevHash, r.EntryHash)) && ed25519.Verify(key, ReceiptBytes(r), r.Signature) -} - -func VerifyCursor(key ed25519.PublicKey, c Cursor) bool { - return len(key) == ed25519.PublicKeySize && len(c.ChainHash) == sha256.Size && validTime(c.IssuedAt) && - (c.HeadSeq != 0 || bytes.Equal(c.ChainHash, make([]byte, sha256.Size))) && ed25519.Verify(key, CursorBytes(c), c.Signature) -} - -func EntryObject(seq uint64) string { return fmt.Sprintf("entries/%020d.json", seq) } - -func keyDocument(public ed25519.PublicKey) Key { - id := sha256.Sum256(public) - return Key{Algorithm: "Ed25519", KeyID: hex.EncodeToString(id[:]), PublicKey: bytes.Clone(public)} -} diff --git a/module/services/policy-log/code/witness/log.go b/module/services/policy-log/code/witness/log.go deleted file mode 100644 index 0d4f9c473..000000000 --- a/module/services/policy-log/code/witness/log.go +++ /dev/null @@ -1,324 +0,0 @@ -package witness - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "time" - - "policy-log/objectstore" -) - -type head struct { - Seq uint64 `json:"seq"` - ChainHash []byte `json:"chain_hash"` -} - -type Log struct { - store objectstore.Store - private ed25519.PrivateKey - public ed25519.PublicKey - gate chan struct{} - head head - generation int64 - records map[uint64]Record - operations map[string]uint64 -} - -// Open verifies the committed chain before the service may listen. A different -// signing key cannot reopen an existing log. The in-memory index is disposable: -// the immutable objects and generation-guarded head are the authority. -func Open(ctx context.Context, store objectstore.Store, key ed25519.PrivateKey) (*Log, error) { - if store == nil || len(key) != ed25519.PrivateKeySize || !bytes.Equal(key, ed25519.NewKeyFromSeed(key.Seed())) { - return nil, fmt.Errorf("policy log requires storage and a valid Ed25519 signing key") - } - l := &Log{store: store, private: bytes.Clone(key), public: bytes.Clone(key.Public().(ed25519.PublicKey)), - gate: make(chan struct{}, 1), head: head{ChainHash: make([]byte, sha256.Size)}, - records: make(map[uint64]Record), operations: make(map[string]uint64)} - if err := l.refresh(ctx); err != nil { - return nil, err - } - return l, nil -} - -func (l *Log) Keys() Key { return keyDocument(l.public) } - -func (l *Log) lock(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - select { - case l.gate <- struct{}{}: - if err := ctx.Err(); err != nil { - <-l.gate - return err - } - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func decode(data []byte, value any) error { - if len(data) > objectstore.MaxObjectBytes { - return ErrIntegrity - } - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - if err := d.Decode(value); err != nil { - return fmt.Errorf("%w: malformed object", ErrIntegrity) - } - if err := d.Decode(new(any)); !errors.Is(err, io.EOF) { - return fmt.Errorf("%w: trailing object bytes", ErrIntegrity) - } - return nil -} - -func (l *Log) checkRecord(r Record, seq uint64, prev []byte) error { - h, err := EntryHash(r.Entry) - if err != nil || r.Receipt.Seq != seq || !bytes.Equal(h, r.Receipt.EntryHash) || - !bytes.Equal(prev, r.Receipt.PrevHash) || !VerifyReceipt(l.public, r.Receipt) { - return fmt.Errorf("%w: sequence %d", ErrIntegrity, seq) - } - return nil -} - -func (l *Log) loadRecord(ctx context.Context, seq uint64) (Record, error) { - obj, err := l.store.Read(ctx, EntryObject(seq)) - if err != nil { - return Record{}, err - } - var r Record - if err := decode(obj.Data, &r); err != nil { - return Record{}, err - } - return r, ctx.Err() -} - -// refresh never publishes a partial read, a lower cursor, or a changed prefix. -// Even an idempotent append re-reads the head: a cached receipt must not hide an -// unreachable or rolled-back authority store. -func (l *Log) refresh(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - obj, err := l.store.Read(ctx, HeadObject) - if errors.Is(err, objectstore.ErrNotFound) { - // Genesis is provisioned once in the independent trust domain. Treating - // a deleted head as a new log could silently reuse old sequence numbers. - return fmt.Errorf("%w: required head object is absent", ErrRollback) - } - if err != nil { - return err - } - var next head - if err := decode(obj.Data, &next); err != nil { - return err - } - if obj.Generation <= 0 || len(next.ChainHash) != sha256.Size { - return ErrIntegrity - } - if next.Seq < l.head.Seq || obj.Generation < l.generation || - (next.Seq == l.head.Seq && !bytes.Equal(next.ChainHash, l.head.ChainHash)) { - return ErrRollback - } - if next.Seq == l.head.Seq { - l.generation = obj.Generation - return ctx.Err() - } - prev := l.head.ChainHash - var pending []Record - seen := make(map[string]bool) - for seq := l.head.Seq + 1; ; seq++ { - r, err := l.loadRecord(ctx, seq) - if err != nil { - return fmt.Errorf("read committed sequence %d: %w", seq, err) - } - if err := l.checkRecord(r, seq, prev); err != nil { - return err - } - if _, exists := l.operations[r.Entry.OperationID]; exists || seen[r.Entry.OperationID] { - return fmt.Errorf("%w: duplicate operation id", ErrIntegrity) - } - seen[r.Entry.OperationID] = true - pending = append(pending, r) - prev = r.Receipt.ChainHash - if seq == next.Seq { - break - } - } - if !bytes.Equal(next.ChainHash, prev) { - return ErrIntegrity - } - if err := ctx.Err(); err != nil { - return err - } - for _, r := range pending { - l.remember(r) - } - l.head, l.generation = next, obj.Generation - return nil -} - -func (l *Log) remember(r Record) { - l.records[r.Receipt.Seq] = r - l.operations[r.Entry.OperationID] = r.Receipt.Seq -} - -func cloneReceipt(r Receipt) Receipt { - r.EntryHash, r.PrevHash, r.ChainHash, r.Signature = bytes.Clone(r.EntryHash), bytes.Clone(r.PrevHash), bytes.Clone(r.ChainHash), bytes.Clone(r.Signature) - return r -} - -func cloneRecord(r Record) Record { - r.Entry.NarrowingHash = bytes.Clone(r.Entry.NarrowingHash) - r.Receipt = cloneReceipt(r.Receipt) - return r -} - -// Append's budget includes queueing and refreshing. There is one attempt at -// each conditional write, with no detached writer and no re-mint on conflict. -func (l *Log) Append(ctx context.Context, entry Entry) (*Receipt, error) { - ctx, cancel := context.WithTimeout(ctx, AppendDeadline) - defer cancel() - if err := l.lock(ctx); err != nil { - return nil, err - } - defer func() { <-l.gate }() - if err := entry.Validate(); err != nil { - return nil, err - } - entry.NarrowingHash = bytes.Clone(entry.NarrowingHash) - hash, err := EntryHash(entry) - if err != nil { - return nil, err - } - if err := l.refresh(ctx); err != nil { - return nil, err - } - if seq, ok := l.operations[entry.OperationID]; ok { - r := l.records[seq].Receipt - if !bytes.Equal(hash, r.EntryHash) { - return nil, ErrConflict - } - if err := ctx.Err(); err != nil { - return nil, err - } - copy := cloneReceipt(r) - return ©, nil - } - if l.head.Seq == math.MaxUint64 { - return nil, fmt.Errorf("%w: sequence exhausted", ErrIntegrity) - } - seq := l.head.Seq + 1 - r, err := l.loadRecord(ctx, seq) - switch { - case err == nil: - // A preceding call may have persisted this slot but lost its head write. - // Only the identical operation may explicitly complete it. Never delete - // the slot, silently skip it, or complete a different operation late. - if err := l.checkRecord(r, seq, l.head.ChainHash); err != nil { - return nil, err - } - if r.Entry.OperationID != entry.OperationID { - return nil, ErrPending - } - if !bytes.Equal(hash, r.Receipt.EntryHash) { - return nil, ErrConflict - } - case errors.Is(err, objectstore.ErrNotFound): - r = Record{Entry: entry, Receipt: Receipt{Seq: seq, EntryHash: hash, PrevHash: bytes.Clone(l.head.ChainHash), - ChainHash: ChainHash(seq, l.head.ChainHash, hash), IssuedAt: time.Now().UTC()}} - r.Receipt.Signature = ed25519.Sign(l.private, ReceiptBytes(r.Receipt)) - body, err := json.Marshal(r) - if err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - if _, err := l.store.Write(ctx, EntryObject(seq), body, 0); err != nil { - return nil, err - } - default: - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - next := head{Seq: seq, ChainHash: bytes.Clone(r.Receipt.ChainHash)} - body, err := json.Marshal(next) - if err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - generation, err := l.store.Write(ctx, HeadObject, body, l.generation) - if err != nil { - return nil, err - } - l.head, l.generation = next, generation - l.remember(r) - if err := ctx.Err(); err != nil { - return nil, err - } - copy := cloneReceipt(r.Receipt) - return ©, nil -} - -func (l *Log) Cursor(ctx context.Context) (*Cursor, error) { - ctx, cancel := context.WithTimeout(ctx, AppendDeadline) - defer cancel() - if err := l.lock(ctx); err != nil { - return nil, err - } - defer func() { <-l.gate }() - if err := l.refresh(ctx); err != nil { - return nil, err - } - c := &Cursor{HeadSeq: l.head.Seq, ChainHash: bytes.Clone(l.head.ChainHash), IssuedAt: time.Now().UTC()} - c.Signature = ed25519.Sign(l.private, CursorBytes(*c)) - if err := ctx.Err(); err != nil { - return nil, err - } - return c, nil -} - -// Read starts at an inclusive sequence (1 is genesis). Asking beyond head+1 -// refuses rather than making a restored store look like a quiet log. Callers -// persist their verified sequence and chain hash across replicas and restarts. -func (l *Log) Read(ctx context.Context, from uint64, limit uint32) ([]Record, error) { - ctx, cancel := context.WithTimeout(ctx, AppendDeadline) - defer cancel() - if from == 0 || limit == 0 || limit > MaxReadLimit { - return nil, ErrInvalid - } - if err := l.lock(ctx); err != nil { - return nil, err - } - defer func() { <-l.gate }() - if err := l.refresh(ctx); err != nil { - return nil, err - } - if from > l.head.Seq && from-l.head.Seq > 1 { - return nil, ErrRollback - } - records := make([]Record, 0, limit) - for seq := from; seq <= l.head.Seq && uint32(len(records)) < limit; seq++ { - records = append(records, cloneRecord(l.records[seq])) - if seq == math.MaxUint64 { - break - } - } - if err := ctx.Err(); err != nil { - return nil, err - } - return records, nil -} diff --git a/module/services/policy-log/code/witness/log_test.go b/module/services/policy-log/code/witness/log_test.go deleted file mode 100644 index 1dee4c7b8..000000000 --- a/module/services/policy-log/code/witness/log_test.go +++ /dev/null @@ -1,508 +0,0 @@ -package witness - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "reflect" - "sync" - "sync/atomic" - "testing" - "time" - - "policy-log/objectstore" - "policy-log/objectstore/memory" -) - -func newStore(t *testing.T) *memory.Store { - t.Helper() - s := memory.New() - body, err := json.Marshal(head{ChainHash: make([]byte, sha256.Size)}) - if err != nil { - t.Fatal(err) - } - if _, err := s.Write(context.Background(), HeadObject, body, 0); err != nil { - t.Fatal(err) - } - return s -} - -func TestMissingHeadRefusesBoot(t *testing.T) { - if _, err := Open(context.Background(), memory.New(), signingKey(t)); !errors.Is(err, ErrRollback) { - t.Fatalf("missing head accepted: %v", err) - } -} - -func signingKey(t *testing.T) ed25519.PrivateKey { - t.Helper() - _, key, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return key -} - -func openLog(t *testing.T, store objectstore.Store, key ed25519.PrivateKey) *Log { - t.Helper() - l, err := Open(context.Background(), store, key) - if err != nil { - t.Fatal(err) - } - return l -} - -func entry(id string) Entry { - h := sha256.Sum256([]byte("canonical narrowing bytes")) - return Entry{OperationID: id, Kind: "narrowed", Subject: "example-principal", Tenant: "example-tenant", - NarrowingHash: h[:], RequestedAt: time.Date(2026, 10, 1, 10, 20, 30, 456, time.UTC)} -} - -func appendEntry(t *testing.T, l *Log, e Entry) *Receipt { - t.Helper() - r, err := l.Append(context.Background(), e) - if err != nil || r == nil { - t.Fatalf("Append = %v, %v", r, err) - } - return r -} - -// Independently encode the contract, rather than verifying the implementation -// with the encoder that signed it. This is also a minimal client example. -func independentEntryHash(e Entry) []byte { - b := []byte("policy-log.entry.v1\x00") - for _, s := range []string{e.OperationID, e.Kind, e.Subject, e.Tenant} { - b = binary.BigEndian.AppendUint32(b, uint32(len(s))) - b = append(b, s...) - } - b = append(b, e.NarrowingHash...) - b = binary.BigEndian.AppendUint64(b, uint64(e.RequestedAt.Unix())) - b = binary.BigEndian.AppendUint32(b, uint32(e.RequestedAt.Nanosecond())) - h := sha256.Sum256(b) - return h[:] -} - -func independentReceiptBytes(r Receipt) []byte { - b := binary.BigEndian.AppendUint64([]byte("policy-log.receipt.v1\x00"), r.Seq) - for _, h := range [][]byte{r.EntryHash, r.PrevHash, r.ChainHash} { - b = append(b, h...) - } - b = binary.BigEndian.AppendUint64(b, uint64(r.IssuedAt.Unix())) - return binary.BigEndian.AppendUint32(b, uint32(r.IssuedAt.Nanosecond())) -} - -func TestReadVerifiesEntireChainAndPagination(t *testing.T) { - l := openLog(t, newStore(t), signingKey(t)) - for i := 1; i <= 4; i++ { - appendEntry(t, l, entry(fmt.Sprint(i))) - } - var all []Record - for _, from := range []uint64{1, 3} { - page, err := l.Read(context.Background(), from, 2) - if err != nil || len(page) != 2 { - t.Fatalf("Read(%d) = %v, %v", from, page, err) - } - all = append(all, page...) - } - prev := make([]byte, sha256.Size) - for i, r := range all { - seq := uint64(i + 1) - hash := independentEntryHash(r.Entry) - b := binary.BigEndian.AppendUint64([]byte("policy-log.chain.v1\x00"), seq) - b = append(b, prev...) - b = append(b, hash...) - chain := sha256.Sum256(b) - if r.Receipt.Seq != seq || !bytes.Equal(hash, r.Receipt.EntryHash) || - !bytes.Equal(prev, r.Receipt.PrevHash) || !bytes.Equal(chain[:], r.Receipt.ChainHash) { - t.Fatalf("chain broken at %d", seq) - } - prev = chain[:] - } - c, err := l.Cursor(context.Background()) - if err != nil || c.HeadSeq != 4 || !bytes.Equal(c.ChainHash, prev) { - t.Fatalf("cursor = %v, %v", c, err) - } - page, err := l.Read(context.Background(), 5, 1) - if err != nil || len(page) != 0 { - t.Fatalf("quiet log = %v, %v", page, err) - } -} - -func TestReceiptVerifiesUnderKeysAndRejectsAnotherKey(t *testing.T) { - l := openLog(t, newStore(t), signingKey(t)) - r := appendEntry(t, l, entry("one")) - keys := l.Keys() - if keys.Algorithm != "Ed25519" || len(keys.KeyID) != 64 || !ed25519.Verify(keys.PublicKey, independentReceiptBytes(*r), r.Signature) { - t.Fatal("receipt does not verify under published key") - } - other := signingKey(t).Public().(ed25519.PublicKey) - if ed25519.Verify(other, independentReceiptBytes(*r), r.Signature) { - t.Fatal("another key accepted receipt") - } - for _, mutate := range []func(*Receipt){ - func(r *Receipt) { r.Seq++ }, func(r *Receipt) { r.EntryHash[0] ^= 1 }, - func(r *Receipt) { r.PrevHash[0] ^= 1 }, func(r *Receipt) { r.ChainHash[0] ^= 1 }, - func(r *Receipt) { r.IssuedAt = r.IssuedAt.Add(time.Nanosecond) }, func(r *Receipt) { r.Signature[0] ^= 1 }, - } { - altered := cloneReceipt(*r) - mutate(&altered) - if VerifyReceipt(keys.PublicKey, altered) { - t.Fatal("tampered receipt verified") - } - } -} - -func TestIdempotentReappendReturnsSameReceiptAfterRestart(t *testing.T) { - s, key := newStore(t), signingKey(t) - l := openLog(t, s, key) - first := appendEntry(t, l, entry("same")) - appendEntry(t, l, entry("later")) - l = openLog(t, s, key) - second := appendEntry(t, l, entry("same")) - if !reflect.DeepEqual(first, second) { - t.Fatalf("receipt reminted: %v != %v", first, second) - } - c, err := l.Cursor(context.Background()) - if err != nil || c.HeadSeq != 2 { - t.Fatalf("idempotent call advanced head: %v, %v", c, err) - } -} - -func TestOperationIDReuseWithDifferentEntryIsRefused(t *testing.T) { - l := openLog(t, newStore(t), signingKey(t)) - appendEntry(t, l, entry("same")) - for _, mutate := range []func(*Entry){ - func(e *Entry) { e.Kind = "regranted" }, func(e *Entry) { e.Subject = "other" }, - func(e *Entry) { e.Tenant = "other" }, func(e *Entry) { e.NarrowingHash[0] ^= 1 }, - func(e *Entry) { e.RequestedAt = e.RequestedAt.Add(time.Nanosecond) }, - } { - e := entry("same") - mutate(&e) - r, err := l.Append(context.Background(), e) - if r != nil || !errors.Is(err, ErrConflict) { - t.Fatalf("reused operation accepted: %v, %v", r, err) - } - } -} - -type interceptStore struct { - objectstore.Store - write func(context.Context, string, []byte, int64) (int64, error) - read func(context.Context, string) (objectstore.Object, error) -} - -func (s *interceptStore) Write(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if s.write != nil { - return s.write(ctx, name, b, g) - } - return s.Store.Write(ctx, name, b, g) -} -func (s *interceptStore) Read(ctx context.Context, name string) (objectstore.Object, error) { - if s.read != nil { - return s.read(ctx, name) - } - return s.Store.Read(ctx, name) -} - -func TestConcurrentSecondAppenderLosesHeadPreconditionAndIssuesNoReceipt(t *testing.T) { - s, key := newStore(t), signingKey(t) - // A crash after the immutable entry leaves a candidate, not a receipt. - interrupted := &interceptStore{Store: s} - interrupted.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == HeadObject { - return 0, objectstore.ErrPrecondition - } - return s.Write(ctx, name, b, g) - } - r, err := openLog(t, interrupted, key).Append(context.Background(), entry("same")) - if r != nil || !errors.Is(err, objectstore.ErrPrecondition) { - t.Fatalf("failed head write returned receipt: %v, %v", r, err) - } - var arrivals atomic.Int32 - ready := make(chan struct{}) - racing := &interceptStore{Store: s} - racing.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == HeadObject { - if arrivals.Add(1) == 2 { - close(ready) - } - select { - case <-ready: - case <-ctx.Done(): - return 0, ctx.Err() - } - } - return s.Write(ctx, name, b, g) - } - a, b := openLog(t, racing, key), openLog(t, racing, key) - type answer struct { - receipt *Receipt - err error - } - answers := make(chan answer, 2) - for _, l := range []*Log{a, b} { - go func() { r, e := l.Append(context.Background(), entry("same")); answers <- answer{r, e} }() - } - successes, losses := 0, 0 - for range 2 { - result := <-answers - switch { - case result.err == nil && result.receipt != nil: - successes++ - case errors.Is(result.err, objectstore.ErrPrecondition) && result.receipt == nil: - losses++ - default: - t.Fatalf("unexpected append: %+v", result) - } - } - if successes != 1 || losses != 1 || arrivals.Load() != 2 { - t.Fatalf("successes=%d, losses=%d, head attempts=%d", successes, losses, arrivals.Load()) - } -} - -func TestConcurrentDifferentOperationsCannotOverwriteSequence(t *testing.T) { - s, key := newStore(t), signingKey(t) - var arrivals atomic.Int32 - ready := make(chan struct{}) - racing := &interceptStore{Store: s} - racing.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == EntryObject(1) { - if arrivals.Add(1) == 2 { - close(ready) - } - select { - case <-ready: - case <-ctx.Done(): - return 0, ctx.Err() - } - } - return s.Write(ctx, name, b, g) - } - a, b := openLog(t, racing, key), openLog(t, racing, key) - var wg sync.WaitGroup - var successes, losses atomic.Int32 - for i, l := range []*Log{a, b} { - wg.Go(func() { - r, err := l.Append(context.Background(), entry(fmt.Sprint(i))) - if err == nil && r != nil { - successes.Add(1) - } else if errors.Is(err, objectstore.ErrPrecondition) && r == nil { - losses.Add(1) - } else { - t.Errorf("unexpected result: %v, %v", r, err) - } - }) - } - wg.Wait() - if successes.Load() != 1 || losses.Load() != 1 { - t.Fatalf("successes=%d, losses=%d", successes.Load(), losses.Load()) - } -} - -func TestUncommittedEntryIsInvisibleAndOnlyExactRetryCanComplete(t *testing.T) { - s, key := newStore(t), signingKey(t) - broken := &interceptStore{Store: s, write: func(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == HeadObject { - return 0, errors.New("head unavailable") - } - return s.Write(ctx, name, b, g) - }} - r, err := openLog(t, broken, key).Append(context.Background(), entry("original")) - if err == nil || r != nil { - t.Fatal("issued receipt before head commit") - } - l := openLog(t, s, key) - rows, err := l.Read(context.Background(), 1, 10) - if err != nil || len(rows) != 0 { - t.Fatalf("uncommitted row visible: %v, %v", rows, err) - } - r, err = l.Append(context.Background(), entry("other")) - if !errors.Is(err, ErrPending) || r != nil { - t.Fatalf("overwrote pending slot: %v, %v", r, err) - } - bad := entry("original") - bad.Kind = "regranted" - r, err = l.Append(context.Background(), bad) - if !errors.Is(err, ErrConflict) || r != nil { - t.Fatalf("changed pending operation: %v, %v", r, err) - } - appendEntry(t, l, entry("original")) -} - -func TestCursorIsSignedAndForwardOnlyAcrossAppenders(t *testing.T) { - s, key := newStore(t), signingKey(t) - a, b := openLog(t, s, key), openLog(t, s, key) - c, err := b.Cursor(context.Background()) - if err != nil || c.HeadSeq != 0 || !VerifyCursor(b.Keys().PublicKey, *c) { - t.Fatalf("genesis cursor: %v, %v", c, err) - } - appendEntry(t, a, entry("one")) - old, _ := s.Read(context.Background(), HeadObject) - c, err = b.Cursor(context.Background()) - if err != nil || c.HeadSeq != 1 || !VerifyCursor(b.Keys().PublicKey, *c) { - t.Fatalf("cursor: %v, %v", c, err) - } - appendEntry(t, b, entry("two")) - c, err = a.Cursor(context.Background()) - if err != nil || c.HeadSeq != 2 { - t.Fatalf("cursor: %v, %v", c, err) - } - current, _ := s.Read(context.Background(), HeadObject) - if _, err := s.Write(context.Background(), HeadObject, old.Data, current.Generation); err != nil { - t.Fatal(err) - } - c, err = a.Cursor(context.Background()) - if c != nil || !errors.Is(err, ErrRollback) { - t.Fatalf("rollback served: %v, %v", c, err) - } - rows, err := a.Read(context.Background(), 1, 1) - if rows != nil || !errors.Is(err, ErrRollback) { - t.Fatalf("rollback read served: %v, %v", rows, err) - } -} - -func TestUnreadableHeadRefusesReadsAndIdempotentAppend(t *testing.T) { - s := &interceptStore{Store: newStore(t)} - l := openLog(t, s, signingKey(t)) - appendEntry(t, l, entry("one")) - s.read = func(context.Context, string) (objectstore.Object, error) { - return objectstore.Object{}, errors.New("unreachable") - } - if r, err := l.Append(context.Background(), entry("one")); err == nil || r != nil { - t.Fatal("cached receipt hid unreachable log") - } - if c, err := l.Cursor(context.Background()); err == nil || c != nil { - t.Fatal("cached cursor hid unreachable log") - } - if rows, err := l.Read(context.Background(), 1, 1); err == nil || rows != nil { - t.Fatal("cached entries hid unreachable log") - } -} - -func TestAppendPastDeadlineIsRefusedWithoutWriting(t *testing.T) { - s := &interceptStore{Store: newStore(t)} - l := openLog(t, s, signingKey(t)) - s.write = func(context.Context, string, []byte, int64) (int64, error) { - t.Error("write after deadline") - return 0, nil - } - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) - defer cancel() - r, err := l.Append(ctx, entry("one")) - if r != nil || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("late Append: %v, %v", r, err) - } -} - -func TestDeadlineDuringHeadWriteDoesNotCommitLate(t *testing.T) { - s := newStore(t) - delayed := &interceptStore{Store: s, write: func(ctx context.Context, name string, b []byte, g int64) (int64, error) { - if name == HeadObject { - <-ctx.Done() - } - return s.Write(ctx, name, b, g) - }} - l := openLog(t, delayed, signingKey(t)) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - r, err := l.Append(ctx, entry("one")) - if r != nil || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("late head write: %v, %v", r, err) - } - obj, err := s.Read(context.Background(), HeadObject) - if err != nil { - t.Fatal(err) - } - var h head - if err := json.Unmarshal(obj.Data, &h); err != nil || h.Seq != 0 { - t.Fatalf("late head committed: %v, %v", h, err) - } -} - -func TestAppendWithoutCallerDeadlineIsBoundedAtFiveSeconds(t *testing.T) { - s := &interceptStore{Store: newStore(t)} - l := openLog(t, s, signingKey(t)) - s.read = func(ctx context.Context, _ string) (objectstore.Object, error) { - deadline, ok := ctx.Deadline() - if !ok || time.Until(deadline) > 5*time.Second { - t.Fatal("append has no five-second budget") - } - <-ctx.Done() - return objectstore.Object{}, ctx.Err() - } - start := time.Now() - r, err := l.Append(context.Background(), entry("one")) - if r != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 5500*time.Millisecond { - t.Fatalf("unbounded append: %v, %v (%v)", r, err, time.Since(start)) - } -} - -func TestWaitingAppenderHonorsItsDeadline(t *testing.T) { - l := openLog(t, newStore(t), signingKey(t)) - l.gate <- struct{}{} - defer func() { <-l.gate }() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - r, err := l.Append(ctx, entry("one")) - if r != nil || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("queued append: %v, %v", r, err) - } -} - -func TestOpenRejectsTamperedHistoryAndAnotherSigningKey(t *testing.T) { - s, key := newStore(t), signingKey(t) - l := openLog(t, s, key) - appendEntry(t, l, entry("one")) - if _, err := Open(context.Background(), s, signingKey(t)); !errors.Is(err, ErrIntegrity) { - t.Fatalf("wrong key: %v", err) - } - obj, _ := s.Read(context.Background(), EntryObject(1)) - var record Record - if err := json.Unmarshal(obj.Data, &record); err != nil { - t.Fatal(err) - } - record.Entry.Subject = "tampered" - body, _ := json.Marshal(record) - if _, err := s.Write(context.Background(), EntryObject(1), body, obj.Generation); err != nil { - t.Fatal(err) - } - if _, err := Open(context.Background(), s, key); !errors.Is(err, ErrIntegrity) { - t.Fatalf("tampered history: %v", err) - } -} - -func TestInvalidRequestsAndMutableResultsCannotChangeLog(t *testing.T) { - l := openLog(t, newStore(t), signingKey(t)) - bad := entry("bad") - bad.NarrowingHash = []byte{1} - if r, err := l.Append(context.Background(), bad); r != nil || !errors.Is(err, ErrInvalid) { - t.Fatalf("bad digest: %v, %v", r, err) - } - for _, q := range []struct { - from uint64 - limit uint32 - }{{0, 1}, {1, 0}, {1, 501}, {2, 1}} { - if rows, err := l.Read(context.Background(), q.from, q.limit); err == nil || rows != nil { - t.Fatalf("bad read accepted: %+v", q) - } - } - first := appendEntry(t, l, entry("one")) - first.Signature[0] ^= 1 - rows, err := l.Read(context.Background(), 1, 1) - if err != nil { - t.Fatal(err) - } - rows[0].Entry.NarrowingHash[0] ^= 1 - rows[0].Receipt.ChainHash[0] ^= 1 - k := l.Keys() - k.PublicKey[0] ^= 1 - r := appendEntry(t, l, entry("one")) - if !VerifyReceipt(l.Keys().PublicKey, *r) { - t.Fatal("caller mutated committed receipt or public key") - } -} diff --git a/module/services/policy-log/proto/README.md b/module/services/policy-log/proto/README.md deleted file mode 100644 index 1982a57f4..000000000 --- a/module/services/policy-log/proto/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Policy log protobuf input - -`saas/policylog/v1/witness.proto` is the only transport projection of the -authored Go contract in [../code/witness/contract.go](../code/witness/contract.go). -It carries the four operations the transport-independent service exposes, and -nothing else: there is no REST annotation, no Connect binding and no OpenAPI -document, because the endpoint is private to this module and reaches no gateway. - -It deliberately takes **no buf dependency**. `google/protobuf/timestamp.proto` -is a well-known type the toolchain already carries, so generation needs no -registry fetch. Validation is not duplicated here either — `Entry.Validate` in -the Go contract is the one authority for identifier limits and the 32-byte -digest, so a protovalidate annotation would be a second place to disagree. - -Regenerate from `module/services/policy-log` with Docker running: - -```sh -codefly generate proto --proto ./proto --output .. --template policy-log/proto/buf.gen.yaml -``` - -The companion image pins every plugin and runs `goimports` over the Go it -wrote. Never run `buf generate` directly: it skips that pass, and -`module/tools/generated-pins-gate.mjs` fails on the resulting import shape. -`buf.gen.local.yaml` beside the service manifest exists only so that gate can -read the plugin versions the image runs; it is not an alternative command. diff --git a/module/services/policy-log/proto/buf.gen.yaml b/module/services/policy-log/proto/buf.gen.yaml deleted file mode 100644 index bebeb5eeb..000000000 --- a/module/services/policy-log/proto/buf.gen.yaml +++ /dev/null @@ -1,16 +0,0 @@ -version: v2 -clean: true -managed: - enabled: true - override: - - file_option: go_package_prefix - value: policy-log/pkg/gen -plugins: - # Codefly's versioned proto companion owns the byte-generating toolchain. - # Local plugins avoid mutable registry execution and anonymous rate limits. - - local: protoc-gen-go - out: ../code/pkg/gen - opt: paths=source_relative - - local: protoc-gen-go-grpc - out: ../code/pkg/gen - opt: paths=source_relative diff --git a/module/services/policy-log/proto/buf.yaml b/module/services/policy-log/proto/buf.yaml deleted file mode 100644 index 734b79ba2..000000000 --- a/module/services/policy-log/proto/buf.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: v2 -modules: - - path: . -lint: - use: - - STANDARD - disallow_comment_ignores: true diff --git a/module/services/policy-log/proto/saas/policylog/v1/witness.proto b/module/services/policy-log/proto/saas/policylog/v1/witness.proto deleted file mode 100644 index 2ce7ac221..000000000 --- a/module/services/policy-log/proto/saas/policylog/v1/witness.proto +++ /dev/null @@ -1,172 +0,0 @@ -syntax = "proto3"; - -package saas.policylog.v1; - -import "google/protobuf/timestamp.proto"; - -// Entry is one operation offered to the witness. -// -// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: -// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and -// the witness stores that digest rather than the operation. Reconciliation can -// identify an operation and verify its bytes; it cannot reconstruct them, so -// the caller must retain or recover the canonical bytes to apply it. -// -// Every field takes part in entry_hash, so every field must stay IDENTICAL on a -// retry of the same operation_id — requested_at included. The witness refuses a -// reused operation_id that names different bytes. -message Entry { - // operation_id identifies one logical operation for the lifetime of the log, - // and it is the caller's: only the caller knows that two attempts are the - // same operation. At most 1024 bytes of control-character-free UTF-8. - string operation_id = 1; - - // kind is what the operation is about, at most 128 bytes. - string kind = 2; - - // subject is what the operation acts on, at most 1024 bytes. - string subject = 3; - - // tenant may be empty for a global operation. It grants no authority: this - // log performs no tenant scoping and the field is an identifier, not a claim. - string tenant = 4; - - // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical - // operation bytes, carried raw rather than hex or base64 so the digest the - // signature covers is the digest on the wire. - bytes narrowing_hash = 5; - - // requested_at is the CALLER'S logical timestamp for the operation, not a - // deadline and not an ordering authority. It is covered by entry_hash, so an - // old operation can be retried without changing its identity only as long as - // this value is sent back unchanged. - google.protobuf.Timestamp requested_at = 6; -} - -// Receipt is the witness's signed acknowledgement of one committed entry. -// -// Only a committed head makes a receipt exist. A signature over a prepared -// candidate that never reached the head is not a receipt and is never returned. -message Receipt { - // seq is the global sequence the witness assigned, never zero and never - // supplied by a caller. The first sequence is 1. Sequence, not clock time, - // orders operations. - uint64 seq = 1; - - // entry_hash is the 32-byte digest of the appended entry. A caller compares - // it against the bytes it asked to append. - bytes entry_hash = 2; - - // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. - bytes prev_hash = 3; - - // chain_hash is the 32-byte chain hash at seq. - bytes chain_hash = 4; - - // issued_at comes from the witness, never from a caller. - google.protobuf.Timestamp issued_at = 5; - - // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which - // depends on no protobuf field ordering. - bytes signature = 6; -} - -// Record is one entry together with the original receipt the witness issued for -// it, which is what makes a read independently verifiable. -message Record { - Entry entry = 1; - Receipt receipt = 2; -} - -// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt -// nor a reset instruction: a verifying client never lowers a persisted cursor, -// and a valid signature cannot by itself distinguish a historical cursor from a -// current one, so the client also enforces its own freshness policy. -message Cursor { - uint64 head_seq = 1; - bytes chain_hash = 2; - google.protobuf.Timestamp issued_at = 3; - bytes signature = 4; -} - -// Key is the public half of the witness signing key, and the same schema as the -// trust-anchor document composition delivers to the host. Publishing it is -// discovery, never authority to replace a caller's pinned key. -message Key { - // algorithm is "Ed25519". - string algorithm = 1; - - // key_id is the lowercase SHA-256 hex of the raw public key. - string key_id = 2; - - // public_key is the raw 32-byte public key. - bytes public_key = 3; -} - -// AppendRequest offers one entry. -message AppendRequest { - Entry entry = 1; -} - -// AppendResponse carries the committed receipt. A retry of the identical entry -// returns the exact original receipt rather than appending again. -message AppendResponse { - Receipt receipt = 1; -} - -// CursorRequest takes no argument: there is one log and one head. -message CursorRequest {} - -// CursorResponse carries the signed head observation. -message CursorResponse { - Cursor cursor = 1; -} - -// ReadRequest asks for a bounded page of committed records. -message ReadRequest { - // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an - // empty page; asking beyond it is refused, so a reconciler that is ahead of - // the log cannot mistake a rollback for inactivity. A caller holding an - // exclusive cursor sends cursor + 1. - uint64 from_seq = 1; - - // limit is 1 through 500. - uint32 limit = 2; -} - -// ReadResponse carries the page in ascending sequence order. -message ReadResponse { - repeated Record records = 1; -} - -// KeysRequest takes no argument. -message KeysRequest {} - -// KeysResponse carries the public signing key document. -message KeysResponse { - Key key = 1; -} - -// WitnessService is the independent append-only witness for authority changes. -// -// Its protocol is append -> signed receipt -> the CALLER commits with the -// receipt in the same transaction. This listener is PRIVATE to the module: it is -// never published through the public gateway and never offered to a composed -// module. Composition admits only the host's declared client, through -// workload-authenticated mesh policy. -service WitnessService { - // Append commits one entry and returns its receipt, within a five-second - // budget that includes queueing. A refusal carries no receipt and never - // mints a new sequence for a retried operation. - rpc Append(AppendRequest) returns (AppendResponse); - - // Cursor returns the signed committed head. - rpc Cursor(CursorRequest) returns (CursorResponse); - - // Read returns committed records with their original receipts. - rpc Read(ReadRequest) returns (ReadResponse); - - // Keys returns the public signing key. It never returns private material and - // does not authorize trust-anchor replacement. - rpc Keys(KeysRequest) returns (KeysResponse); -} diff --git a/module/services/policy-log/service.codefly.yaml b/module/services/policy-log/service.codefly.yaml deleted file mode 100644 index 5d9b8763f..000000000 --- a/module/services/policy-log/service.codefly.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: policy-log -version: 0.0.0 -description: Independent signed witness for authority changes -agent: - kind: codefly:service - name: go-grpc - version: 0.1.58 - publisher: codefly.dev -workspace-configuration-dependencies: - - policy-log -endpoints: - # Private to this module. The host declares the dependency when its client - # is wired; neither the public gateway nor composed modules get this API. - - name: grpc -spec: - hot-reload: true - # Only the Go and gRPC bindings. There is no OpenAPI document because the - # endpoint is private gRPC and reaches no gateway, and a declared generated - # directory nothing writes is a claim no regeneration can reproduce. - protocol-output-dirs: - - code/pkg/gen - service-account: - name: policy-log - deployment: - # Codefly allocates the endpoint port. This declaration carries no - # accounts/store dependency and no fixed port or injected credential. - public-egress-ports: - - 443 diff --git a/module/services/store/migrations/56_authority_core.down.sql b/module/services/store/migrations/56_authority_core.down.sql new file mode 100644 index 000000000..609bb5adb --- /dev/null +++ b/module/services/store/migrations/56_authority_core.down.sql @@ -0,0 +1,98 @@ +-- Withdrawing the authority core's records ends every installation, grant and +-- delegation it decided, and every capability it issued with them. +-- The delegations the core holds return to accounts' own relation (migration +-- 51), as they stood. +-- Delegations to a module: the host authority interface's Delegate. A signed-in +-- person grants one composed module the right to act for them beyond any +-- session, on scopes they hold; the module then issues from the delegation +-- through a binding it declares (Issue with a delegation). Every issue re-reads +-- the row and what its grantor holds at that moment, so a delegation never +-- confers more than its grantor still holds. +-- +-- The grant is immutable once recorded. A row is never deleted by the host: +-- revocation stamps revoked_at and who revoked it (the grantor, or an +-- administrator of the organisation), so what was delegated, to which module +-- and why stays readable after it stopped. Every capability issued under a +-- delegation is sealed to it, and the seal reads this row live: revocation +-- takes effect immediately, for this delegation's capabilities alone. + +CREATE TABLE public.module_delegations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + org_id uuid NOT NULL, + principal_id uuid NOT NULL, + module_prefix text NOT NULL, + scopes jsonb NOT NULL, + purpose text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + revoked_at timestamp with time zone, + revoked_by uuid, + CONSTRAINT module_delegations_module_prefix_check CHECK ((module_prefix ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'::text) AND (length(module_prefix) <= 63)), + CONSTRAINT module_delegations_scopes_check CHECK ((jsonb_typeof(scopes) = 'array'::text) AND (jsonb_array_length(scopes) > 0)), + CONSTRAINT module_delegations_purpose_check CHECK (length(purpose) <= 1024), + CONSTRAINT module_delegations_revocation_whole CHECK (((revoked_at IS NULL) = (revoked_by IS NULL))) +); + +ALTER TABLE ONLY public.module_delegations FORCE ROW LEVEL SECURITY; + +ALTER TABLE ONLY public.module_delegations + ADD CONSTRAINT module_delegations_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.module_delegations + ADD CONSTRAINT module_delegations_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.organizations(id) ON DELETE CASCADE; + +CREATE INDEX module_delegations_grantor ON public.module_delegations USING btree (org_id, principal_id, created_at DESC); + +ALTER TABLE public.module_delegations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY module_delegations_tenant ON public.module_delegations + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +CREATE POLICY app_control_plane_explicit_rows ON public.module_delegations + TO app_control_plane + USING ((CURRENT_USER = 'app_control_plane'::name)) + WITH CHECK ((CURRENT_USER = 'app_control_plane'::name)); + +-- No DELETE for either role: a delegation is revoked, never erased (the +-- organization's own deletion removes its rows through the foreign key). +GRANT SELECT,INSERT,UPDATE ON TABLE public.module_delegations TO app_tenant; +GRANT SELECT,INSERT,UPDATE ON TABLE public.module_delegations TO app_control_plane; + +-- Only the revocation stamp may change, and only once. +CREATE FUNCTION public.module_delegation_immutable() RETURNS trigger +LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + IF (NEW.id, NEW.org_id, NEW.principal_id, NEW.module_prefix, NEW.scopes, NEW.purpose, NEW.created_at) + IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.principal_id, OLD.module_prefix, OLD.scopes, OLD.purpose, OLD.created_at) + OR (OLD.revoked_at IS NOT NULL AND (NEW.revoked_at, NEW.revoked_by) IS DISTINCT FROM (OLD.revoked_at, OLD.revoked_by)) THEN + RAISE EXCEPTION 'a module delegation is immutable once recorded and is revoked at most once' USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER module_delegation_immutable BEFORE UPDATE ON public.module_delegations + FOR EACH ROW EXECUTE FUNCTION public.module_delegation_immutable(); + +REVOKE ALL ON FUNCTION public.module_delegation_immutable() FROM PUBLIC; + +INSERT INTO public.module_delegations (id, org_id, principal_id, module_prefix, scopes, purpose, revoked_at, revoked_by) +SELECT d.id::uuid, d.organization_id, d.grantor::uuid, d.module, + COALESCE((SELECT jsonb_agg(jsonb_strip_nulls(jsonb_build_object( + 'resource_kind', s->>'kind', 'actions', s->'actions', 'resource_ids', s->'ids'))) + FROM jsonb_array_elements(d.scopes) AS s), '[]'::jsonb), + d.purpose, + CASE WHEN d.revoked THEN CURRENT_TIMESTAMP END, + CASE WHEN d.revoked THEN d.grantor::uuid END + FROM public.authority_core_delegations d + WHERE d.grantor ~ '^[0-9a-f-]{36}$' AND d.id ~ '^[0-9a-f-]{36}$' AND d.module ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'; + +DROP TABLE public.authority_core_replay; +DROP TABLE public.authority_core_owner_aware; +DROP TABLE public.authority_core_grant_epochs; +DROP TABLE public.authority_core_person_epochs; +DROP TABLE public.authority_core_delegations; +DROP TABLE public.authority_core_role_grants; +DROP TABLE public.authority_core_module_revisions; +DROP TABLE public.authority_core_installations; +DROP TABLE public.authority_core_tombstones; +DROP TABLE public.authority_core_modules; diff --git a/module/services/store/migrations/56_authority_core.up.sql b/module/services/store/migrations/56_authority_core.up.sql new file mode 100644 index 000000000..9ac1f6e44 --- /dev/null +++ b/module/services/store/migrations/56_authority_core.up.sql @@ -0,0 +1,166 @@ +-- The authority core's records (codefly-dev/authority libraries/authority-core). +-- +-- accounts serves the host interfaces (authority, composition, installation) +-- through the shared authority core, the same one the local authority runs on +-- an in-memory store. These are the records the core decides with and writes: +-- the modules a delivery composed and the tombstones of withdrawn ones, every +-- installation (a module's at each organization, a solution's installed by an +-- administrator), the solution roles granted on them, persons' delegations to +-- modules, the epochs and revisions that end what was issued before them, and +-- the single-use capabilities already consumed. Identity (organizations, +-- members, teams, roles) and the organization's authorization revision stay +-- accounts' own relations; the core only reads them. +-- +-- Control-plane only, like every platform relation: the core decides across +-- organizations (a module delivered is installed at every one), so request +-- traffic has no access and nothing here carries a request policy. Isolation +-- between organizations is the core's decision, proven by the host-interface +-- conformance suite. + +CREATE TABLE public.authority_core_modules ( + name text NOT NULL, + -- The audience every capability to the module names; a module keeps it for life. + principal text NOT NULL, + kind text NOT NULL, + generation bigint NOT NULL, + -- A fixture's stand-in until the module's own delivery replaces it. + stand_in boolean DEFAULT false NOT NULL, + -- What it serves, its bindings, a solution's roles, its method policies. + declarations jsonb NOT NULL, + CONSTRAINT authority_core_modules_pkey PRIMARY KEY (name), + CONSTRAINT authority_core_modules_principal_key UNIQUE (principal), + CONSTRAINT authority_core_modules_kind_check CHECK ((kind = ANY (ARRAY['module'::text, 'solution'::text]))), + CONSTRAINT authority_core_modules_generation_check CHECK ((generation > 0)) +); + +-- What stays of a withdrawn module, never removed: its principal is its own +-- for life and a redelivery must exceed its generation. +CREATE TABLE public.authority_core_tombstones ( + name text NOT NULL, + principal text NOT NULL, + generation bigint NOT NULL, + CONSTRAINT authority_core_tombstones_pkey PRIMARY KEY (name), + CONSTRAINT authority_core_tombstones_principal_key UNIQUE (principal) +); + +CREATE TABLE public.authority_core_installations ( + id text NOT NULL, + organization_id uuid NOT NULL, + target text NOT NULL, + status text NOT NULL, + revision bigint NOT NULL, + -- A solution installation's own role, its principal and its installer; + -- empty for a module's. + agent_role text DEFAULT ''::text NOT NULL, + principal_id text DEFAULT ''::text NOT NULL, + installed_by text DEFAULT ''::text NOT NULL, + -- The last reviewed consent operation. + consent jsonb DEFAULT '{}'::jsonb NOT NULL, + -- The resource ids attached to the installation, by kind. + owned jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT authority_core_installations_pkey PRIMARY KEY (id), + CONSTRAINT authority_core_installations_organization_fkey FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE, + CONSTRAINT authority_core_installations_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'active'::text, 'revoked'::text]))) +); + +CREATE INDEX authority_core_installations_organization_idx ON public.authority_core_installations USING btree (organization_id); +CREATE UNIQUE INDEX authority_core_installations_principal_key ON public.authority_core_installations USING btree (principal_id) WHERE (principal_id <> ''::text); + +-- A module's installation is not here: it is accounts' own record +-- (public.installations, kind 'module'), an administrator's consent within the +-- module envelope, retired by an uninstall or by a ceiling that no longer +-- allows the organization. The core reads those records as its installations +-- (it is configured so that it installs no module itself) and only ever raises +-- one's revision, when a redelivery narrows the module. accounts' own revision +-- is lifecycle-owned, so the raise is kept here, beside the record, and the +-- revision the core reads is the sum. +CREATE TABLE public.authority_core_module_revisions ( + installation_id uuid NOT NULL, + raised bigint NOT NULL, + CONSTRAINT authority_core_module_revisions_pkey PRIMARY KEY (installation_id), + CONSTRAINT authority_core_module_revisions_installation_fkey FOREIGN KEY (installation_id) REFERENCES public.installations(id) ON DELETE CASCADE, + CONSTRAINT authority_core_module_revisions_raised_check CHECK ((raised > 0)) +); + +-- One role on one installation, held by exactly one person or team. +CREATE TABLE public.authority_core_role_grants ( + installation_id text NOT NULL, + role text NOT NULL, + person_id text DEFAULT ''::text NOT NULL, + team_id text DEFAULT ''::text NOT NULL, + CONSTRAINT authority_core_role_grants_pkey PRIMARY KEY (installation_id, role, person_id, team_id), + CONSTRAINT authority_core_role_grants_subject_check CHECK (((person_id = ''::text) <> (team_id = ''::text))) +); + +CREATE TABLE public.authority_core_delegations ( + id text NOT NULL, + organization_id uuid NOT NULL, + grantor text NOT NULL, + module text NOT NULL, + purpose text DEFAULT ''::text NOT NULL, + scopes jsonb NOT NULL, + revoked boolean DEFAULT false NOT NULL, + CONSTRAINT authority_core_delegations_pkey PRIMARY KEY (id), + CONSTRAINT authority_core_delegations_organization_fkey FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE +); + +-- Counters that only grow: a person the host reported changed, a person's +-- role revoked on one installation, an audience that withdrew owner-aware +-- checking. Each ends the capabilities sealed before it moved. +CREATE TABLE public.authority_core_person_epochs ( + person_id text NOT NULL, + epoch bigint NOT NULL, + CONSTRAINT authority_core_person_epochs_pkey PRIMARY KEY (person_id), + CONSTRAINT authority_core_person_epochs_epoch_check CHECK ((epoch > 0)) +); + +CREATE TABLE public.authority_core_grant_epochs ( + person_id text NOT NULL, + installation_id text NOT NULL, + epoch bigint NOT NULL, + CONSTRAINT authority_core_grant_epochs_pkey PRIMARY KEY (person_id, installation_id), + CONSTRAINT authority_core_grant_epochs_epoch_check CHECK ((epoch > 0)) +); + +CREATE TABLE public.authority_core_owner_aware ( + audience text NOT NULL, + revision bigint NOT NULL, + CONSTRAINT authority_core_owner_aware_pkey PRIMARY KEY (audience), + CONSTRAINT authority_core_owner_aware_revision_check CHECK ((revision > 0)) +); + +-- A single-use capability's nonce, consumed once across every replica and +-- kept until the capability expires. +CREATE TABLE public.authority_core_replay ( + nonce text NOT NULL, + retain_until timestamp with time zone NOT NULL, + CONSTRAINT authority_core_replay_pkey PRIMARY KEY (nonce) +); + +CREATE INDEX authority_core_replay_retain_until_idx ON public.authority_core_replay USING btree (retain_until); + +-- The delegations accounts recorded itself before the core decided them +-- (migration 51) are the core's now, as they stood: active or revoked. The +-- capabilities issued under them were sealed to a binding id the core does +-- not issue, so they end here and are issued again from the same delegation. +INSERT INTO public.authority_core_delegations (id, organization_id, grantor, module, purpose, scopes, revoked) +SELECT d.id::text, d.org_id, d.principal_id::text, d.module_prefix, d.purpose, + COALESCE((SELECT jsonb_agg(jsonb_strip_nulls(jsonb_build_object( + 'kind', s->>'resource_kind', 'actions', s->'actions', 'ids', s->'resource_ids'))) + FROM jsonb_array_elements(d.scopes) AS s), '[]'::jsonb), + d.revoked_at IS NOT NULL + FROM public.module_delegations d; + +DROP TABLE public.module_delegations; +DROP FUNCTION public.module_delegation_immutable(); + +GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE public.authority_core_modules TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_tombstones TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_installations TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_module_revisions TO app_control_plane; +GRANT SELECT,INSERT,DELETE ON TABLE public.authority_core_role_grants TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_delegations TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_person_epochs TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_grant_epochs TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.authority_core_owner_aware TO app_control_plane; +GRANT SELECT,INSERT,DELETE ON TABLE public.authority_core_replay TO app_control_plane; diff --git a/module/services/store/migrations/57_drop_policy_log.down.sql b/module/services/store/migrations/57_drop_policy_log.down.sql new file mode 100644 index 000000000..a7de26b48 --- /dev/null +++ b/module/services/store/migrations/57_drop_policy_log.down.sql @@ -0,0 +1,53 @@ +-- The policy log's local half, as migrations 25, 27 and 32 left it: the +-- receipt of each narrowing appended to the external log, and the singleton +-- cursor of how far this host reconciled it. Control-plane only, updateable, +-- never deletable. The rows the up migration dropped are not recoverable. + +CREATE TABLE public.policy_log_commits ( + operation_id text NOT NULL, + receipt text NOT NULL, + log_sequence bigint NOT NULL, + subject_kind text NOT NULL, + subject_id text NOT NULL, + appended_at timestamp with time zone NOT NULL, + committed_at timestamp with time zone, + + CONSTRAINT policy_log_commits_operation_id_check + CHECK ((length(operation_id) BETWEEN 1 AND 512)), + CONSTRAINT policy_log_commits_receipt_check + CHECK ((length(receipt) BETWEEN 1 AND 1024)), + CONSTRAINT policy_log_commits_sequence_check + CHECK ((log_sequence > 0)), + CONSTRAINT policy_log_commits_subject_kind_check + CHECK (subject_kind IN ('principal','binding','installation','team_membership','scope_grant','organization','module_envelope')), + CONSTRAINT policy_log_commits_subject_id_check + CHECK ((length(subject_id) BETWEEN 1 AND 512)) +); + +ALTER TABLE ONLY public.policy_log_commits + ADD CONSTRAINT policy_log_commits_pkey PRIMARY KEY (operation_id); + +CREATE INDEX policy_log_commits_uncommitted + ON public.policy_log_commits USING btree (log_sequence) + WHERE (committed_at IS NULL); + +CREATE INDEX policy_log_commits_subject + ON public.policy_log_commits USING btree (subject_kind, subject_id); + +CREATE TABLE public.policy_log_cursor ( + id boolean DEFAULT true NOT NULL, + reconciled_sequence bigint DEFAULT 0 NOT NULL, + reached_at timestamp with time zone, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT policy_log_cursor_singleton CHECK ((id = true)), + CONSTRAINT policy_log_cursor_sequence_check CHECK ((reconciled_sequence >= 0)) +); + +ALTER TABLE ONLY public.policy_log_cursor + ADD CONSTRAINT policy_log_cursor_pkey PRIMARY KEY (id); + +INSERT INTO public.policy_log_cursor (id, reconciled_sequence) VALUES (true, 0); + +GRANT SELECT,INSERT,UPDATE ON TABLE public.policy_log_commits TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.policy_log_cursor TO app_control_plane; diff --git a/module/services/store/migrations/57_drop_policy_log.up.sql b/module/services/store/migrations/57_drop_policy_log.up.sql new file mode 100644 index 000000000..a3cf6bd9d --- /dev/null +++ b/module/services/store/migrations/57_drop_policy_log.up.sql @@ -0,0 +1,12 @@ +-- The policy log's local half is withdrawn. +-- +-- A narrowing of authority (a member removed, a grant, share, key, principal +-- or delegation revoked, an installation narrowed or uninstalled, a withdrawn +-- solution target closed, a ceiling lowered) now applies directly, recorded in +-- accounts' audit journal in the same transaction (owner decision, +-- 2026-10-27). Nothing appends to an external log first, so no receipt is +-- written and no cursor advances: both relations held only that protocol's +-- state, and nothing reads them any more. + +DROP TABLE public.policy_log_cursor; +DROP TABLE public.policy_log_commits; diff --git a/module/services/store/migrations/58_hop_admission_instance_pin.down.sql b/module/services/store/migrations/58_hop_admission_instance_pin.down.sql new file mode 100644 index 000000000..813cd8012 --- /dev/null +++ b/module/services/store/migrations/58_hop_admission_instance_pin.down.sql @@ -0,0 +1,6 @@ +-- Admission keys return to one registration per key, unpinned, and proofs are +-- no longer remembered. +DROP TABLE public.hop_admission_proofs; +DROP INDEX public.hop_admission_keys_instance_key; +ALTER TABLE public.hop_admission_keys DROP CONSTRAINT hop_admission_keys_instance_check; +ALTER TABLE public.hop_admission_keys DROP COLUMN instance; diff --git a/module/services/store/migrations/58_hop_admission_instance_pin.up.sql b/module/services/store/migrations/58_hop_admission_instance_pin.up.sql new file mode 100644 index 000000000..ff798e2e2 --- /dev/null +++ b/module/services/store/migrations/58_hop_admission_instance_pin.up.sql @@ -0,0 +1,23 @@ +-- Interface 0.3.4 (codefly-dev/authority#32): one admission key is pinned per +-- hop instance, and every registration carries a proof used once. +-- +-- instance is the hop instance (its pod) whose registration holds the key: the +-- first registration for an instance binds its key until the lease lapses, the +-- same key renews it, and another key for that instance is refused until then. +-- A key published before this migration names no instance and lapses with its +-- lease. +ALTER TABLE public.hop_admission_keys ADD COLUMN instance text; +ALTER TABLE public.hop_admission_keys + ADD CONSTRAINT hop_admission_keys_instance_check CHECK ((instance IS NULL OR (length(instance) BETWEEN 1 AND 253))); +CREATE UNIQUE INDEX hop_admission_keys_instance_key ON public.hop_admission_keys USING btree (instance) WHERE (instance IS NOT NULL); + +-- The registration proofs' nonces, each kept until a proof carrying it could no +-- longer be judged fresh: a nonce seen once is refused again until then. +CREATE TABLE public.hop_admission_proofs ( + nonce text NOT NULL, + expires_at timestamp with time zone NOT NULL, + CONSTRAINT hop_admission_proofs_pkey PRIMARY KEY (nonce), + CONSTRAINT hop_admission_proofs_nonce_check CHECK ((nonce ~ '^[0-9a-f]{64}$')) +); + +GRANT SELECT,INSERT,DELETE ON TABLE public.hop_admission_proofs TO app_control_plane; diff --git a/module/tools/core_pin_floor_test.go b/module/tools/core_pin_floor_test.go index 8589d5396..5874ef368 100644 --- a/module/tools/core_pin_floor_test.go +++ b/module/tools/core_pin_floor_test.go @@ -36,7 +36,7 @@ func TestCorePinIsNotAVersionThatDroppedTheWorkThisModuleNeeds(t *testing.T) { // Every Go module in the tree that pins core. Discovered rather than listed: // a new service pinning core must be covered without anyone remembering to - // add it here, which is how the policy-log service arrived. + // add it here. var found []string err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/module/tools/execution_binding_wired_test.go b/module/tools/execution_binding_wired_test.go index 44e954af3..00fdc7973 100644 --- a/module/tools/execution_binding_wired_test.go +++ b/module/tools/execution_binding_wired_test.go @@ -27,11 +27,8 @@ import ( // "wired" from "wireable", and the deletion that unwires it is a one-line // change that compiles, passes every test, and silently removes a boundary. // -// WHY NOT-NIL IS PART OF THE RULE. This repository deliberately wires one -// mechanism with nil arguments — `service.SetPolicyLog(nil, nil)` — and says why -// in a long comment: a host holding a log it can never reach is worse than a -// host holding none. So "the setter is called" is not the property; the property -// is that it is called with real sources. A future edit that reduced +// WHY NOT-NIL IS PART OF THE RULE. "The setter is called" is not the property; +// the property is that it is called with real sources. A future edit that reduced // SetExecutionBinding to `SetExecutionBinding(nil)` would satisfy a grep // for the call and restore the hole exactly. func TestExecutionBindingIsWiredInProduction(t *testing.T) { @@ -76,8 +73,7 @@ func TestExecutionBindingIsWiredInProduction(t *testing.T) { "A nil source makes BindExecution answer ErrExecutionUnbound for every caller, which is the "+ "same observable as not wiring it at all — so this reads as enforcement while enforcing "+ "nothing.\n\n"+ - "This repository DOES wire SetPolicyLog(nil, nil) deliberately, and explains why in place. "+ - "If there is an equivalent argument for leaving execution binding unwired, it belongs in a "+ + "If there is an argument for leaving execution binding unwired, it belongs in a "+ "comment and in this gate, not in a silent nil.", which, fileSet.Position(argument.Pos())) } diff --git a/module/tools/naming-allowlist.json b/module/tools/naming-allowlist.json index 2fceed20e..7d7fcd934 100644 --- a/module/tools/naming-allowlist.json +++ b/module/tools/naming-allowlist.json @@ -21,11 +21,6 @@ "path": "module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go", "reason": "Holds the ONE literal filesystem path the delivery trust anchor is mounted at. It must match byte-for-byte what the platform infrastructure renders and pins, in a different repository, or the host reads a path nothing writes \u2014 which fails closed at boot forever and invites someone to 'fix' it by making the path configurable again, restoring the seam that let a composition choose the anchor its own documents are verified against. A placeholder here would break the file in the strict sense: the constant would name a directory that does not exist. Scoped to this one file deliberately; every other reference in this repository cites the Go constant rather than repeating the literal, so there is one source of truth and nothing to drift.", "ticket": "policy:AGENTS.md" - }, - { - "path": "module/services/policy-log/code/identity/signing_key.go", - "reason": "Holds the ONE literal path the witness log's Ed25519 signing key is mounted at, by the same rule as the delivery trust anchor: no path argument, no environment override, so the location is out of reach of anything the composition supplies. It must match byte-for-byte what the platform infrastructure renders, in a different repository, or the service reads a path nothing writes and refuses to boot forever \u2014 and the obvious 'fix' is to make the path configurable, which is the seam this design deletes. A placeholder here would name a directory that does not exist. Scoped to this one file: every other reference cites the Go constant rather than repeating the literal.", - "ticket": "policy:AGENTS.md" } ] } diff --git a/module/tools/workspace_configuration_dependencies_test.go b/module/tools/workspace_configuration_dependencies_test.go index 00ba11c19..bd94ab2ab 100644 --- a/module/tools/workspace_configuration_dependencies_test.go +++ b/module/tools/workspace_configuration_dependencies_test.go @@ -99,7 +99,7 @@ var skippedSourceDirs = map[string]bool{ // servicesKnownToReadGroups are services whose literal reads this gate must find. // If one of them yields none, an accessor was renamed and the gate is proving // nothing for it. A service that reads its group through a named constant -// (policy-log, telemetry) is outside what a literal match can see, and declares +// (telemetry) is outside what a literal match can see, and declares // its one group beside that constant. var servicesKnownToReadGroups = []string{"accounts", "auth-gateway", "frontend"} diff --git a/scripts/ci/host-interface-conformance-main.sh b/scripts/ci/host-interface-conformance-main.sh new file mode 100755 index 000000000..08f5bb837 --- /dev/null +++ b/scripts/ci/host-interface-conformance-main.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# The shared host-interface conformance suite at codefly-dev/authority's main, +# against accounts. accounts pins an authority revision in its go.mod; this +# runs the newest authority that still contains that pin, so a case authority +# adds, or a rule the authority core changes, reaches accounts on its next pull +# request instead of on the next re-pin. A main that does not contain the pin +# (the pin is a branch not merged yet) runs at the pin and says so. +set -euo pipefail +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$repo_root/module/services/accounts/code" +export GOWORK=off + +pinned="$(go list -m -f '{{.Version}}' github.com/codefly-dev/authority/libraries/authority-core/go)" +pinned_sha="${pinned##*-}" +# Ancestry is read from a blobless clone of authority's main, never from the +# GitHub API: an unauthenticated API call shares a small per-address budget on +# hosted runners, and a check that fails when that budget runs out is a flake. +# The clone is retried, because a network failure says nothing about accounts. +authority="$(mktemp -d)" +trap 'rm -rf "$authority"' EXIT +for attempt in 1 2 3; do + if git clone -q --bare --filter=blob:none --single-branch --branch main \ + https://github.com/codefly-dev/authority.git "$authority/main.git"; then + break + fi + rm -rf "$authority/main.git" + if [ "$attempt" = 3 ]; then + echo "::error::cannot clone codefly-dev/authority main" + exit 1 + fi + sleep $((attempt * 10)) +done +main_sha="$(git -C "$authority/main.git" rev-parse --short=12 HEAD)" +if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_sha" "$main_sha" 2>/dev/null; then + status="ahead" + [ "$(git -C "$authority/main.git" rev-parse --short=12 "$pinned_sha")" = "$main_sha" ] && status="identical" +else + status="diverged" +fi +echo "authority pinned at ${pinned_sha}; main at ${main_sha}; main is ${status} of the pin" +if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + for library in authority-core conformance authority-client delivery resources; do + go get "github.com/codefly-dev/authority/libraries/${library}/go@${main_sha}" + done + go mod tidy +else + echo "::notice::authority main does not contain the pinned revision ${pinned_sha}; the suite runs at the pin" +fi +go test -count=1 -timeout 30m -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters diff --git a/scripts/ci/release-gates.mjs b/scripts/ci/release-gates.mjs index af5764550..2adcd1c7e 100644 --- a/scripts/ci/release-gates.mjs +++ b/scripts/ci/release-gates.mjs @@ -56,6 +56,7 @@ export const REQUIRED_GATES = [ "codefly-quality", "codefly-supply-chain", "docs-sync", + "host-interface-conformance", "kit-version", "marketing", "module-package", @@ -401,6 +402,7 @@ export const REQUIRED_CONTEXTS = [ "Codefly SDK boundary", "Codefly supply chain", "Frontend kit version", + "Host interface conformance (authority main)", "Immutable module package", "Interface docs and story tests", "Marketing isolation",