From c93e0343a686ddd5e234ef7aa678fa72a3ee4970 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 11 Oct 2026 00:01:23 -0400 Subject: [PATCH 1/2] browser: add direct-credential sources and the directCredentials type Adds the directcred source contract and registry, the secret source (with in-process TOTP) and the 1Password source over the official SDK, the directCredentials credential type with its 1Password-vault pinning, and fail-closed handling when a source is unregistered. --- THIRD_PARTY_NOTICES.md | 8 + ...rimitives.authzed.com_agentidentities.yaml | 95 +++++ ...ves.authzed.com_sessionuseridentities.yaml | 44 ++ ...agentprimitives.authzed.com_toolcalls.yaml | 1 + ...primitives.authzed.com_useridentities.yaml | 44 ++ go.mod | 10 + go.sum | 18 + internal/cmd/operator/main.go | 4 +- pkg/apis/v1alpha1/agentidentity_types.go | 52 ++- pkg/apis/v1alpha1/conditions.go | 9 + pkg/apis/v1alpha1/credential_descriptor.go | 2 +- pkg/apis/v1alpha1/directcredentials_types.go | 31 ++ pkg/apis/v1alpha1/zz_generated.deepcopy.go | 41 ++ pkg/controllers/agentidentity/controller.go | 173 +++++++- .../agentidentity/direct_credential_test.go | 363 +++++++++++++++++ .../credkind/directcredentials/kind.go | 177 ++++++++ .../credkind/directcredentials/kind_test.go | 247 +++++++++++ pkg/platform/identity/credkind/guard_test.go | 2 + .../identity/credkind/imports/imports.go | 1 + .../identity/directcred/directcred.go | 123 ++++++ .../identity/directcred/directcred_test.go | 86 ++++ .../directcred/enum_invariant_test.go | 108 +++++ .../identity/directcred/onepassword/init.go | 5 + .../directcred/onepassword/live_test.go | 49 +++ .../identity/directcred/onepassword/sdk.go | 124 ++++++ .../identity/directcred/onepassword/source.go | 384 ++++++++++++++++++ .../directcred/onepassword/source_test.go | 372 +++++++++++++++++ pkg/platform/identity/directcred/registry.go | 21 + .../identity/directcred/secretsource/init.go | 5 + .../directcred/secretsource/source.go | 187 +++++++++ .../directcred/secretsource/source_test.go | 177 ++++++++ .../identity/directcred/secretsource/totp.go | 82 ++++ .../directcred/secretsource/totp_test.go | 102 +++++ pkg/platform/manifests/install.yaml | 184 +++++++++ .../licenses/demangle-BSD-3-Clause.txt | 27 ++ .../licenses/extism-go-sdk-BSD-3-Clause.txt | 28 ++ third_party/licenses/gobwas-glob-MIT.txt | 21 + .../licenses/observe-sdk-go-Apache-2.0.txt | 200 +++++++++ .../licenses/onepassword-sdk-go-MIT.txt | 21 + .../licenses/pquerna-otp-Apache-2.0.txt | 202 +++++++++ third_party/licenses/wabin-Apache-2.0.txt | 201 +++++++++ third_party/licenses/wazero-Apache-2.0.txt | 208 ++++++++++ 42 files changed, 4224 insertions(+), 15 deletions(-) create mode 100644 pkg/apis/v1alpha1/directcredentials_types.go create mode 100644 pkg/controllers/agentidentity/direct_credential_test.go create mode 100644 pkg/platform/identity/credkind/directcredentials/kind.go create mode 100644 pkg/platform/identity/credkind/directcredentials/kind_test.go create mode 100644 pkg/platform/identity/directcred/directcred.go create mode 100644 pkg/platform/identity/directcred/directcred_test.go create mode 100644 pkg/platform/identity/directcred/enum_invariant_test.go create mode 100644 pkg/platform/identity/directcred/onepassword/init.go create mode 100644 pkg/platform/identity/directcred/onepassword/live_test.go create mode 100644 pkg/platform/identity/directcred/onepassword/sdk.go create mode 100644 pkg/platform/identity/directcred/onepassword/source.go create mode 100644 pkg/platform/identity/directcred/onepassword/source_test.go create mode 100644 pkg/platform/identity/directcred/registry.go create mode 100644 pkg/platform/identity/directcred/secretsource/init.go create mode 100644 pkg/platform/identity/directcred/secretsource/source.go create mode 100644 pkg/platform/identity/directcred/secretsource/source_test.go create mode 100644 pkg/platform/identity/directcred/secretsource/totp.go create mode 100644 pkg/platform/identity/directcred/secretsource/totp_test.go create mode 100644 third_party/licenses/demangle-BSD-3-Clause.txt create mode 100644 third_party/licenses/extism-go-sdk-BSD-3-Clause.txt create mode 100644 third_party/licenses/gobwas-glob-MIT.txt create mode 100644 third_party/licenses/observe-sdk-go-Apache-2.0.txt create mode 100644 third_party/licenses/onepassword-sdk-go-MIT.txt create mode 100644 third_party/licenses/pquerna-otp-Apache-2.0.txt create mode 100644 third_party/licenses/wabin-Apache-2.0.txt create mode 100644 third_party/licenses/wazero-Apache-2.0.txt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7678ba60..42868681 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,14 @@ does not relicense them under the project's license. | TestSavantAI Prompt Injection Defender Base v2 ONNX model, [pinned revision](https://huggingface.co/testsavantai/prompt-injection-defender-base-v2-onnx/tree/a2109a5d583963f4962a9796d35315bdfed7c294) | Downloaded into the detector image at build time | [Apache License 2.0](images/promptinjection-detector/LICENSE) | | Microsoft DeBERTa v3 Base, used as the detector model's base model | Detector image | [MIT License](third_party/licenses/DeBERTa-MIT.txt) | | OWASP Top 10 for Agentic Applications 2026, by the OWASP GenAI Security Project | [`docs/references/OWASP-Top-10-for-Agentic-Applications-2026.pdf`](docs/references/OWASP-Top-10-for-Agentic-Applications-2026.pdf) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/legalcode); [source and attribution](docs/references/README.md) | +| pquerna/otp v1.5.0, by Paul Querna | Compiled into the runner binary (`pkg/platform/identity/directcred/secretsource`) | [Apache License 2.0](third_party/licenses/pquerna-otp-Apache-2.0.txt) | +| 1Password Go SDK v0.4.1, by 1Password | Compiled into any binary linking the 1Password directcred source (`pkg/platform/identity/directcred/onepassword`) | [MIT License](third_party/licenses/onepassword-sdk-go-MIT.txt) | +| Extism Go SDK v1.7.1, by Extism | Pulled in by the 1Password Go SDK (wasm host runtime) | [BSD 3-Clause License](third_party/licenses/extism-go-sdk-BSD-3-Clause.txt) | +| wazero v1.11.0, by the wazero authors | Pulled in by the 1Password Go SDK (wasm runtime) | [Apache License 2.0](third_party/licenses/wazero-Apache-2.0.txt) | +| dylibso/observe-sdk (Go), by Dylibso | Pulled in by the 1Password Go SDK (via Extism) | [Apache License 2.0](third_party/licenses/observe-sdk-go-Apache-2.0.txt) | +| tetratelabs/wabin, by Tetrate | Pulled in by the 1Password Go SDK (via Extism) | [Apache License 2.0](third_party/licenses/wabin-Apache-2.0.txt) | +| ianlancetaylor/demangle, by The Go Authors | Pulled in by the 1Password Go SDK (via Extism/observe-sdk) | [BSD 3-Clause License](third_party/licenses/demangle-BSD-3-Clause.txt) | +| gobwas/glob v0.2.3, by Sergey Kamardin | Pulled in by the 1Password Go SDK (via Extism) | [MIT License](third_party/licenses/gobwas-glob-MIT.txt) | Dependency metadata also identifies [MPL 2.0](https://www.mozilla.org/en-US/MPL/2.0/) components (including diff --git a/config/crds/agentprimitives.authzed.com_agentidentities.yaml b/config/crds/agentprimitives.authzed.com_agentidentities.yaml index bc8455f8..2fc495a8 100644 --- a/config/crds/agentprimitives.authzed.com_agentidentities.yaml +++ b/config/crds/agentprimitives.authzed.com_agentidentities.yaml @@ -88,6 +88,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -209,6 +252,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name @@ -352,6 +396,57 @@ spec: reflects. format: int64 type: integer + pinnedVaults: + description: |- + PinnedVaults records, for each type=directCredentials credential whose + source has a vault concept (onepassword), the vault its backing token was + PROVEN to resolve to. The validity reconciler calls the directcred source's + Verify on first sight and records the vault id + name here; it re-verifies + only when the token changes (TokenFingerprint differs), so a rotated token + that now resolves to a DIFFERENT vault surfaces loudly as + Valid=False/DirectCredentialInvalid instead of silently listing another + vault's logins at the browser toolbox. A source with no vault concept + (secret) is never recorded here. + + Operator-owned and derived: sorted by credential name and set once per + (credential, token) pair, so an unchanged token re-writes nothing and the + MergeFrom patch is a no-op. TokenFingerprint is a sha256 digest of the + token value, never the value itself. + items: + description: |- + PinnedVault is one entry of an identity's status.pinnedVaults — the vault a + type=directCredentials credential's token was proven to resolve to, pinned + once per token so a later rotation that redirects to a different vault is + caught rather than silently followed. Never carries the token value, only a + digest of it. + properties: + credential: + description: Credential is the credential name from spec.credentials[].name. + type: string + tokenFingerprint: + description: |- + TokenFingerprint is a hex sha256 digest of the backing token value at the + time the pin was written. It is the set-once key: a reconcile whose token + still hashes to this value carries the pin forward without re-verifying, + and a changed token forces a fresh Verify. It is a digest, never the token. + type: string + vaultID: + description: |- + VaultID is the backend's stable identifier for the pinned vault, as + returned by the source's Verify. + type: string + vaultName: + description: |- + VaultName is the human-readable vault name the backend confirmed. May + differ in case/whitespace from the credential's declared vault. + type: string + required: + - credential + - tokenFingerprint + - vaultID + - vaultName + type: object + type: array resolvedCredentials: description: ResolvedCredentials lists credential names whose Secret + key resolved. diff --git a/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml b/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml index b4d1bbec..19598136 100644 --- a/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml +++ b/config/crds/agentprimitives.authzed.com_sessionuseridentities.yaml @@ -98,6 +98,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -219,6 +262,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name diff --git a/config/crds/agentprimitives.authzed.com_toolcalls.yaml b/config/crds/agentprimitives.authzed.com_toolcalls.yaml index 2a9c2fd1..ede99fc4 100644 --- a/config/crds/agentprimitives.authzed.com_toolcalls.yaml +++ b/config/crds/agentprimitives.authzed.com_toolcalls.yaml @@ -165,6 +165,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name diff --git a/config/crds/agentprimitives.authzed.com_useridentities.yaml b/config/crds/agentprimitives.authzed.com_useridentities.yaml index 2d505801..f22de9cb 100644 --- a/config/crds/agentprimitives.authzed.com_useridentities.yaml +++ b/config/crds/agentprimitives.authzed.com_useridentities.yaml @@ -94,6 +94,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -215,6 +258,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name diff --git a/go.mod b/go.mod index c825dea6..059e7907 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ tool ( require ( fyne.io/systray v1.12.2 + github.com/1password/onepassword-sdk-go v0.4.1 github.com/Code-Hex/vz/v3 v3.7.1 github.com/Masterminds/semver/v3 v3.4.0 github.com/alecthomas/chroma/v2 v2.26.1 @@ -50,6 +51,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/ory/dockertest/v3 v3.12.0 + github.com/pquerna/otp v1.5.0 github.com/rs/zerolog v1.34.0 github.com/sigstore/sigstore v1.10.8 github.com/slack-go/slack v0.23.1 @@ -93,17 +95,25 @@ require ( buf.build/go/protovalidate v1.3.0 // indirect github.com/authzed/ctxkey v0.0.0-20250226155515-d49f99185584 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.37 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creasty/defaults v1.8.0 // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect + github.com/extism/go-sdk v1.7.1 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect resenje.org/singleflight v0.4.3 // indirect ) diff --git a/go.sum b/go.sum index 5b2409ad..ccff827c 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/1password/onepassword-sdk-go v0.4.1 h1:My/Q2QXemep0I0qHgGrOs7EEzpPh2QZ1/II+S3YqOG0= +github.com/1password/onepassword-sdk-go v0.4.1/go.mod h1:j/CbzhucTywjlYrd6SE6k0LcQaFZ2l8OLBsAsOYtvD0= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= @@ -229,6 +231,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 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/btnguyen2k/consu/checksum v1.1.1 h1:kdIJGk3yl83Nn1HxZRk3bXJM0xvlwTcTYUmZ8BiloPU= @@ -366,6 +370,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= +github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -394,6 +400,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exaring/otelpgx v0.9.3 h1:4yO02tXC7ZJZ+hcqcUkfxblYNCIFGVhpUWI0iw1TzPU= github.com/exaring/otelpgx v0.9.3/go.mod h1:R5/M5LWsPPBZc1SrRE5e0DiU48bI78C1/GPTWs6I66U= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= 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= @@ -478,6 +486,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -593,6 +603,8 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/go-syslog/v3 v3.0.0 h1:jichmjSZlYK0VMmlz+k4WeOQd7z745YLsvGMqwtYt4I= @@ -806,6 +818,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240917153116-6f2963f01587/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -919,6 +933,10 @@ github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5 github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/cmd/operator/main.go b/internal/cmd/operator/main.go index 7dd52907..d34192bd 100644 --- a/internal/cmd/operator/main.go +++ b/internal/cmd/operator/main.go @@ -167,7 +167,9 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/platform/identity/authkind/loader" "github.com/authzed/openagentprimitives/pkg/platform/identity/broker/inproc" "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/githubapp" - _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated/githubApp credkind.Kinds the broker dispatches to via registry.Get + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated/githubApp credkind.Kinds the broker dispatches to via registry.Get + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" // register the "onepassword" directcred.Source the AgentIdentity controller's vault-pin Verify dispatches to + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" // register the "secret" directcred.Source (zero-vault; shape-only Verify) "github.com/authzed/openagentprimitives/pkg/platform/identity/federation/idjag" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/idp/googlekind" // register "google" idp.Kind: ClusterIdentityProvider validity controller dispatches via registry.Get _ "github.com/authzed/openagentprimitives/pkg/platform/identity/idp/oidckind" // register "oidc" idp.Kind diff --git a/pkg/apis/v1alpha1/agentidentity_types.go b/pkg/apis/v1alpha1/agentidentity_types.go index 7b6cd6bd..4061dfaa 100644 --- a/pkg/apis/v1alpha1/agentidentity_types.go +++ b/pkg/apis/v1alpha1/agentidentity_types.go @@ -61,7 +61,7 @@ type AgentCredential struct { Name string `json:"name"` // Type discriminates which of the blocks below is populated. - // +kubebuilder:validation:Enum=static;oauth;federated;githubApp + // +kubebuilder:validation:Enum=static;oauth;federated;githubApp;directCredentials Type string `json:"type"` // Static is populated when type=static. @@ -86,6 +86,15 @@ type AgentCredential struct { // +optional GitHubApp *GitHubAppCredentialSource `json:"githubApp,omitempty"` + // DirectCredentials is populated when type=directCredentials: a website + // login the browser toolbox fills at use time. It never flows through the + // broker — Resolve errors unconditionally — so it never projects into an + // env var or header the way every other block here does. Valid ONLY on + // AgentIdentity: the credential is authored on the bot's own identity, + // not passed through from a human. + // +optional + DirectCredentials *DirectCredentialsSource `json:"directCredentials,omitempty"` + // AllowedHosts scopes where this credential may be sent, as host patterns // ("github.com", "*.internal.example"). Matching is on the URL authority // (host and port), case-insensitively; a leading "*." matches one or more @@ -205,6 +214,23 @@ type AgentIdentityStatus struct { // +optional ObservedCredentials []ObservedCredential `json:"observedCredentials,omitempty"` + // PinnedVaults records, for each type=directCredentials credential whose + // source has a vault concept (onepassword), the vault its backing token was + // PROVEN to resolve to. The validity reconciler calls the directcred source's + // Verify on first sight and records the vault id + name here; it re-verifies + // only when the token changes (TokenFingerprint differs), so a rotated token + // that now resolves to a DIFFERENT vault surfaces loudly as + // Valid=False/DirectCredentialInvalid instead of silently listing another + // vault's logins at the browser toolbox. A source with no vault concept + // (secret) is never recorded here. + // + // Operator-owned and derived: sorted by credential name and set once per + // (credential, token) pair, so an unchanged token re-writes nothing and the + // MergeFrom patch is a no-op. TokenFingerprint is a sha256 digest of the + // token value, never the value itself. + // +optional + PinnedVaults []PinnedVault `json:"pinnedVaults,omitempty"` + // Conditions carries Valid, Refresh and PlatformLinked. // +patchStrategy=merge // +patchMergeKey=type @@ -234,6 +260,30 @@ type ObservedCredential struct { SecretNames []string `json:"secretNames,omitempty"` } +// PinnedVault is one entry of an identity's status.pinnedVaults — the vault a +// type=directCredentials credential's token was proven to resolve to, pinned +// once per token so a later rotation that redirects to a different vault is +// caught rather than silently followed. Never carries the token value, only a +// digest of it. +type PinnedVault struct { + // Credential is the credential name from spec.credentials[].name. + Credential string `json:"credential"` + + // VaultID is the backend's stable identifier for the pinned vault, as + // returned by the source's Verify. + VaultID string `json:"vaultID"` + + // VaultName is the human-readable vault name the backend confirmed. May + // differ in case/whitespace from the credential's declared vault. + VaultName string `json:"vaultName"` + + // TokenFingerprint is a hex sha256 digest of the backing token value at the + // time the pin was written. It is the set-once key: a reconcile whose token + // still hashes to this value carries the pin forward without re-verifying, + // and a changed token forces a fresh Verify. It is a digest, never the token. + TokenFingerprint string `json:"tokenFingerprint"` +} + const ( AgentIdentityConditionValid = "Valid" AgentIdentityConditionRefresh = "Refresh" diff --git a/pkg/apis/v1alpha1/conditions.go b/pkg/apis/v1alpha1/conditions.go index 44258f5b..674d93d4 100644 --- a/pkg/apis/v1alpha1/conditions.go +++ b/pkg/apis/v1alpha1/conditions.go @@ -179,6 +179,15 @@ const ( // both the expected shape and the one the value actually has. ReasonCredentialShapeMismatch = "CredentialShapeMismatch" + // ReasonAgentIdentityDirectCredentialInvalid: a type=directCredentials + // credential's backing token could not be proven to resolve to its declared + // vault — the directcred source's Verify returned an error (zero or many + // accessible vaults, a vault-name mismatch, or an auth failure). Valid=False + // rather than a warning because the browser toolbox would otherwise fill + // logins from whatever vault the token now sees; the condition message + // carries the verify error (which names vaults/counts, never the token). + ReasonAgentIdentityDirectCredentialInvalid = "DirectCredentialInvalid" + // PlatformLinked reasons. ReasonPlatformLinkFailed is TRANSIENT (SpiceDB // unreachable, schema not composed yet) and keeps being retried. The two // Unrepresentable* reasons are PERMANENT: a Kubernetes name may contain diff --git a/pkg/apis/v1alpha1/credential_descriptor.go b/pkg/apis/v1alpha1/credential_descriptor.go index 07df8cb8..1b903c1a 100644 --- a/pkg/apis/v1alpha1/credential_descriptor.go +++ b/pkg/apis/v1alpha1/credential_descriptor.go @@ -17,7 +17,7 @@ type CredentialDescriptor struct { // fixed multi-key OAuth shape and gates on expires_at. type CredentialSource struct { // Type drives the read and expiry rules; see the type doc. - // +kubebuilder:validation:Enum=static;oauth;federated;githubApp + // +kubebuilder:validation:Enum=static;oauth;federated;githubApp;directCredentials Type string `json:"type"` // Namespace is the Secret's namespace; must equal the ToolCall's own, which // ToolCall.ValidateCredentialSourceNamespaces enforces. diff --git a/pkg/apis/v1alpha1/directcredentials_types.go b/pkg/apis/v1alpha1/directcredentials_types.go new file mode 100644 index 00000000..00dbe11a --- /dev/null +++ b/pkg/apis/v1alpha1/directcredentials_types.go @@ -0,0 +1,31 @@ +package v1alpha1 + +// DirectCredentialsSource declares a website login the browser toolbox fills +// at use time, never resolved into an injectable env var or header. +// +// Deliberately defined in its own file rather than inside agentidentity_types.go +// (where AgentCredential's union lives): its Source field carries its OWN +// +kubebuilder:validation:Enum marker (onepassword;secret), and +// pkg/platform/identity/credkind/enum_invariant_test.go's +// TestEveryCRDEnumValueHasARegisteredKind scans every Enum marker found +// anywhere in agentidentity_types.go and credential_descriptor.go, asserting +// each VALUE is a registered credkind.Kind's Type(). "onepassword" and +// "secret" are directcred.Source kinds (pkg/platform/identity/directcred), +// not credkind.Kind types, so landing this marker in either scanned file +// would fail that test. See pkg/platform/identity/credkind/directcredentials's +// doc comment for why Resolve never reads either arm. +type DirectCredentialsSource struct { + // Source names the login backend: onepassword resolves through a + // 1Password Connect token; secret reads a host-keyed login Secret + // written directly. + // +kubebuilder:validation:Enum=onepassword;secret + Source string `json:"source"` + + // SecretRef names the source's token Secret (key "token", onepassword) + // or the host-keyed login Secret (secret). + SecretRef SecretKeyRef `json:"secretRef"` + + // Vault is the 1Password vault NAME; required iff source is onepassword. + // +optional + Vault string `json:"vault,omitempty"` +} diff --git a/pkg/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/v1alpha1/zz_generated.deepcopy.go index 0f97055a..e4394923 100644 --- a/pkg/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/v1alpha1/zz_generated.deepcopy.go @@ -409,6 +409,11 @@ func (in *AgentCredential) DeepCopyInto(out *AgentCredential) { *out = new(GitHubAppCredentialSource) **out = **in } + if in.DirectCredentials != nil { + in, out := &in.DirectCredentials, &out.DirectCredentials + *out = new(DirectCredentialsSource) + **out = **in + } if in.AllowedHosts != nil { in, out := &in.AllowedHosts, &out.AllowedHosts *out = make([]string, len(*in)) @@ -535,6 +540,11 @@ func (in *AgentIdentityStatus) DeepCopyInto(out *AgentIdentityStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.PinnedVaults != nil { + in, out := &in.PinnedVaults, &out.PinnedVaults + *out = make([]PinnedVault, len(*in)) + copy(*out, *in) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -3008,6 +3018,22 @@ func (in *DefaultModel) DeepCopy() *DefaultModel { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DirectCredentialsSource) DeepCopyInto(out *DirectCredentialsSource) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DirectCredentialsSource. +func (in *DirectCredentialsSource) DeepCopy() *DirectCredentialsSource { + if in == nil { + return nil + } + out := new(DirectCredentialsSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EffectiveAuthz) DeepCopyInto(out *EffectiveAuthz) { *out = *in @@ -4516,6 +4542,21 @@ func (in *PinnedMessageStatus) DeepCopy() *PinnedMessageStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PinnedVault) DeepCopyInto(out *PinnedVault) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PinnedVault. +func (in *PinnedVault) DeepCopy() *PinnedVault { + if in == nil { + return nil + } + out := new(PinnedVault) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PinningBypass) DeepCopyInto(out *PinningBypass) { *out = *in diff --git a/pkg/controllers/agentidentity/controller.go b/pkg/controllers/agentidentity/controller.go index bcfd3e35..7604e9ce 100644 --- a/pkg/controllers/agentidentity/controller.go +++ b/pkg/controllers/agentidentity/controller.go @@ -13,8 +13,12 @@ package agentidentity import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "slices" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -35,6 +39,7 @@ import ( "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind" credkindregistry "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" "github.com/authzed/openagentprimitives/pkg/platform/identity/credresolve" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" "github.com/authzed/openagentprimitives/pkg/platform/identity/passthroughcatalog" "github.com/authzed/openagentprimitives/pkg/platform/identity/provider" "github.com/authzed/openagentprimitives/pkg/tools/adoptkit" @@ -243,10 +248,19 @@ func (r *Reconciler) reconcileStatus(ctx context.Context, a *spiceboxv1alpha1.Ag // Step 3: resolve every credentials[*].secretRef. Existence + key presence // only — never read or store the value bytes. Each Secret is adopted before // being read so the adoptguard SecretReader permits the access. + // + // A type=directCredentials/onepassword credential additionally has its + // backing token VERIFIED against the declared vault here (set once per + // token); priorPins threads the last-committed pins in so an unchanged token + // carries its pin forward without re-hitting the backend. See + // checkCredentialSecret / verifyDirectCredential. ownerRef := types.NamespacedName{Namespace: a.Namespace, Name: a.Name} resolved := make(map[string]bool, len(a.Spec.Credentials)) + priorPins := indexPinnedVaults(prior.Status.PinnedVaults) + var pins []spiceboxv1alpha1.PinnedVault for _, c := range a.Spec.Credentials { - if err := r.checkCredentialSecret(ctx, a.Namespace, ownerRef, c); err != nil { + pin, err := r.checkCredentialSecret(ctx, a.Namespace, ownerRef, c, priorPins[c.Name]) + if err != nil { switch { case errors.Is(err, credresolve.ErrSecretMissing): return r.setInvalidAndRetry(ctx, a, prior, spiceboxv1alpha1.ReasonSecretMissing, @@ -259,11 +273,20 @@ func (r *Reconciler) reconcileStatus(ctx context.Context, a *spiceboxv1alpha1.Ag return r.setInvalid(ctx, a, prior, spiceboxv1alpha1.ReasonAgentCredentialExpired, fmt.Sprintf("credentials[%s]: %v", c.Name, err)) case errors.Is(err, errCredentialShapeMismatch): return r.setInvalid(ctx, a, prior, spiceboxv1alpha1.ReasonCredentialShapeMismatch, fmt.Sprintf("credentials[%s]: %v", c.Name, err)) + case errors.Is(err, errDirectCredentialInvalid): + // A verify failure leaves a.Status.PinnedVaults untouched (the + // local `pins` slice is only committed in step 4, which this + // early return never reaches), so an existing pin is PRESERVED + // rather than erased when a rotated token stops verifying. + return r.setInvalid(ctx, a, prior, spiceboxv1alpha1.ReasonAgentIdentityDirectCredentialInvalid, fmt.Sprintf("credentials[%s]: %v", c.Name, err)) default: return ctrl.Result{}, err // transient (API error) — requeue } } resolved[c.Name] = true + if pin != nil { + pins = append(pins, *pin) + } } // Step 4: compute status. @@ -274,6 +297,12 @@ func (r *Reconciler) reconcileStatus(ctx context.Context, a *spiceboxv1alpha1.Ag a.Status.ResolvedCredentials = append(a.Status.ResolvedCredentials, c.Name) } } + // Sorted by credential name so an unchanged pin set re-writes byte-identically + // (the DeepEqual below then drops the no-op patch). nil when no credential pins. + slices.SortFunc(pins, func(x, y spiceboxv1alpha1.PinnedVault) int { + return strings.Compare(x.Credential, y.Credential) + }) + a.Status.PinnedVaults = pins conditions.SetTrue(a, &a.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid, spiceboxv1alpha1.ReasonAllReferencesResolve) if equality.Semantic.DeepEqual(prior.Status, a.Status) { @@ -351,15 +380,25 @@ func (r *Reconciler) ensurePlatformLink(ctx context.Context, a *spiceboxv1alpha1 // checkCredentialSecret adopts the credential's referenced Secret and reads it // via SecretReader, then validates existence, key presence, and (for oauth) // expiry. Returns credresolve sentinel errors so callers can map to conditions. +// +// For a type=directCredentials credential it additionally proves, via the +// directcred source's Verify, which vault the token resolves to, returning the +// PinnedVault to record on status — set once per token (priorPin threads the +// last-committed pin in so an unchanged token skips the backend call). A nil +// pin means "nothing to pin" (not a directCredentials credential, or a source +// with no vault concept); a non-nil pin is appended to status.pinnedVaults by +// the caller. errDirectCredentialInvalid is returned when Verify fails — or +// when the named source is not even registered (fail closed; see +// verifyDirectCredential). func (r *Reconciler) checkCredentialSecret(ctx context.Context, ns string, ownerRef types.NamespacedName, - cred spiceboxv1alpha1.AgentCredential) error { + cred spiceboxv1alpha1.AgentCredential, priorPin *spiceboxv1alpha1.PinnedVault) (*spiceboxv1alpha1.PinnedVault, error) { k, err := credkindregistry.Get(cred.Type) if err != nil { - return err + return nil, err } ref := k.SecretRef(cred) if ref == nil { - return nil // nothing stored; nothing to adopt or key-check + return nil, nil // nothing stored; nothing to adopt or key-check } secretName := ref.Name @@ -372,16 +411,16 @@ func (r *Reconciler) checkCredentialSecret(ctx context.Context, ns string, owner // pre-check produced — without silently minting an empty Secret via the SSA apply. if err := adoptkit.AdoptSecret(ctx, r.SecretReader.Reader, r.Client, secretRef, ownerRef, "AgentIdentity"); err != nil { if apierrors.IsNotFound(err) { - return fmt.Errorf("%w: %s/%s", credresolve.ErrSecretMissing, ns, secretName) + return nil, fmt.Errorf("%w: %s/%s", credresolve.ErrSecretMissing, ns, secretName) } - return fmt.Errorf("%w: %s/%s (adopt: %v)", credresolve.ErrSecretMissing, ns, secretName, err) + return nil, fmt.Errorf("%w: %s/%s (adopt: %v)", credresolve.ErrSecretMissing, ns, secretName, err) } sec, err := r.SecretReader.Get(ctx, secretRef) if err != nil { if apierrors.IsNotFound(err) { - return fmt.Errorf("%w: %s/%s", credresolve.ErrSecretMissing, ns, secretName) + return nil, fmt.Errorf("%w: %s/%s", credresolve.ErrSecretMissing, ns, secretName) } - return err + return nil, err } // Expiry is a fact about whether a STORED value is still usable, not @@ -392,7 +431,7 @@ func (r *Reconciler) checkCredentialSecret(ctx context.Context, ns string, owner if exp, ok := sec.Data["expires_at"]; ok && len(exp) > 0 { t, perr := time.Parse(time.RFC3339, string(exp)) if perr == nil && !t.After(time.Now()) { - return fmt.Errorf("%w: %s/%s", credresolve.ErrExpired, ns, secretName) + return nil, fmt.Errorf("%w: %s/%s", credresolve.ErrExpired, ns, secretName) } } } @@ -416,14 +455,124 @@ func (r *Reconciler) checkCredentialSecret(ctx context.Context, ns string, owner for _, key := range k.RequiredSecretKeys(cred) { val, ok := sec.Data[key] if !ok { - return keyErr(credresolve.ErrSecretKeyMissing, key) + return nil, keyErr(credresolve.ErrSecretKeyMissing, key) } if len(val) == 0 { - return keyErr(credresolve.ErrSecretValueEmpty, key) + return nil, keyErr(credresolve.ErrSecretValueEmpty, key) } } - return r.checkCredentialShape(ctx, ns, k, cred) + if err := r.checkCredentialShape(ctx, ns, k, cred); err != nil { + return nil, err + } + // Last, because it is the only step that reaches an external backend: prove + // the token's vault for a type=directCredentials credential (no-op for every + // other type). ref carries the Secret key holding the token value. + return r.verifyDirectCredential(ctx, cred, ref, sec.Data, priorPin) +} + +// verifyDirectCredential proves, for a type=directCredentials credential, which +// vault its backing token resolves to, returning the PinnedVault to record. +// +// - A non-directCredentials credential returns (nil, nil): nothing to verify. +// - The pin is SET ONCE per token: an unchanged TokenFingerprint carries +// priorPin forward WITHOUT calling the backend, so a steady-state reconcile +// makes no external call and writes nothing new. +// - On a changed/first token it calls the source's Verify. A verify error maps +// to errDirectCredentialInvalid (its text names vaults/counts, never the +// token — every directcred source scrubs it). A source with no vault concept +// (secret) returns a zero Pin and pins nothing. +// - An UNREGISTERED source FAILS CLOSED, mirroring credkindregistry.Get: the +// identity must not report Valid=True while the vault-pin gate silently +// cannot run — the pin is the whole defense against a rotated token +// quietly resolving to a different vault, and "healthy but unguarded" is +// the exact failure it exists to prevent. The CRD enum admits only known +// source names, so this branch is reachable only in the +// enum-updated-without-blank-import window (the same window +// TestEveryCRDEnumValueHasARegisteredKind guards for credkinds; the +// directcred twin lives in pkg/platform/identity/directcred's +// enum_invariant_test.go). +func (r *Reconciler) verifyDirectCredential(ctx context.Context, cred spiceboxv1alpha1.AgentCredential, + ref *credkind.SecretRef, secretData map[string][]byte, priorPin *spiceboxv1alpha1.PinnedVault) (*spiceboxv1alpha1.PinnedVault, error) { + dc := cred.DirectCredentials + if dc == nil { + return nil, nil + } + src, ok := directcred.Get(dc.Source) + if !ok { + return nil, fmt.Errorf( + "%w: no directcred source registered for source kind %q (registered: %s); the binary may be missing its blank import of the source's package under pkg/platform/identity/directcred/", + errDirectCredentialInvalid, dc.Source, registeredDirectCredKinds()) + } + + // ref.Key names the Secret key holding the token the source authenticates + // with ("token" for onepassword; "" for secret, which has no single token + // and never pins). The fingerprint is a digest, never the value. + fprint := tokenFingerprint(secretData[ref.Key]) + if priorPin != nil && priorPin.TokenFingerprint == fprint { + carried := *priorPin + return &carried, nil + } + + pin, err := src.Verify(ctx, directcred.Config{SecretData: secretData, Vault: dc.Vault}) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDirectCredentialInvalid, err) + } + if pin.VaultID == "" { + return nil, nil // source has no vault concept (secret): nothing to pin + } + return &spiceboxv1alpha1.PinnedVault{ + Credential: cred.Name, + VaultID: pin.VaultID, + VaultName: pin.VaultName, + TokenFingerprint: fprint, + }, nil +} + +// errDirectCredentialInvalid marks a type=directCredentials credential whose +// token could not be proven to resolve to its declared vault. Package-local: it +// carries the verdict from verifyDirectCredential up to the one switch that maps +// it onto ReasonAgentIdentityDirectCredentialInvalid. +var errDirectCredentialInvalid = errors.New("direct credential vault verification failed") + +// registeredDirectCredKinds names every registered directcred source kind for +// the fail-closed error above — the same "(registered: ...)" style +// credkindregistry.Get uses, so the two registries' wiring failures read alike. +func registeredDirectCredKinds() string { + all := directcred.All() + if len(all) == 0 { + return "none" + } + kinds := make([]string, len(all)) + for i, s := range all { + kinds[i] = s.Kind() + } + return strings.Join(kinds, ", ") +} + +// tokenFingerprint is a hex sha256 digest of a token value — the set-once key +// for a vault pin. Same three-line shape (and reasoning) as the per-kind +// tokenFingerprint helpers under pkg/channels/channelkinds/*: a small local +// helper over a speculative shared extraction. Never stores or compares the +// token itself. +func tokenFingerprint(tok []byte) string { + sum := sha256.Sum256(tok) + return hex.EncodeToString(sum[:]) +} + +// indexPinnedVaults keys the committed pins by credential name so a reconcile +// can find the prior pin for a credential in O(1). The pointers alias the +// caller's slice (the pre-mutation snapshot), so callers must treat them as +// read-only. +func indexPinnedVaults(pins []spiceboxv1alpha1.PinnedVault) map[string]*spiceboxv1alpha1.PinnedVault { + if len(pins) == 0 { + return nil + } + m := make(map[string]*spiceboxv1alpha1.PinnedVault, len(pins)) + for i := range pins { + m[pins[i].Credential] = &pins[i] + } + return m } // errCredentialShapeMismatch marks a credential whose stored value does not diff --git a/pkg/controllers/agentidentity/direct_credential_test.go b/pkg/controllers/agentidentity/direct_credential_test.go new file mode 100644 index 00000000..32466e65 --- /dev/null +++ b/pkg/controllers/agentidentity/direct_credential_test.go @@ -0,0 +1,363 @@ +//go:build integration + +// Vault-pinning behavior for type=directCredentials credentials (Phase B +// Task 5). The validity reconciler proves, via the directcred source's Verify, +// which vault a credential's token resolves to; it records that vault on +// status.pinnedVaults, set once per token. A later rotation re-verifies: a +// token that still resolves to the same vault updates the pin, one that +// resolves to a different vault (or fails to verify) turns the identity +// Valid=False/DirectCredentialInvalid rather than silently following the new +// vault. +// +// Testing seam (see the fake below): the real onepassword source registers +// itself only via the operator's blank imports, which a test binary never +// links — so this package's test binary registers a fakeOnePasswordSource +// under the SAME kind string with no collision. Each test asserts that fact as +// a precondition: if the real source were somehow linked, the Register in +// init() would have panicked and directcred.Get would return the real one. +package agentidentity_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz/adoptguard" + "github.com/authzed/openagentprimitives/pkg/controllers/agentidentity" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + + // Register the directCredentials credkind so validateSpecShape's + // credkindregistry.ValidateCredential dispatch resolves in this test binary. + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" +) + +// fakeOnePasswordSource stands in for the real 1Password source under the same +// kind string. Its Verify is driven per-test by verifyFn and counts calls, so a +// test can prove both the returned pin and that Verify was (or was NOT) hit. +type fakeOnePasswordSource struct { + mu sync.Mutex + calls int + verifyFn func(cfg directcred.Config) (directcred.Pin, error) +} + +var fakeOP = &fakeOnePasswordSource{} + +func init() { directcred.Register(fakeOP) } + +func (f *fakeOnePasswordSource) Kind() string { return directcred.SourceOnePassword } + +func (f *fakeOnePasswordSource) Verify(_ context.Context, cfg directcred.Config) (directcred.Pin, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.verifyFn == nil { + return directcred.Pin{}, errors.New("fakeOnePasswordSource: no verifyFn configured") + } + return f.verifyFn(cfg) +} + +// List/Fetch are unused by Task 5 (vault pinning is Verify-only). +func (f *fakeOnePasswordSource) List(context.Context, directcred.Config) ([]directcred.Ref, error) { + return nil, errors.New("fakeOnePasswordSource: List not exercised by Task 5") +} + +func (f *fakeOnePasswordSource) Fetch(context.Context, directcred.Config, string) (directcred.Credential, error) { + return directcred.Credential{}, errors.New("fakeOnePasswordSource: Fetch not exercised by Task 5") +} + +// configureFake resets the call counter and installs the per-test Verify +// behavior, and asserts the fake (not the real source) is what is registered. +func configureFake(t *testing.T, fn func(cfg directcred.Config) (directcred.Pin, error)) { + t.Helper() + src, ok := directcred.Get(directcred.SourceOnePassword) + require.True(t, ok, "a %q source must be registered", directcred.SourceOnePassword) + _, isFake := src.(*fakeOnePasswordSource) + require.True(t, isFake, + "the REAL onepassword source must not be linked into this test binary; got %T", src) + + fakeOP.mu.Lock() + defer fakeOP.mu.Unlock() + fakeOP.calls = 0 + fakeOP.verifyFn = fn +} + +func fakeVerifyCalls() int { + fakeOP.mu.Lock() + defer fakeOP.mu.Unlock() + return fakeOP.calls +} + +func fp(tok string) string { + sum := sha256.Sum256([]byte(tok)) + return hex.EncodeToString(sum[:]) +} + +// directCredAI builds an AgentIdentity carrying one onepassword +// directCredentials credential pointing at tokenSecret, scoped to vault. +func directCredAI(name, tokenSecret, vault string) *spiceboxv1alpha1.AgentIdentity { + return &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "logins", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: directcred.SourceOnePassword, + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: tokenSecret, Key: "token"}, + Vault: vault, + }, + }}, + }, + } +} + +func directCredReconciler(env *testenv.Env) (*agentidentity.Reconciler, context.Context) { + sr := adoptguard.NewSecretReader(env.Client, env.Client, adoptguard.Warn, func(types.NamespacedName) bool { return false }) + return &agentidentity.Reconciler{Client: env.Client, APIReader: env.Client, SecretReader: sr}, context.Background() +} + +func validCond(t *testing.T, env *testenv.Env, name string) *metav1.Condition { + t.Helper() + var got spiceboxv1alpha1.AgentIdentity + require.NoError(t, env.Client.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: name}, &got)) + return meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid) +} + +// TestDirectCredential_VaultPinnedSetOnce: a directCredentials/onepassword +// credential whose token Secret resolves is Valid=True and records one +// PinnedVaults entry carrying the fake's vault id and the token fingerprint. A +// SECOND reconcile with the unchanged token must neither re-verify (call +// counter stays 1) nor re-patch the object (resourceVersion unchanged). +func TestDirectCredential_VaultPinnedSetOnce(t *testing.T) { + env := testenv.Shared(t) + r, ctx := directCredReconciler(env) + configureFake(t, func(directcred.Config) (directcred.Pin, error) { + return directcred.Pin{VaultID: "vault-abc", VaultName: "demo-vault"}, nil + }) + + const token = "ops-token-v1" + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "op-token-setonce", Namespace: "default"}, + Data: map[string][]byte{"token": []byte(token)}, + })) + require.NoError(t, env.Client.Create(ctx, directCredAI("id-setonce", "op-token-setonce", "demo-vault"))) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "id-setonce"}}) + require.NoError(t, err, "first reconcile") + + var got spiceboxv1alpha1.AgentIdentity + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "id-setonce"}, &got)) + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status, "a verified onepassword credential is Valid=True") + assert.Equal(t, spiceboxv1alpha1.ReasonAllReferencesResolve, cond.Reason) + + require.Len(t, got.Status.PinnedVaults, 1, "exactly one vault pinned") + assert.Equal(t, spiceboxv1alpha1.PinnedVault{ + Credential: "logins", VaultID: "vault-abc", VaultName: "demo-vault", TokenFingerprint: fp(token), + }, got.Status.PinnedVaults[0]) + assert.Equal(t, 1, fakeVerifyCalls(), "Verify called once on first sight") + + rv := got.ResourceVersion + + _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "id-setonce"}}) + require.NoError(t, err, "second reconcile") + + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "id-setonce"}, &got)) + assert.Equal(t, rv, got.ResourceVersion, + "set-once: an unchanged token must not re-patch status (DeepEqual no-op)") + assert.Equal(t, 1, fakeVerifyCalls(), + "set-once: a matching token fingerprint must NOT re-hit the backend") +} + +// TestDirectCredential_TokenRotation: after a pin is set, replacing the token +// Secret forces a re-verify. If the new token resolves to a different vault +// (Verify errors on the name mismatch) the identity goes +// Valid=False/DirectCredentialInvalid; if it still resolves to the same-named +// vault the pin updates to the new fingerprint and Valid stays True. +func TestDirectCredential_TokenRotation(t *testing.T) { + cases := []struct { + name string + rotatedFn func(cfg directcred.Config) (directcred.Pin, error) + wantStatus metav1.ConditionStatus + wantReason string + wantFP string // expected PinnedVaults[0].TokenFingerprint after rotation ("" ⇒ pin unchanged) + }{ + { + name: "rotated token resolves to a DIFFERENT vault: Valid=False/DirectCredentialInvalid, pin preserved", + rotatedFn: func(directcred.Config) (directcred.Pin, error) { + return directcred.Pin{}, errors.New("onepassword: the accessible vault \"other-vault\" does not match the configured vault \"demo-vault\"") + }, + wantStatus: metav1.ConditionFalse, + wantReason: spiceboxv1alpha1.ReasonAgentIdentityDirectCredentialInvalid, + wantFP: "", // the prior pin is kept on a verify failure + }, + { + name: "rotated token resolves to the SAME-named vault: pin updated, Valid stays True", + rotatedFn: func(directcred.Config) (directcred.Pin, error) { + return directcred.Pin{VaultID: "vault-abc", VaultName: "demo-vault"}, nil + }, + wantStatus: metav1.ConditionTrue, + wantReason: spiceboxv1alpha1.ReasonAllReferencesResolve, + wantFP: fp("ops-token-v2"), + }, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := testenv.Shared(t) + r, ctx := directCredReconciler(env) + idName := "id-rotate-" + string(rune('a'+i)) + secName := "op-token-rotate-" + string(rune('a'+i)) + + // First sight: pin vault-abc on the original token. + configureFake(t, func(directcred.Config) (directcred.Pin, error) { + return directcred.Pin{VaultID: "vault-abc", VaultName: "demo-vault"}, nil + }) + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secName, Namespace: "default"}, + Data: map[string][]byte{"token": []byte("ops-token-v1")}, + })) + require.NoError(t, env.Client.Create(ctx, directCredAI(idName, secName, "demo-vault"))) + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: idName}}) + require.NoError(t, err, "first reconcile") + require.Equal(t, fp("ops-token-v1"), pinFP(t, env, idName), "pin set on the original token") + + // Rotate the token and install the rotation outcome. + configureFake(t, tc.rotatedFn) + var sec corev1.Secret + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: secName}, &sec)) + sec.Data["token"] = []byte("ops-token-v2") + require.NoError(t, env.Client.Update(ctx, &sec)) + + _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: idName}}) + require.NoError(t, err, "reconcile after rotation") + assert.Positive(t, fakeVerifyCalls(), "a changed token fingerprint must re-verify") + + cond := validCond(t, env, idName) + require.NotNil(t, cond) + assert.Equal(t, tc.wantStatus, cond.Status) + assert.Equal(t, tc.wantReason, cond.Reason) + + if tc.wantFP == "" { + assert.Equal(t, fp("ops-token-v1"), pinFP(t, env, idName), + "a failed re-verify must preserve the prior pin, not erase it") + } else { + assert.Equal(t, tc.wantFP, pinFP(t, env, idName), "a successful re-verify updates the pin's fingerprint") + } + }) + } +} + +// pinFP returns the TokenFingerprint of the identity's single pinned vault, or +// "" when none is recorded. +func pinFP(t *testing.T, env *testenv.Env, name string) string { + t.Helper() + var got spiceboxv1alpha1.AgentIdentity + require.NoError(t, env.Client.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: name}, &got)) + if len(got.Status.PinnedVaults) == 0 { + return "" + } + return got.Status.PinnedVaults[0].TokenFingerprint +} + +// TestDirectCredential_UnregisteredSourceFailsClosed: a directCredentials +// credential whose source kind has NO registered directcred.Source must turn +// the identity Valid=False/DirectCredentialInvalid — never Valid=True with the +// vault-pin gate silently skipped. The CRD enum admits only known source +// names, so the live window for this is a binary missing its blank import +// (the gap pkg/platform/identity/directcred's enum_invariant_test.go guards); +// this test constructs that window honestly: THIS test binary registers only +// the fake "onepassword" (init above) and links neither real source package, +// so "secret" — a perfectly enum-valid source kind — is genuinely unregistered +// here, exercising exactly the branch an un-imported operator would hit. +func TestDirectCredential_UnregisteredSourceFailsClosed(t *testing.T) { + env := testenv.Shared(t) + r, ctx := directCredReconciler(env) + + // Precondition: "secret" must be unregistered in this binary, or this test + // would silently exercise the registered path instead of the fail-closed one. + _, registered := directcred.Get(directcred.SourceSecret) + require.False(t, registered, + "the real secretsource package must not be linked into this test binary; it is, so this test no longer exercises the unregistered branch") + + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "login-secret-unreg", Namespace: "default"}, + Data: map[string][]byte{ + "a.example.com.username": []byte("demo-user"), + "a.example.com.password": []byte("demo-pass"), + }, + })) + ai := &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: "id-unreg-source", Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "logins-secret", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: directcred.SourceSecret, // enum-valid, unregistered HERE + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "login-secret-unreg"}, + }, + }}, + }, + } + require.NoError(t, env.Client.Create(ctx, ai)) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "id-unreg-source"}}) + require.NoError(t, err, "reconcile (an unregistered source is a condition, not a transient error)") + + var got spiceboxv1alpha1.AgentIdentity + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "id-unreg-source"}, &got)) + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status, + "an identity must not report healthy while the vault-pin gate cannot run") + assert.Equal(t, spiceboxv1alpha1.ReasonAgentIdentityDirectCredentialInvalid, cond.Reason) + assert.Contains(t, cond.Message, "credentials[logins-secret]", "the message names the credential") + assert.Contains(t, cond.Message, `"secret"`, "the message names the unregistered source kind") + assert.Contains(t, cond.Message, "blank import", "the message points at the likely missing wiring") + assert.Empty(t, got.Status.PinnedVaults, "nothing may be pinned by an unverifiable credential") +} + +// TestDirectCredential_VerifyError: a Verify failure on first sight turns the +// identity Valid=False/DirectCredentialInvalid, carries the verify error's text +// in the condition message, and NEVER leaks the token bytes. +func TestDirectCredential_VerifyError(t *testing.T) { + env := testenv.Shared(t) + r, ctx := directCredReconciler(env) + + const token = "ops-token-secret-bytes" + const verifyErr = "onepassword: expected exactly one accessible vault, found 2: [vault-a vault-b]" + configureFake(t, func(directcred.Config) (directcred.Pin, error) { + return directcred.Pin{}, errors.New(verifyErr) + }) + + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "op-token-err", Namespace: "default"}, + Data: map[string][]byte{"token": []byte(token)}, + })) + require.NoError(t, env.Client.Create(ctx, directCredAI("id-verifyerr", "op-token-err", "demo-vault"))) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "id-verifyerr"}}) + require.NoError(t, err, "reconcile (a verify failure is a condition, not a transient error)") + + var got spiceboxv1alpha1.AgentIdentity + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "id-verifyerr"}, &got)) + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid) + require.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, spiceboxv1alpha1.ReasonAgentIdentityDirectCredentialInvalid, cond.Reason) + assert.Contains(t, cond.Message, verifyErr, "the condition must carry the verify error text") + assert.NotContains(t, cond.Message, token, "the token bytes must NEVER appear in a condition message") + assert.Empty(t, got.Status.PinnedVaults, "a failed first-sight verify pins nothing") +} diff --git a/pkg/platform/identity/credkind/directcredentials/kind.go b/pkg/platform/identity/credkind/directcredentials/kind.go new file mode 100644 index 00000000..a1975c51 --- /dev/null +++ b/pkg/platform/identity/credkind/directcredentials/kind.go @@ -0,0 +1,177 @@ +// This file implements the credkind.Kind for type=directCredentials: a +// website login the browser toolbox's form filler fetches at the moment it +// fills a page, never resolved into an injectable env var or HTTP header. +// +// Every other credkind.Kind ends at Resolve producing a value the broker +// hands to a tool call or an MCP server. This one is the deliberate +// exception: Resolve errors unconditionally, because a directCredentials +// value must never reach the broker's cache, a tool's env/header, or a log +// line — only the browser toolbox's filler, reading directly from the +// backend Source names, at the instant it types the value into a page. This +// package is the CRD-anchoring half only: it validates the spec and points +// at the backing Secret. The fetch itself belongs to +// pkg/platform/identity/directcred, called from the filler — never from +// here. +package directcredentials + +import ( + "context" + "fmt" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/platform/identity/authkind" + "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind" + "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/sensitive" +) + +func init() { registry.Register(Kind{}) } + +// Kind is the type=directCredentials driver. +type Kind struct{} + +var _ credkind.Kind = Kind{} + +func (Kind) Type() string { return "directCredentials" } +func (Kind) Minted() bool { return true } +func (Kind) NeedsRefresh() bool { return false } +func (Kind) DisplayName() string { return "Direct credentials (website logins)" } + +// Projectable is false: a directCredentials value never becomes a stored +// value at all, so there is nothing to project into a per-session Secret +// keyed by credential name. See Resolve. +func (Kind) Projectable() bool { return false } + +// ValidOn is AgentIdentity only: the login is authored directly on the bot's +// own identity, not passed through from a human subject the way a +// UserIdentity/SessionUserIdentity credential is. +func (Kind) ValidOn() []credkind.Scope { + return []credkind.Scope{credkind.ScopeAgentIdentity} +} + +// ValidateSpec checks the directCredentials block's own shape: Source must +// be a recognized backend, SecretRef.Name must be set, and Vault is required +// for source=onepassword and forbidden for source=secret. SecretRef.Key is +// similarly backend-shaped: "" or "token" for onepassword (its Connect token +// lives under one named key) and "" only for secret (its login Secret is a +// multi-key shape with no single named value key). +func (Kind) ValidateSpec(c spiceboxv1alpha1.AgentCredential) error { + if c.DirectCredentials == nil { + return fmt.Errorf("credentials[%s].directCredentials: block required for type=directCredentials", c.Name) + } + dc := c.DirectCredentials + + // Like every sibling ValidateSpec, the messages below name the offending + // FIELD (and the credential), never the field's VALUE: they are surfaced + // verbatim on a SpecInvalid condition, and the convention is uniform + // across kinds. + switch dc.Source { + case directcred.SourceOnePassword, directcred.SourceSecret: + default: + return fmt.Errorf("credentials[%s].directCredentials: source must be %q or %q", + c.Name, directcred.SourceOnePassword, directcred.SourceSecret) + } + + if dc.SecretRef.Name == "" { + return fmt.Errorf("credentials[%s].directCredentials: secretRef.name required", c.Name) + } + + switch dc.Source { + case directcred.SourceOnePassword: + if dc.Vault == "" { + return fmt.Errorf("credentials[%s].directCredentials: vault required when source=%s", + c.Name, directcred.SourceOnePassword) + } + if dc.SecretRef.Key != "" && dc.SecretRef.Key != "token" { + return fmt.Errorf("credentials[%s].directCredentials: secretRef.key must be \"token\" (or empty) when source=%s", + c.Name, directcred.SourceOnePassword) + } + case directcred.SourceSecret: + if dc.Vault != "" { + return fmt.Errorf("credentials[%s].directCredentials: vault must be empty when source=%s", + c.Name, directcred.SourceSecret) + } + if dc.SecretRef.Key != "" { + return fmt.Errorf("credentials[%s].directCredentials: secretRef.key must be empty when source=%s", + c.Name, directcred.SourceSecret) + } + } + return nil +} + +// HasBlock reports whether the directCredentials block is populated. Sibling +// exclusivity is registry.ValidateExclusive's job off this answer, not a +// list of the other kinds' blocks written out here — see +// credkind.Kind.HasBlock. +func (Kind) HasBlock(c spiceboxv1alpha1.AgentCredential) bool { return c.DirectCredentials != nil } + +// SecretRef returns the backend's backing Secret. onepassword's Connect +// token lives under one named key ("token"); secret's login Secret is a +// multi-key shape, so its Key is empty — the same shape githubApp's own +// multi-key SecretRef reports. +func (Kind) SecretRef(c spiceboxv1alpha1.AgentCredential) *credkind.SecretRef { + if c.DirectCredentials == nil { + return nil + } + key := "" + if c.DirectCredentials.Source == directcred.SourceOnePassword { + key = "token" + } + return &credkind.SecretRef{Name: c.DirectCredentials.SecretRef.Name, Key: key} +} + +// SecretRefPath is where SecretRef above reads the Secret NAME from, spelled +// as a field path for callers rewriting an unstructured CR (`oap agent +// install --name ` relocating a bundled Secret). +func (Kind) SecretRefPath() []string { return []string{"directCredentials", "secretRef", "name"} } + +// RequiredSecretKeys is the Connect token key for onepassword; nil for +// secret, whose login Secret has no single fixed key the generic checker in +// pkg/controllers/agentidentity could validate — that host-keyed shape is +// meaningful only to the secret backend's own Fetch. +func (Kind) RequiredSecretKeys(c spiceboxv1alpha1.AgentCredential) []string { + if c.DirectCredentials == nil { + return nil + } + if c.DirectCredentials.Source == directcred.SourceOnePassword { + return []string{"token"} + } + return nil +} + +// PublicSecretKeys is nil, unconditionally: fail closed, every key is +// secret. Unlike a GitHub App's app-id/installation-id, nothing in a website +// login's backing Secret is an identifier published independently of the +// credential itself. +func (Kind) PublicSecretKeys(spiceboxv1alpha1.AgentCredential) []string { return nil } + +// BuildCredential always errors: a directCredentials credential is authored +// directly on the identity — its Source/SecretRef/Vault are the spec itself +// — not written by the `oap identity` setup flow the way a static token is. +// The setup flow's only role for this type is filling its token Secret, +// never constructing the block. +func (Kind) BuildCredential(name, _, _ string) (spiceboxv1alpha1.AgentCredential, error) { + return spiceboxv1alpha1.AgentCredential{}, fmt.Errorf( + "credential %q: type=directCredentials is authored on the identity directly; the setup flow only fills its token Secret", name) +} + +// Resolve always errors: a directCredentials value never becomes an +// injectable credential. It is fetched at use time, directly by the browser +// toolbox's form filler, and must never reach the broker's cache, a tool +// call's env var or header, or a log line. +func (Kind) Resolve(_ context.Context, _ credkind.Deps, _ spiceboxv1alpha1.CredentialSource) (authkind.ResolvedCredential, time.Time, error) { + return authkind.ResolvedCredential{}, time.Time{}, fmt.Errorf( + "directCredentials never project into env vars or headers; they are fetched at use time by the browser filler") +} + +// ReadStoredValue always errors: this type has no single stored value to +// read the way a static or oauth credential does — Resolve's own doc +// explains why nothing here ever becomes a readable value at all. +func (Kind) ReadStoredValue(_ context.Context, _ client.Reader, _ string, cred spiceboxv1alpha1.AgentCredential) (sensitive.SensitiveValue, error) { + return sensitive.SensitiveValue{}, fmt.Errorf( + "credential %q: type=directCredentials is fetched at use time by the browser filler and has no stored value to read", cred.Name) +} diff --git a/pkg/platform/identity/credkind/directcredentials/kind_test.go b/pkg/platform/identity/credkind/directcredentials/kind_test.go new file mode 100644 index 00000000..a1dda0cd --- /dev/null +++ b/pkg/platform/identity/credkind/directcredentials/kind_test.go @@ -0,0 +1,247 @@ +package directcredentials + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind" +) + +func onePasswordCred(name, secretName string) spiceboxv1alpha1.AgentCredential { + return spiceboxv1alpha1.AgentCredential{ + Name: name, Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "onepassword", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: secretName, Key: "token"}, + Vault: "Engineering", + }, + } +} + +func secretCred(name, secretName string) spiceboxv1alpha1.AgentCredential { + return spiceboxv1alpha1.AgentCredential{ + Name: name, Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: secretName}, + }, + } +} + +func TestKind_Facts(t *testing.T) { + k := Kind{} + assert.Equal(t, "directCredentials", k.Type()) + assert.True(t, k.Minted(), "a value is fetched fresh at use time, never cached by the broker") + assert.False(t, k.NeedsRefresh(), "nothing is stored, so nothing is refreshed") + assert.False(t, k.Projectable(), "the value never projects into a per-session Secret") + assert.Equal(t, "Direct credentials (website logins)", k.DisplayName()) +} + +// TestValidOn_AgentIdentityOnly: the login is authored on the bot's own +// identity, not passed through from a human subject. +func TestValidOn_AgentIdentityOnly(t *testing.T) { + assert.True(t, credkind.ValidOnScope(Kind{}, credkind.ScopeAgentIdentity)) + assert.False(t, credkind.ValidOnScope(Kind{}, credkind.ScopeUserIdentity)) + assert.False(t, credkind.ValidOnScope(Kind{}, credkind.ScopeSessionUserIdentity)) +} + +func TestHasBlock(t *testing.T) { + assert.False(t, Kind{}.HasBlock(spiceboxv1alpha1.AgentCredential{Type: "directCredentials"}), + "no block populated") + assert.True(t, Kind{}.HasBlock(onePasswordCred("demo-dc-cred", "demo-dc-secret"))) +} + +func TestSecretRefPath(t *testing.T) { + assert.Equal(t, []string{"directCredentials", "secretRef", "name"}, Kind{}.SecretRefPath()) +} + +// TestValidateSpec covers both source arms plus the rejection rows the brief +// calls out by name: missing vault with onepassword, vault set with secret, +// a bad key value for each arm, and a missing secretRef.name. +// +// wantNotContain pins the sibling-wide message convention: errors name the +// offending FIELD (and the credential), never the field's VALUE — the same +// shape static/oauth/federated/githubapp's ValidateSpec messages all have. +func TestValidateSpec(t *testing.T) { + cases := []struct { + name string + cred spiceboxv1alpha1.AgentCredential + wantErr string + wantNotContain string + }{ + { + name: "missing directCredentials block: rejected", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials"}, + wantErr: "block required for type=directCredentials", + }, + { + name: "bad source value: rejected, message does not echo the value", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "ldap", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret"}, + }}, + wantErr: "source must be", + wantNotContain: "ldap", + }, + { + name: "missing secretRef.name: rejected", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + }}, + wantErr: "secretRef.name required", + }, + { + name: "onepassword missing vault: rejected", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "onepassword", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret"}, + }}, + wantErr: "vault required when source=onepassword", + }, + { + name: "secret with vault set: rejected, message does not echo the value", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret"}, + Vault: "Engineering", + }}, + wantErr: "vault must be empty when source=secret", + wantNotContain: "Engineering", + }, + { + // The bad key is "credential-value", not "password": "password" is + // a substring of the message's legitimate "source=onepassword", so + // the no-echo assertion would false-positive on it. + name: "onepassword bad key: rejected, message does not echo the value", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "onepassword", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret", Key: "credential-value"}, + Vault: "Engineering", + }}, + wantErr: "secretRef.key must be", + wantNotContain: "credential-value", + }, + { + name: "secret with non-empty key: rejected, message does not echo the value", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret", Key: "token"}, + }}, + wantErr: "secretRef.key must be empty when source=secret", + wantNotContain: "token", + }, + { + name: "well-formed onepassword: accepted", + cred: onePasswordCred("demo-dc-cred", "demo-dc-secret"), + }, + { + name: "well-formed onepassword with empty key: accepted", + cred: spiceboxv1alpha1.AgentCredential{Name: "demo-dc-cred", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "onepassword", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "demo-dc-secret"}, + Vault: "Engineering", + }}, + }, + { + name: "well-formed secret: accepted", + cred: secretCred("demo-dc-cred", "demo-dc-secret"), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := Kind{}.ValidateSpec(tc.cred) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Contains(t, err.Error(), "demo-dc-cred", "the message must name the offending credential") + if tc.wantNotContain != "" { + assert.NotContains(t, err.Error(), tc.wantNotContain, + "the message must name the offending field, never the field's value") + } + }) + } +} + +func TestSecretRef(t *testing.T) { + assert.Nil(t, Kind{}.SecretRef(spiceboxv1alpha1.AgentCredential{Type: "directCredentials"}), + "a malformed credential has no Secret to point at; validation reports it, SecretRef does not guess") + + ref := Kind{}.SecretRef(onePasswordCred("demo-dc-cred", "demo-op-secret")) + require.NotNil(t, ref) + assert.Equal(t, "demo-op-secret", ref.Name) + assert.Equal(t, "token", ref.Key, "onepassword's Connect token lives under one named key") + + ref = Kind{}.SecretRef(secretCred("demo-dc-cred", "demo-login-secret")) + require.NotNil(t, ref) + assert.Equal(t, "demo-login-secret", ref.Name) + assert.Empty(t, ref.Key, "the secret backend's login Secret is a multi-key shape, not one named value") +} + +func TestRequiredSecretKeys(t *testing.T) { + assert.Nil(t, Kind{}.RequiredSecretKeys(spiceboxv1alpha1.AgentCredential{Type: "directCredentials"}), + "nil block must fail closed to no requirement, not a nil dereference") + assert.Equal(t, []string{"token"}, Kind{}.RequiredSecretKeys(onePasswordCred("demo-dc-cred", "demo-dc-secret"))) + assert.Nil(t, Kind{}.RequiredSecretKeys(secretCred("demo-dc-cred", "demo-dc-secret")), + "the secret backend's Secret shape is not a fixed single key") +} + +// TestPublicSecretKeys_FailsClosed pins the brief's answer directly: every +// key is secret, regardless of source arm or even a malformed/absent block. +func TestPublicSecretKeys_FailsClosed(t *testing.T) { + assert.Nil(t, Kind{}.PublicSecretKeys(onePasswordCred("demo-dc-cred", "demo-dc-secret")), + "every key is secret: there is no public identifier in a website login") + assert.Nil(t, Kind{}.PublicSecretKeys(secretCred("demo-dc-cred", "demo-dc-secret"))) + assert.Nil(t, Kind{}.PublicSecretKeys(spiceboxv1alpha1.AgentCredential{Type: "directCredentials"})) +} + +func TestBuildCredential_NotWritableBySetup(t *testing.T) { + _, err := Kind{}.BuildCredential("demo-dc-cred", "demo-dc-secret", "demo-dc-key") + require.Error(t, err, "the credential is authored on the identity, so the setup flow cannot write one") + assert.Contains(t, err.Error(), "demo-dc-cred", "the message must name the offending credential") +} + +func TestReadStoredValue_AlwaysErrors(t *testing.T) { + _, err := Kind{}.ReadStoredValue(context.Background(), nil, "default", onePasswordCred("demo-dc-cred", "demo-dc-secret")) + require.Error(t, err) + assert.Contains(t, err.Error(), "demo-dc-cred", "the message must name the offending credential") +} + +// TestResolve_AlwaysErrors_EvenOnPerfectInput pins the one invariant that +// matters most for this type: it never flows through the broker, no matter +// how well-formed the descriptor handed to Resolve is. +func TestResolve_AlwaysErrors_EvenOnPerfectInput(t *testing.T) { + _, _, err := Kind{}.Resolve(context.Background(), credkind.Deps{}, spiceboxv1alpha1.CredentialSource{ + Type: "directCredentials", Namespace: "default", Name: "demo-op-secret", Key: "token", + }) + require.Error(t, err) + assert.Equal(t, + "directCredentials never project into env vars or headers; they are fetched at use time by the browser filler", + err.Error()) +} + +// TestMintedKindsAreSubjectExclusiveAndStoreNothing locally pins the same +// facts the repo-wide registry sweep +// (pkg/platform/identity/credkind/enum_invariant_test.go) asserts: a minted +// kind serves one subject kind only and stores nothing refreshable. +func TestMintedKindsAreSubjectExclusiveAndStoreNothing(t *testing.T) { + k := Kind{} + assert.False(t, credkind.ValidOnScope(k, credkind.ScopeUserIdentity) && + credkind.ValidOnScope(k, credkind.ScopeAgentIdentity)) + assert.False(t, k.NeedsRefresh()) + _, err := k.BuildCredential("c", "s", "k") + assert.Error(t, err) +} diff --git a/pkg/platform/identity/credkind/guard_test.go b/pkg/platform/identity/credkind/guard_test.go index 86bc649c..71706742 100644 --- a/pkg/platform/identity/credkind/guard_test.go +++ b/pkg/platform/identity/credkind/guard_test.go @@ -22,6 +22,7 @@ import ( // remove. var credentialTypeLiterals = map[string]bool{ `"static"`: true, `"oauth"`: true, `"federated"`: true, `"githubApp"`: true, + `"directCredentials"`: true, } // falsePositiveSuffixes names specific files (matched by path suffix, not @@ -160,6 +161,7 @@ func TestNoCredentialTypeSwitchesOutsideCredkind(t *testing.T) { // literal-matching AST walk. This is Guard 6. var credentialDispatchFields = map[string]bool{ "Static": true, "OAuth": true, "Federated": true, "GitHubApp": true, + "DirectCredentials": true, } // guard6ExcludedSuffixes names files that legitimately test two or more diff --git a/pkg/platform/identity/credkind/imports/imports.go b/pkg/platform/identity/credkind/imports/imports.go index d05a98e7..66e2ff06 100644 --- a/pkg/platform/identity/credkind/imports/imports.go +++ b/pkg/platform/identity/credkind/imports/imports.go @@ -4,6 +4,7 @@ package imports import ( + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/directcredentials" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/federated" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/githubapp" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/oauth" diff --git a/pkg/platform/identity/directcred/directcred.go b/pkg/platform/identity/directcred/directcred.go new file mode 100644 index 00000000..270dd966 --- /dev/null +++ b/pkg/platform/identity/directcred/directcred.go @@ -0,0 +1,123 @@ +// Package directcred defines the pluggable "website login source" contract +// for the browser toolbox's direct-credential path: a Source lists and +// fetches logins held by some external vault (1Password, a plain +// Kubernetes Secret, …) so the browser toolbox can fill them into a page +// without the agent ever seeing — or the runner ever persisting — the +// values outside the moment of use. +// +// This package is pure contract + registry: no Kubernetes or client-go +// imports, no backend logic. The two backends (1Password, Secret) live in +// their own packages under pkg/platform/identity/directcred// and +// register themselves here via an init(), mirroring every other kind +// registry in this repo (pkg/tools/kinds/registry, authkind/registry, …). +// +// Security posture, load-bearing for every caller of this package: +// +// - Ref carries NOTHING from a login item's fields — only an opaque ID, a +// display label, and the hosts it matches. A List call must be safe to +// log and to show in a UI without exposing any secret. +// - Credential values are for IMMEDIATE USE ONLY (Phase C's form filler). +// They must never be logged, cached beyond the call that produced them, +// or written to any store — memory, status, or otherwise. +// - Config.SecretData is supplied by the CALLER. The operator/runner reads +// the backing Secret (via credresolve + the adopted-secret reader) and +// passes its data in; a Source implementation never touches the API +// server, and never holds a client.Client. +package directcred + +import "context" + +// Kind strings a Source registers under. Every later task (the two +// backends, the AgentIdentity/AgentSession controllers, the setup flow) +// matches against these constants rather than a literal string. +const ( + // SourceOnePassword is the kind string for the 1Password-backed Source. + SourceOnePassword = "onepassword" + // SourceSecret is the kind string for the plain-Secret-backed Source. + SourceSecret = "secret" +) + +// Config carries everything a Source needs for one call. The CALLER reads +// the referenced Secret (via credresolve + the adopted-secret reader) and +// the declared vault name/id from the CRD spec/status — a Source never +// touches the API server itself. +type Config struct { + // SecretData is the referenced Secret's data, verbatim. Its expected + // keys are backend-specific (e.g. a 1Password Connect token, or the + // username/password pair for the secret backend). + SecretData map[string][]byte + // Vault is the declared vault name. Onepassword only; ignored by the + // secret backend. + Vault string + // VaultID is the pinned vault id from a prior Verify call, or "" before + // the first Verify. Onepassword only; ignored by the secret backend. + VaultID string +} + +// Host is one hostname pattern a Ref is scoped to. +type Host struct { + // Name is the canonical form: lowercase, no trailing dot. + Name string + // Exact is true when Name must match the page host exactly; false when + // Name also matches subdomains. + Exact bool +} + +// Ref is one login a Source lists. It carries NOTHING from the underlying +// item's fields — no username, no password, no notes — only what is needed +// to show the entry in a picker and to later Fetch it. +type Ref struct { + // ID is opaque to everyone but the Source that issued it. Callers must + // not parse or infer structure from it. + ID string + // Label is the display name shown to a human picking a login. + Label string + // Hosts is the set of hostname patterns this login applies to. + Hosts []Host +} + +// Credential is one login's values, for immediate use by Phase C's form +// filler. A Credential must never be logged or persisted — not to memory, +// not to status, not to a log line — by any caller of Fetch. +type Credential struct { + // Username is the login's username or email value. + Username string + // Password is the login's password value. + Password string + // OTP, when non-nil, produces the login's current one-time code on + // demand. Nil when the entry has no one-time code configured. + OTP func() (string, error) +} + +// Pin is what Verify proves: which vault a configuration's credentials are +// scoped to. Sources with no vault concept (secret) return a zero Pin. +type Pin struct { + // VaultID is the backend's stable identifier for the vault. + VaultID string + // VaultName is the human-readable vault name, as confirmed by the + // backend — may differ in case/whitespace from the declared Vault. + VaultName string +} + +// Source is the per-backend plug-in surface. One impl per login-storage +// backend (1Password, a plain Secret, …), registered under its Kind() via +// Register. +type Source interface { + // Kind returns the registration key — one of the Source* constants + // above for the backends shipped in this repo. + Kind() string + + // Verify checks the configuration against the live backend and returns + // the vault it resolves to. For a Source with no vault concept (secret), + // Pin is zero and a non-nil error reports only malformed configuration + // (missing/invalid SecretData), never a vault mismatch. + Verify(ctx context.Context, cfg Config) (Pin, error) + + // List returns the credential references in scope for cfg. Never a + // value — see Ref's doc comment. + List(ctx context.Context, cfg Config) ([]Ref, error) + + // Fetch returns one credential's values, for immediate use. id is a Ref.ID + // previously returned by List for the same cfg. + Fetch(ctx context.Context, cfg Config, id string) (Credential, error) +} diff --git a/pkg/platform/identity/directcred/directcred_test.go b/pkg/platform/identity/directcred/directcred_test.go new file mode 100644 index 00000000..5ddd02e3 --- /dev/null +++ b/pkg/platform/identity/directcred/directcred_test.go @@ -0,0 +1,86 @@ +package directcred_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" +) + +// fakeSource is a minimal Source impl for registry tests. It records +// nothing and returns zero values — the registry tests exercise +// Register/Get/All wiring, not any backend behavior. +type fakeSource struct{ kind string } + +func (f fakeSource) Kind() string { return f.kind } + +func (f fakeSource) Verify(ctx context.Context, cfg directcred.Config) (directcred.Pin, error) { + return directcred.Pin{}, nil +} + +func (f fakeSource) List(ctx context.Context, cfg directcred.Config) ([]directcred.Ref, error) { + return nil, nil +} + +func (f fakeSource) Fetch(ctx context.Context, cfg directcred.Config, id string) (directcred.Credential, error) { + return directcred.Credential{}, nil +} + +func TestRegisterGetAll(t *testing.T) { + directcred.Reset() + t.Cleanup(directcred.Reset) + + // Register out of order; All must come back sorted by Kind(). + directcred.Register(fakeSource{kind: "secret"}) + directcred.Register(fakeSource{kind: "onepassword"}) + + got, ok := directcred.Get("onepassword") + require.True(t, ok, "Get(onepassword) must hit") + assert.Equal(t, "onepassword", got.Kind()) + + all := directcred.All() + require.Len(t, all, 2) + assert.Equal(t, []string{"onepassword", "secret"}, + []string{all[0].Kind(), all[1].Kind()}, "All must be sorted by Kind()") +} + +func TestGetUnknownReturnsFalse(t *testing.T) { + directcred.Reset() + t.Cleanup(directcred.Reset) + + directcred.Register(fakeSource{kind: "secret"}) + + _, ok := directcred.Get("nope") + assert.False(t, ok, "Get(nope) should miss") +} + +func TestRegisterDuplicatePanics(t *testing.T) { + directcred.Reset() + t.Cleanup(directcred.Reset) + + directcred.Register(fakeSource{kind: "secret"}) + assert.Panics(t, func() { + directcred.Register(fakeSource{kind: "secret"}) + }, "duplicate Kind() must panic") +} + +// TestConstantsMatchKind locks SourceOnePassword/SourceSecret to the kind +// strings later tasks (the two backends) register under. +func TestConstantsMatchKind(t *testing.T) { + cases := []struct { + name string + got string + want string + }{ + {"SourceOnePassword", directcred.SourceOnePassword, "onepassword"}, + {"SourceSecret", directcred.SourceSecret, "secret"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.got) + }) + } +} diff --git a/pkg/platform/identity/directcred/enum_invariant_test.go b/pkg/platform/identity/directcred/enum_invariant_test.go new file mode 100644 index 00000000..186acffe --- /dev/null +++ b/pkg/platform/identity/directcred/enum_invariant_test.go @@ -0,0 +1,108 @@ +//go:build !integration && !e2e + +package directcred_test + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + // Register the two real sources. Without these blank imports the registry + // is empty in THIS test binary regardless of what the operator imports — a + // test binary is its own process, and internal/cmd/operator's blank imports + // do not leak in. The snapshot below carries its own require.NotEmpty + // precondition so dropping either import fails loudly instead of returning + // both guards to a vacuous, always-green loop over zero sources. + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" +) + +// sourcesAtStartup snapshots the registered source kinds at package-var-init +// time — after the blank imports' init() registrations, BEFORE any test body +// runs. The sibling registry tests (directcred_test.go) Reset() the +// process-wide registry as part of their own fixtures, so reading +// directcred.All() inside a test body would make these guards order-dependent +// on which file's tests ran first; the snapshot is not. +var sourcesAtStartup = func() map[string]bool { + kinds := map[string]bool{} + for _, s := range directcred.All() { + kinds[s.Kind()] = true + } + return kinds +}() + +// enumMarkerRE finds a `+kubebuilder:validation:Enum=a;b;c` marker line and +// captures the semicolon-separated value list. Same shape as the credkind +// enum guard (pkg/platform/identity/credkind/enum_invariant_test.go), whose +// window this test mirrors for directcred sources. +var enumMarkerRE = regexp.MustCompile(`\+kubebuilder:validation:Enum=([A-Za-z0-9_;-]+)`) + +// directCredentialsTypesFile holds the DirectCredentialsSource.Source enum +// marker this guard reads. +const directCredentialsTypesFile = "../../../apis/v1alpha1/directcredentials_types.go" + +// enumValues reads the types file and returns the union of every Enum +// marker's values (today there is exactly one marker: Source's). +func enumValues(t *testing.T) map[string]bool { + t.Helper() + src, err := os.ReadFile(directCredentialsTypesFile) + require.NoError(t, err, "reading %s", directCredentialsTypesFile) + + values := map[string]bool{} + for _, m := range enumMarkerRE.FindAllStringSubmatch(string(src), -1) { + for _, v := range strings.Split(m[1], ";") { + values[v] = true + } + } + require.NotEmpty(t, values, "no +kubebuilder:validation:Enum marker found in %s; "+ + "this test's assumption about where the DirectCredentialsSource.Source enum lives is stale", + directCredentialsTypesFile) + return values +} + +// TestEveryEnumSourceValueHasARegisteredSource is the directcred twin of +// credkind's TestEveryCRDEnumValueHasARegisteredKind: it catches an Enum +// marker value with NO registered Source behind it. That gap matters because +// adding a backend is two steps — add the value to the Enum marker, register +// the Source — and this is exactly the window between them: the apiserver +// would accept a source the binary cannot drive, and the AgentIdentity +// reconciler would then fail CLOSED (Valid=False/DirectCredentialInvalid, +// see verifyDirectCredential) on every identity using it, far from the +// commit that opened the window. +func TestEveryEnumSourceValueHasARegisteredSource(t *testing.T) { + require.NotEmpty(t, sourcesAtStartup, + "registry snapshot is empty — this test file is missing its blank imports of "+ + "directcred/onepassword and directcred/secretsource, and every assertion below "+ + "would iterate nothing") + + for value := range enumValues(t) { + assert.True(t, sourcesAtStartup[value], + "the +kubebuilder:validation:Enum marker in %s accepts source=%q but no "+ + "directcred.Source is registered for it; the apiserver will accept it and the "+ + "AgentIdentity reconciler will fail closed (DirectCredentialInvalid) on every "+ + "identity that uses it until the source's package is written and blank-imported", + directCredentialsTypesFile, value) + } +} + +// TestEveryRegisteredSourceIsInTheEnum is the reverse direction: a Source +// registered in Go but absent from the static Enum marker would be rejected +// by the apiserver at apply time, far from its cause. +func TestEveryRegisteredSourceIsInTheEnum(t *testing.T) { + require.NotEmpty(t, sourcesAtStartup, + "registry snapshot is empty — this test file is missing its blank imports of "+ + "directcred/onepassword and directcred/secretsource, and every assertion below "+ + "would iterate nothing") + + values := enumValues(t) + for kind := range sourcesAtStartup { + assert.True(t, values[kind], + "directcred source %q is registered but absent from the Enum marker in %s; "+ + "add it and re-run `mage gen:api && mage manifests`", kind, directCredentialsTypesFile) + } +} diff --git a/pkg/platform/identity/directcred/onepassword/init.go b/pkg/platform/identity/directcred/onepassword/init.go new file mode 100644 index 00000000..e6af3da5 --- /dev/null +++ b/pkg/platform/identity/directcred/onepassword/init.go @@ -0,0 +1,5 @@ +package onepassword + +import "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + +func init() { directcred.Register(New()) } diff --git a/pkg/platform/identity/directcred/onepassword/live_test.go b/pkg/platform/identity/directcred/onepassword/live_test.go new file mode 100644 index 00000000..ed9dd437 --- /dev/null +++ b/pkg/platform/identity/directcred/onepassword/live_test.go @@ -0,0 +1,49 @@ +package onepassword_test + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" +) + +// TestLiveVerifyAndList exercises the real SDK (wasm core and all) against a +// live 1Password service account. It is gated on OP_SERVICE_ACCOUNT_TOKEN and +// OP_TEST_VAULT and skips with a reason otherwise, so it is excluded from the +// ship gate by construction -- the unit suite builds it but never runs its +// body without those env vars. +func TestLiveVerifyAndList(t *testing.T) { + token := os.Getenv("OP_SERVICE_ACCOUNT_TOKEN") + vaultName := os.Getenv("OP_TEST_VAULT") + if token == "" || vaultName == "" { + t.Skipf("set OP_SERVICE_ACCOUNT_TOKEN and OP_TEST_VAULT to run the live 1Password round-trip") + } + + src := onepassword.New() + ctx := context.Background() + cfg := directcred.Config{ + SecretData: map[string][]byte{"token": []byte(token)}, + Vault: vaultName, + } + + pin, err := src.Verify(ctx, cfg) + require.NoError(t, err, "Verify against the live vault") + assert.Equal(t, vaultName, pin.VaultName) + require.NotEmpty(t, pin.VaultID) + + cfg.VaultID = pin.VaultID + refs, err := src.List(ctx, cfg) + require.NoError(t, err, "List against the live vault") + for _, r := range refs { + assert.NotEmpty(t, r.ID, "every Ref must carry an id") + for _, h := range r.Hosts { + assert.NotEmpty(t, h.Name, "every host must be non-empty and canonical") + } + } + t.Logf("live vault %q (%s): %d login refs", pin.VaultName, pin.VaultID, len(refs)) +} diff --git a/pkg/platform/identity/directcred/onepassword/sdk.go b/pkg/platform/identity/directcred/onepassword/sdk.go new file mode 100644 index 00000000..ac9c53ce --- /dev/null +++ b/pkg/platform/identity/directcred/onepassword/sdk.go @@ -0,0 +1,124 @@ +package onepassword + +import ( + "context" + + opsdk "github.com/1password/onepassword-sdk-go" + + "github.com/authzed/openagentprimitives/pkg/platform/apimage" +) + +// integrationName is the name reported to 1Password via WithIntegrationInfo. +// Paired with apimage.Version so a vault's access log identifies this project. +const integrationName = "agentprimitives" + +// sdkClient is the real client seam implementation: the one place the +// 1Password SDK is imported. Every method is an unconditionally boring +// projection of an SDK call onto the package-internal neutral types in +// source.go -- no mapping, filtering, or policy decision beyond "which +// category counts as a login," which the List contract names here. All the +// branching that a test needs to cover lives in source.go. +// +// token is retained for ONE reason: to scrub it from any error the SDK core +// returns, so a bad-token failure can never carry the token into a log line +// (see scrubToken in source.go). It is never otherwise read. +type sdkClient struct { + oc *opsdk.Client + token string +} + +var _ client = (*sdkClient)(nil) + +// newSDKClient builds a service-account-authenticated SDK client. It matches +// the Source.newClient signature so New can wire it as the default. On error +// it returns a genuine nil interface (never a typed-nil *sdkClient). +func newSDKClient(ctx context.Context, token string) (client, error) { + oc, err := opsdk.NewClient(ctx, + opsdk.WithServiceAccountToken(token), + opsdk.WithIntegrationInfo(integrationName, apimage.Version), + ) + if err != nil { + return nil, scrubToken(err, token) + } + return &sdkClient{oc: oc, token: token}, nil +} + +func (c *sdkClient) ListVaults(ctx context.Context) ([]vault, error) { + overviews, err := c.oc.Vaults().List(ctx) + if err != nil { + return nil, scrubToken(err, c.token) + } + out := make([]vault, 0, len(overviews)) + for _, v := range overviews { + out = append(out, vault{ID: v.ID, Title: v.Title}) + } + return out, nil +} + +func (c *sdkClient) ListLoginItems(ctx context.Context, vaultID string) ([]listItem, error) { + overviews, err := c.oc.Items().List(ctx, vaultID, + opsdk.NewItemListFilterTypeVariantByState(&opsdk.ItemListFilterByStateInner{Active: true}), + ) + if err != nil { + return nil, scrubToken(err, c.token) + } + out := make([]listItem, 0, len(overviews)) + for _, o := range overviews { + if o.Category != opsdk.ItemCategoryLogin { + continue + } + out = append(out, listItem{ + ID: o.ID, + Title: o.Title, + Websites: projectWebsites(o.Websites), + }) + } + return out, nil +} + +func (c *sdkClient) GetItem(ctx context.Context, vaultID, itemID string) (itemDetail, error) { + item, err := c.oc.Items().Get(ctx, vaultID, itemID) + if err != nil { + return itemDetail{}, scrubToken(err, c.token) + } + fields := make([]field, 0, len(item.Fields)) + for _, f := range item.Fields { + fields = append(fields, field{ + ID: f.ID, + FieldType: string(f.FieldType), + Value: f.Value, + OTP: projectOTP(f), + }) + } + return itemDetail{ID: item.ID, Title: item.Title, Fields: fields}, nil +} + +// projectWebsites copies the SDK's autofill-behavior string through verbatim; +// the AnywhereOnWebsite/ExactDomain/Never decision is source.go's. +func projectWebsites(sites []opsdk.Website) []website { + out := make([]website, 0, len(sites)) + for _, w := range sites { + out = append(out, website{URL: w.URL, Autofill: string(w.AutofillBehavior)}) + } + return out +} + +// projectOTP surfaces a TOTP field's freshly computed code (and any compute +// error) from the SDK's Details envelope. Nil for non-TOTP fields. +func projectOTP(f opsdk.ItemField) *otp { + if f.FieldType != opsdk.ItemFieldTypeTOTP || f.Details == nil { + return nil + } + d := f.Details.OTP() + if d == nil { + return &otp{} + } + o := &otp{} + if d.Code != nil { + o.Code = *d.Code + } + if d.ErrorMessage != nil { + o.Error = *d.ErrorMessage + } + return o +} diff --git a/pkg/platform/identity/directcred/onepassword/source.go b/pkg/platform/identity/directcred/onepassword/source.go new file mode 100644 index 00000000..efe3bbe1 --- /dev/null +++ b/pkg/platform/identity/directcred/onepassword/source.go @@ -0,0 +1,384 @@ +// Package onepassword implements the directcred.Source backed by 1Password, +// over the official 1Password Go SDK. It lists the Login items a service +// account can see in one vault and maps each item's autofill websites onto +// the host patterns the browser toolbox fills against, never carrying a field +// value into a listing. +// +// Layout follows a strict seam (spec 4.2): this file holds ALL mapping, +// Verify, List, and Fetch logic over a thin package-internal client +// interface; sdk.go is the ONLY file that imports the SDK and implements that +// interface by boring projection; init.go registers the Source. The seam +// exists because the SDK's core is a wasm module that cannot be pointed at an +// httptest server -- the logic here is unit-tested through a fake client, and +// the SDK adapter stays untested-but-trivial. +// +// Security posture (load-bearing): the service-account token is read from +// cfg.SecretData["token"] and is NEVER interpolated into an error or a log. +// Every error this package returns passes through scrubToken, so even an SDK +// core error that embedded the token cannot carry it out. A Ref carries only +// an item's id, title, and mapped hosts -- never a username, password, or any +// field value (directcred.Ref's contract). +package onepassword + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +const ( + // secretKeyToken is the SecretData key holding the 1Password service-account + // token. + secretKeyToken = "token" + + // Built-in Login-item field ids the SDK assigns to the username and + // password fields. The SDK carries no "purpose" concept (see the report's + // SDK-vs-brief notes); these stable ids are how a Login item's + // username/password are identified. + fieldIDUsername = "username" + fieldIDPassword = "password" + + // fieldTypeTOTP is the SDK's ItemFieldType string for a one-time-code + // field (opsdk.ItemFieldTypeTOTP), kept here as a plain string so source.go + // needs no SDK import. + fieldTypeTOTP = "Totp" + + // autofill* are the SDK's AutofillBehavior strings this package acts on. A + // website with any other behavior (notably "Never") is not auto-fillable + // and is dropped. + autofillAnywhere = "AnywhereOnWebsite" + autofillExactDomain = "ExactDomain" +) + +// client is the package-internal seam between the mapping/Verify/List/Fetch +// logic (this file) and the real 1Password SDK (sdk.go). It exists ONLY so +// that logic can be unit-tested: the SDK's core is a wasm module that cannot +// be pointed at an httptest server, so source_test.go injects a fake client +// and the SDK adapter in sdk.go stays thin and unconditionally boring. +// +// Every method returns the package-internal neutral projections below, never +// an SDK type -- that is what keeps the SDK import confined to sdk.go. +type client interface { + // ListVaults returns every vault the service-account token can see. + ListVaults(ctx context.Context) ([]vault, error) + // ListLoginItems returns the Login-category items in the given vault. + ListLoginItems(ctx context.Context, vaultID string) ([]listItem, error) + // GetItem returns one item's full detail (including any freshly computed + // OTP code), by vault and item id. + GetItem(ctx context.Context, vaultID, itemID string) (itemDetail, error) +} + +// vault is a neutral projection of the SDK's VaultOverview. +type vault struct { + ID string + Title string +} + +// website is a neutral projection of the SDK's Website. Autofill is the SDK's +// autofill-behavior string verbatim (one of the autofill* constants above); +// the mapping decision lives here in source.go, not in the adapter. +type website struct { + URL string + Autofill string +} + +// listItem is a neutral projection of the SDK's ItemOverview, carrying only +// what List needs -- never a field value. +type listItem struct { + ID string + Title string + Websites []website +} + +// field is a neutral projection of the SDK's ItemField. OTP is non-nil only +// for a TOTP field whose current code the SDK core computed on this read. +type field struct { + ID string + FieldType string + Value string + OTP *otp +} + +// otp carries a TOTP field's freshly computed code, or the reason it could +// not be computed. Never cached: produced anew on every GetItem. +type otp struct { + Code string + Error string +} + +// itemDetail is a neutral projection of the SDK's Item, carrying the fields +// Fetch needs. +type itemDetail struct { + ID string + Title string + Fields []field +} + +// Source is the directcred.Source impl backed by 1Password over the official +// SDK. It holds no per-call state: newClient builds a fresh backend client +// from the service-account token for each Verify/List/Fetch, matching the +// stateless Source contract. +type Source struct { + // newClient builds the backend client from a service-account token. The + // default (New) wires the real SDK adapter in sdk.go; tests inject a fake + // so the logic below runs without the SDK's wasm core. + newClient func(ctx context.Context, token string) (client, error) +} + +// New returns a Source wired to the real 1Password SDK adapter. +func New() *Source { return &Source{newClient: newSDKClient} } + +var _ directcred.Source = (*Source)(nil) + +// Kind returns the registration key, directcred.SourceOnePassword. +func (*Source) Kind() string { return directcred.SourceOnePassword } + +// Verify lists the vaults the token can see and confirms the configuration +// resolves to exactly one. Exactly one visible vault AND (cfg.Vault == "" or +// its name matches) yields its Pin; zero or many vaults, or a name mismatch, +// is an error naming the vault names/count found -- never the token. +func (s *Source) Verify(ctx context.Context, cfg directcred.Config) (directcred.Pin, error) { + c, token, err := s.clientFor(ctx, cfg) + if err != nil { + return directcred.Pin{}, err + } + vaults, err := c.ListVaults(ctx) + if err != nil { + return directcred.Pin{}, scrubToken(wrapErr("listing vaults", err), token) + } + if len(vaults) != 1 { + return directcred.Pin{}, fmt.Errorf( + "onepassword: expected exactly one accessible vault, found %d: %v", + len(vaults), vaultTitles(vaults)) + } + v := vaults[0] + if cfg.Vault != "" && v.Title != cfg.Vault { + return directcred.Pin{}, fmt.Errorf( + "onepassword: the accessible vault %q does not match the configured vault %q", + v.Title, cfg.Vault) + } + return directcred.Pin{VaultID: v.ID, VaultName: v.Title}, nil +} + +// List returns one Ref per Login item in the pinned vault whose websites map +// to at least one usable host, sorted by id. An item with no usable host is +// omitted. A Ref carries only id, title, and hosts -- never a field value. +func (s *Source) List(ctx context.Context, cfg directcred.Config) ([]directcred.Ref, error) { + c, token, err := s.clientFor(ctx, cfg) + if err != nil { + return nil, err + } + vaultID, err := s.resolveVaultID(ctx, c, cfg, token) + if err != nil { + return nil, err + } + items, err := c.ListLoginItems(ctx, vaultID) + if err != nil { + return nil, scrubToken(wrapErr("listing login items", err), token) + } + + refs := make([]directcred.Ref, 0, len(items)) + for _, it := range items { + hosts := mapWebsites(it.Websites) + if len(hosts) == 0 { + // Spec: an item with no usable host is omitted. + continue + } + refs = append(refs, directcred.Ref{ID: it.ID, Label: it.Title, Hosts: hosts}) + } + sort.Slice(refs, func(i, j int) bool { return refs[i].ID < refs[j].ID }) + return refs, nil +} + +// Fetch returns one Login item's username/password and -- iff the item has a +// TOTP field -- an OTP func that re-reads the item for a fresh code on every +// call. id must be a Ref.ID previously returned by List for the same cfg. +func (s *Source) Fetch(ctx context.Context, cfg directcred.Config, id string) (directcred.Credential, error) { + c, token, err := s.clientFor(ctx, cfg) + if err != nil { + return directcred.Credential{}, err + } + vaultID, err := s.resolveVaultID(ctx, c, cfg, token) + if err != nil { + return directcred.Credential{}, err + } + item, err := c.GetItem(ctx, vaultID, id) + if err != nil { + return directcred.Credential{}, scrubToken(wrapErr(fmt.Sprintf("reading item %s", id), err), token) + } + + var cred directcred.Credential + hasTOTP := false + for _, f := range item.Fields { + switch f.ID { + case fieldIDUsername: + cred.Username = f.Value + case fieldIDPassword: + cred.Password = f.Value + } + if f.FieldType == fieldTypeTOTP { + hasTOTP = true + } + } + if hasTOTP { + // Capture the already-built client: each call re-reads the item so the + // SDK core computes the CURRENT code. Nothing is cached. + cred.OTP = func() (string, error) { return freshOTP(ctx, c, vaultID, id, token) } + } + return cred, nil +} + +// clientFor extracts the service-account token, rejects a missing one before +// touching the backend, and builds the client. It returns the token so the +// caller can scrub it from any later error. +func (s *Source) clientFor(ctx context.Context, cfg directcred.Config) (client, string, error) { + token := string(cfg.SecretData[secretKeyToken]) + if token == "" { + return nil, "", fmt.Errorf("onepassword: secret is missing the %q key", secretKeyToken) + } + c, err := s.newClient(ctx, token) + if err != nil { + return nil, token, scrubToken(wrapErr("creating client", err), token) + } + return c, token, nil +} + +// resolveVaultID returns cfg.VaultID when a prior Verify pinned one; otherwise +// it resolves cfg.Vault by name against the live vault list. A name matching +// zero or multiple vaults is an error naming what was found -- never the token. +func (s *Source) resolveVaultID(ctx context.Context, c client, cfg directcred.Config, token string) (string, error) { + if cfg.VaultID != "" { + return cfg.VaultID, nil + } + vaults, err := c.ListVaults(ctx) + if err != nil { + return "", scrubToken(wrapErr("listing vaults", err), token) + } + var matches []vault + for _, v := range vaults { + if v.Title == cfg.Vault { + matches = append(matches, v) + } + } + switch len(matches) { + case 1: + return matches[0].ID, nil + case 0: + return "", fmt.Errorf("onepassword: no accessible vault named %q (found: %v)", + cfg.Vault, vaultTitles(vaults)) + default: + return "", fmt.Errorf("onepassword: %d accessible vaults named %q; cannot disambiguate", + len(matches), cfg.Vault) + } +} + +// mapWebsites turns an item's autofill websites into host patterns: +// AnywhereOnWebsite -> suffix (non-exact) host, ExactDomain -> exact host, +// anything else (Never, or an unknown behavior) -> dropped. Each website +// value is treated as possibly a URL, so the hostname is parsed out first and +// then canonicalized; a host browserpolicy rejects is dropped. Input order is +// preserved. +func mapWebsites(sites []website) []directcred.Host { + var hosts []directcred.Host + for _, w := range sites { + var exact bool + switch w.Autofill { + case autofillAnywhere: + exact = false + case autofillExactDomain: + exact = true + default: + continue // Never, or any unknown behavior: not auto-fillable. + } + canon, err := browserpolicy.CanonicalHost(hostFromWebsiteValue(w.URL)) + if err != nil { + continue // a host the policy rejects is dropped. + } + hosts = append(hosts, directcred.Host{Name: canon, Exact: exact}) + } + return hosts +} + +// hostFromWebsiteValue extracts a hostname from a 1Password website value, +// which may be a bare host ("example.com"), a full URL +// ("https://example.com/login"), or a host:port. CanonicalHost does the final +// validation; this only strips scheme/path/port so a URL-shaped value does +// not reach CanonicalHost (which rejects schemes and ports) and get dropped. +func hostFromWebsiteValue(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if u, err := url.Parse(v); err == nil && u.Host != "" { + return u.Hostname() + } + // A scheme-less "example.com[:port][/path]" parses with an empty Host; + // re-parse as an authority so the host is recovered. + if u, err := url.Parse("//" + v); err == nil && u.Host != "" { + return u.Hostname() + } + return v +} + +// freshOTP re-reads the item and returns its current one-time code. Called +// anew on every Credential.OTP() invocation: the SDK core recomputes the code +// at read time, so this must never cache. +func freshOTP(ctx context.Context, c client, vaultID, id, token string) (string, error) { + item, err := c.GetItem(ctx, vaultID, id) + if err != nil { + return "", scrubToken(wrapErr(fmt.Sprintf("reading item %s for its one-time code", id), err), token) + } + for _, f := range item.Fields { + if f.FieldType != fieldTypeTOTP { + continue + } + switch { + case f.OTP == nil: + return "", fmt.Errorf("onepassword: item %s has a one-time-code field but no computed code", id) + case f.OTP.Error != "": + return "", fmt.Errorf("onepassword: item %s one-time code could not be computed: %s", id, f.OTP.Error) + default: + return f.OTP.Code, nil + } + } + return "", fmt.Errorf("onepassword: item %s no longer has a one-time-code field", id) +} + +// vaultTitles lists vault names for an error message. Vault names are not +// secret (the contract wants them named); a token never reaches here. +func vaultTitles(vaults []vault) []string { + titles := make([]string, len(vaults)) + for i, v := range vaults { + titles[i] = v.Title + } + return titles +} + +// wrapErr adds operation context to an error without introducing the token. +func wrapErr(op string, err error) error { + return fmt.Errorf("onepassword: %s: %w", op, err) +} + +const redactedToken = "[redacted 1Password service-account token]" + +// scrubToken guarantees the service-account token never rides out inside an +// error string. The SDK's own error unmarshalling (its errors.go) surfaces a +// core-supplied message, and while a bad-token failure today reports a +// format/auth message rather than echoing the token, that is not a contract +// we control -- so if the token literal appears anywhere in the error text it +// is replaced. The %w chain is preserved in the common (no-match) case. +func scrubToken(err error, token string) error { + if err == nil { + return nil + } + if token != "" && strings.Contains(err.Error(), token) { + return errors.New(strings.ReplaceAll(err.Error(), token, redactedToken)) + } + return err +} diff --git a/pkg/platform/identity/directcred/onepassword/source_test.go b/pkg/platform/identity/directcred/onepassword/source_test.go new file mode 100644 index 00000000..66eb659d --- /dev/null +++ b/pkg/platform/identity/directcred/onepassword/source_test.go @@ -0,0 +1,372 @@ +package onepassword + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" +) + +// tokenData is the minimal valid SecretData: a non-empty service-account +// token under the "token" key. +func tokenData() map[string][]byte { + return map[string][]byte{"token": []byte("fake-sa-token")} +} + +// fakeClient implements the package-internal client seam without the SDK's +// wasm core, so the mapping/Verify/List/Fetch logic runs under test. +type fakeClient struct { + vaults []vault + loginItems map[string][]listItem // keyed by vaultID + items map[string]itemDetail // keyed by itemID + + listVaultsErr error + listItemsErr error + getItemErr error + + getItemCalls int + // otpCodes rotates a fresh code per GetItem call, so a test can prove each + // OTP() invocation re-reads the item rather than caching. + otpCodes []string +} + +func (f *fakeClient) ListVaults(context.Context) ([]vault, error) { + if f.listVaultsErr != nil { + return nil, f.listVaultsErr + } + return f.vaults, nil +} + +func (f *fakeClient) ListLoginItems(_ context.Context, vaultID string) ([]listItem, error) { + if f.listItemsErr != nil { + return nil, f.listItemsErr + } + return f.loginItems[vaultID], nil +} + +func (f *fakeClient) GetItem(_ context.Context, _ string, itemID string) (itemDetail, error) { + f.getItemCalls++ + if f.getItemErr != nil { + return itemDetail{}, f.getItemErr + } + it, ok := f.items[itemID] + if !ok { + return itemDetail{}, fmt.Errorf("fake: no item %q", itemID) + } + out := itemDetail{ID: it.ID, Title: it.Title} + for _, fld := range it.Fields { + nf := fld + if nf.FieldType == fieldTypeTOTP && len(f.otpCodes) > 0 { + code := f.otpCodes[(f.getItemCalls-1)%len(f.otpCodes)] + nf.OTP = &otp{Code: code} + } + out.Fields = append(out.Fields, nf) + } + return out, nil +} + +func sourceWith(c client) *Source { + return &Source{newClient: func(context.Context, string) (client, error) { return c, nil }} +} + +func sourceWithClientErr(err error) *Source { + return &Source{newClient: func(context.Context, string) (client, error) { return nil, err }} +} + +func TestKind(t *testing.T) { + assert.Equal(t, directcred.SourceOnePassword, New().Kind()) +} + +func TestListWebsiteMapping(t *testing.T) { + cases := []struct { + name string + items []listItem + want []directcred.Ref + }{ + { + name: "AnywhereOnWebsite maps to a suffix (non-exact) host", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "example.com", Autofill: autofillAnywhere}}}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{{Name: "example.com", Exact: false}}}}, + }, + { + name: "ExactDomain maps to an exact host", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "app.example.com", Autofill: autofillExactDomain}}}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{{Name: "app.example.com", Exact: true}}}}, + }, + { + name: "Never is dropped; an item with no other usable host is omitted", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "example.com", Autofill: "Never"}}}}, + want: []directcred.Ref{}, + }, + { + name: "URL-shaped value: hostname is parsed out of a full URL", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "https://sub.example.com/login?next=1", Autofill: autofillAnywhere}}}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{{Name: "sub.example.com", Exact: false}}}}, + }, + { + name: "URL-shaped value with a port: port stripped, host kept", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "example.com:8443", Autofill: autofillExactDomain}}}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{{Name: "example.com", Exact: true}}}}, + }, + { + name: "uppercase host is canonicalized to lowercase", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "EXAMPLE.COM", Autofill: autofillAnywhere}}}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{{Name: "example.com", Exact: false}}}}, + }, + { + name: "a host the policy rejects is dropped; item with only that host omitted", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "bad host with spaces", Autofill: autofillAnywhere}}}}, + want: []directcred.Ref{}, + }, + { + name: "mixed within one item: Never dropped, Anywhere and Exact kept in order", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{ + {URL: "a.com", Autofill: autofillAnywhere}, + {URL: "b.com", Autofill: "Never"}, + {URL: "c.com", Autofill: autofillExactDomain}, + }}}, + want: []directcred.Ref{{ID: "i1", Label: "A", Hosts: []directcred.Host{ + {Name: "a.com", Exact: false}, + {Name: "c.com", Exact: true}, + }}}, + }, + { + name: "multiple items sorted by ID; a no-usable-host item is omitted", + items: []listItem{ + {ID: "i2", Title: "Two", Websites: []website{{URL: "two.com", Autofill: autofillAnywhere}}}, + {ID: "i1", Title: "One", Websites: []website{{URL: "one.com", Autofill: autofillAnywhere}}}, + {ID: "i3", Title: "Three", Websites: []website{{URL: "three.com", Autofill: "Never"}}}, + }, + want: []directcred.Ref{ + {ID: "i1", Label: "One", Hosts: []directcred.Host{{Name: "one.com", Exact: false}}}, + {ID: "i2", Label: "Two", Hosts: []directcred.Host{{Name: "two.com", Exact: false}}}, + }, + }, + { + name: "an unknown autofill behavior is dropped (fail-closed)", + items: []listItem{{ID: "i1", Title: "A", Websites: []website{{URL: "example.com", Autofill: "SomethingNew"}}}}, + want: []directcred.Ref{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + loginItems: map[string][]listItem{"v1": tc.items}, + } + got, err := sourceWith(fake).List(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestListRefsCarryNoFieldValues(t *testing.T) { + const sentinel = "S3NT1NEL-PASSWORD-MUST-NOT-LEAK" + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + loginItems: map[string][]listItem{"v1": { + {ID: "i1", Title: "Example Login", Websites: []website{{URL: "example.com", Autofill: autofillAnywhere}}}, + }}, + // The sentinel lives ONLY in the item's field detail (GetItem). If List + // ever started embedding field values into a Ref, this would catch it. + items: map[string]itemDetail{"i1": {ID: "i1", Title: "Example Login", Fields: []field{ + {ID: "password", FieldType: "Concealed", Value: sentinel}, + }}}, + } + + refs, err := sourceWith(fake).List(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}) + require.NoError(t, err) + + b, err := json.Marshal(refs) + require.NoError(t, err) + assert.NotContains(t, string(b), sentinel) +} + +func TestVerify(t *testing.T) { + cases := []struct { + name string + vaults []vault + cfgVault string + wantPin directcred.Pin + wantErr []string // all substrings must be present; empty means no error + }{ + { + name: "one vault matching the configured name: Pin", + vaults: []vault{{ID: "v1", Title: "Prod"}}, + cfgVault: "Prod", + wantPin: directcred.Pin{VaultID: "v1", VaultName: "Prod"}, + }, + { + name: "one vault, no name configured: pins the single vault", + vaults: []vault{{ID: "v1", Title: "OnlyOne"}}, + wantPin: directcred.Pin{VaultID: "v1", VaultName: "OnlyOne"}, + }, + { + name: "one vault with the wrong name: error naming both", + vaults: []vault{{ID: "v1", Title: "Staging"}}, + cfgVault: "Prod", + wantErr: []string{"Staging", "Prod"}, + }, + { + name: "two vaults: error listing their names and the count", + vaults: []vault{{ID: "v1", Title: "Prod"}, {ID: "v2", Title: "Staging"}}, + cfgVault: "Prod", + wantErr: []string{"found 2", "Prod", "Staging"}, + }, + { + name: "zero vaults: error naming the count", + vaults: nil, + cfgVault: "Prod", + wantErr: []string{"found 0"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &fakeClient{vaults: tc.vaults} + pin, err := sourceWith(fake).Verify(context.Background(), + directcred.Config{SecretData: tokenData(), Vault: tc.cfgVault}) + + if len(tc.wantErr) > 0 { + require.Error(t, err) + for _, want := range tc.wantErr { + assert.Contains(t, err.Error(), want) + } + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantPin, pin) + }) + } +} + +func TestVerifyListVaultsError(t *testing.T) { + fake := &fakeClient{listVaultsErr: errors.New("boom")} + _, err := sourceWith(fake).Verify(context.Background(), + directcred.Config{SecretData: tokenData(), Vault: "Prod"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "listing vaults") + assert.Contains(t, err.Error(), "boom") +} + +func TestVerifyAuthErrorNeverLeaksToken(t *testing.T) { + const token = "SUPER-SECRET-SA-TOKEN" + // The client constructor fails with an error that embeds the token, the + // way a careless SDK core error could. Verify must scrub it. + s := sourceWithClientErr(fmt.Errorf("auth failed for token %s", token)) + _, err := s.Verify(context.Background(), + directcred.Config{SecretData: map[string][]byte{"token": []byte(token)}, Vault: "Prod"}) + require.Error(t, err) + assert.NotContains(t, err.Error(), token, "the service-account token must never appear in an error") +} + +func TestVerifyMissingToken(t *testing.T) { + // newClient must never even be called when the token key is absent. + called := false + s := &Source{newClient: func(context.Context, string) (client, error) { + called = true + return &fakeClient{}, nil + }} + _, err := s.Verify(context.Background(), directcred.Config{SecretData: map[string][]byte{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "token") + assert.False(t, called, "newClient must not be called without a token") +} + +func TestFetchFieldSelection(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + items: map[string]itemDetail{"i1": {ID: "i1", Title: "Login", Fields: []field{ + {ID: "username", FieldType: "Text", Value: "alice"}, + {ID: "password", FieldType: "Concealed", Value: "s3cret"}, + {ID: "notesPlain", FieldType: "Text", Value: "ignore me"}, + }}}, + } + cred, err := sourceWith(fake).Fetch(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}, "i1") + require.NoError(t, err) + assert.Equal(t, "alice", cred.Username) + assert.Equal(t, "s3cret", cred.Password) + assert.Nil(t, cred.OTP, "no TOTP field present: OTP must be nil") +} + +func TestFetchOTPFreshPerCall(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + items: map[string]itemDetail{"i1": {ID: "i1", Title: "Login", Fields: []field{ + {ID: "username", FieldType: "Text", Value: "alice"}, + {ID: "password", FieldType: "Concealed", Value: "s3cret"}, + {ID: "onetimepassword", FieldType: fieldTypeTOTP, Value: "otpauth://totp/x?secret=abc"}, + }}}, + otpCodes: []string{"111111", "222222"}, + } + cred, err := sourceWith(fake).Fetch(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}, "i1") + require.NoError(t, err) + require.NotNil(t, cred.OTP, "a TOTP field is present: OTP must be non-nil") + + callsAfterFetch := fake.getItemCalls + code1, err := cred.OTP() + require.NoError(t, err) + code2, err := cred.OTP() + require.NoError(t, err) + + // Each OTP() must re-read the item (two more GetItem calls) rather than + // cache, and surface the freshly computed code each time. + assert.Equal(t, callsAfterFetch+2, fake.getItemCalls, "each OTP() call must re-read the item") + assert.NotEqual(t, code1, code2, "a re-read must surface a fresh code") +} + +func TestFetchOTPComputeError(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + items: map[string]itemDetail{"i1": {ID: "i1", Title: "Login", Fields: []field{ + {ID: "onetimepassword", FieldType: fieldTypeTOTP, Value: "bad", OTP: &otp{Error: "invalid seed"}}, + }}}, + // No otpCodes, so GetItem leaves the seeded OTP (with its Error) intact. + } + cred, err := sourceWith(fake).Fetch(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}, "i1") + require.NoError(t, err) + require.NotNil(t, cred.OTP) + + _, err = cred.OTP() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid seed") +} + +func TestFetchUnknownItem(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Vault"}}, + items: map[string]itemDetail{}, + } + _, err := sourceWith(fake).Fetch(context.Background(), + directcred.Config{SecretData: tokenData(), VaultID: "v1"}, "nope") + require.Error(t, err) + assert.Contains(t, err.Error(), "nope") +} + +func TestListResolvesVaultByName(t *testing.T) { + fake := &fakeClient{ + vaults: []vault{{ID: "v1", Title: "Prod"}, {ID: "v2", Title: "Staging"}}, + loginItems: map[string][]listItem{"v2": { + {ID: "i1", Title: "A", Websites: []website{{URL: "example.com", Autofill: autofillAnywhere}}}, + }}, + } + // No VaultID set: List must resolve "Staging" to v2 via ListVaults. + refs, err := sourceWith(fake).List(context.Background(), + directcred.Config{SecretData: tokenData(), Vault: "Staging"}) + require.NoError(t, err) + require.Len(t, refs, 1) + assert.Equal(t, "i1", refs[0].ID) +} diff --git a/pkg/platform/identity/directcred/registry.go b/pkg/platform/identity/directcred/registry.go new file mode 100644 index 00000000..c4b90d21 --- /dev/null +++ b/pkg/platform/identity/directcred/registry.go @@ -0,0 +1,21 @@ +package directcred + +import "github.com/authzed/openagentprimitives/pkg/x/kindregistry" + +// reg is the process-wide directcred registry, keyed by Source.Kind(). +// Storage/mutex/sort/panic-on-dup live in pkg/x/kindregistry; the exported +// funcs below are thin forwarders, same shape as pkg/tools/kinds/registry. +var reg = kindregistry.New[Source]("directcred", Source.Kind) + +// Register adds s to the registry. Panics on duplicate Kind(). +func Register(s Source) { reg.Register(s) } + +// Get returns the source registered under kind. +func Get(kind string) (Source, bool) { return reg.Get(kind) } + +// All returns a snapshot of every registered source, sorted by Kind(). +func All() []Source { return reg.All() } + +// Reset clears the registry. Test-only helper; do not call from +// production code paths. +func Reset() { reg.Reset() } diff --git a/pkg/platform/identity/directcred/secretsource/init.go b/pkg/platform/identity/directcred/secretsource/init.go new file mode 100644 index 00000000..63de0094 --- /dev/null +++ b/pkg/platform/identity/directcred/secretsource/init.go @@ -0,0 +1,5 @@ +package secretsource + +import "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + +func init() { directcred.Register(New()) } diff --git a/pkg/platform/identity/directcred/secretsource/source.go b/pkg/platform/identity/directcred/secretsource/source.go new file mode 100644 index 00000000..839605fc --- /dev/null +++ b/pkg/platform/identity/directcred/secretsource/source.go @@ -0,0 +1,187 @@ +// Package secretsource implements the directcred.Source backed by a plain +// Kubernetes Secret: one login per host, laid out as the three keys +// ".username", ".password", and an optional ".totp" +// (an otpauth:// URI or a bare base32 seed -- see totp.go). It registers +// itself under directcred.SourceSecret via init.go, mirroring every other +// kind registry in this repo. +// +// Security posture: every error returned by this package names a SECRET +// KEY (e.g. "a.example.com.username"), never a credential VALUE -- not a +// username, not a password, not a TOTP seed or URI. Callers may log these +// errors freely. +package secretsource + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +const ( + suffixUsername = "username" + suffixPassword = "password" + suffixTOTP = "totp" +) + +// Source is the directcred.Source impl for Config.SecretData laid out per +// the package doc comment. It holds no state: every call is a pure +// function of the Config it's given. +type Source struct{} + +// New returns a fresh Source. +func New() *Source { return &Source{} } + +var _ directcred.Source = (*Source)(nil) + +func (*Source) Kind() string { return directcred.SourceSecret } + +// Verify validates cfg.SecretData's layout and returns a zero Pin: the +// secret backend has no vault concept to confirm. A non-nil error reports +// only malformed data (an orphaned username/password key, an +// unrecognized key suffix, or a non-canonical host key) -- the same +// checks List and Fetch run. +func (*Source) Verify(_ context.Context, cfg directcred.Config) (directcred.Pin, error) { + if _, err := entriesByCanonicalHost(cfg.SecretData); err != nil { + return directcred.Pin{}, err + } + return directcred.Pin{}, nil +} + +// List returns one Ref per host found in cfg.SecretData, sorted by ID +// (the canonical host). Ref.Hosts always carries exactly one non-exact +// Host, matching the secret backend's per-host (not per-subdomain) grant. +func (*Source) List(_ context.Context, cfg directcred.Config) ([]directcred.Ref, error) { + entries, err := entriesByCanonicalHost(cfg.SecretData) + if err != nil { + return nil, err + } + + refs := make([]directcred.Ref, 0, len(entries)) + for host := range entries { + refs = append(refs, directcred.Ref{ + ID: host, + Label: host, + Hosts: []directcred.Host{{Name: host, Exact: false}}, + }) + } + sort.Slice(refs, func(i, j int) bool { return refs[i].ID < refs[j].ID }) + return refs, nil +} + +// Fetch returns the host's username/password, and -- iff the host has a +// .totp key -- an OTP func that computes a fresh code per call. id must +// be a canonical host previously returned as a Ref.ID by List against the +// same cfg; an unknown id is an error naming id (never a value). +func (*Source) Fetch(_ context.Context, cfg directcred.Config, id string) (directcred.Credential, error) { + entries, err := entriesByCanonicalHost(cfg.SecretData) + if err != nil { + return directcred.Credential{}, err + } + + e, ok := entries[id] + if !ok { + return directcred.Credential{}, fmt.Errorf("secretsource: unknown id %q", id) + } + + cred := directcred.Credential{ + Username: *e.username, + Password: *e.password, + } + if e.totp != nil { + cred.OTP = newOTPFunc(*e.totp) + } + return cred, nil +} + +// hostEntry collects the raw field values found under one host's keys, +// before validate cross-checks them into either an error or a usable +// entry. +type hostEntry struct { + username *string + password *string + totp *string +} + +// parseEntries walks data's keys, bucketing each by the host portion of +// its key (everything before the LAST '.') and recording the value under +// its suffix (everything after). It performs no cross-validation (orphan +// pairing, host canonicalization) -- see entriesByCanonicalHost -- so this +// pass alone just explains "which key, if any, has no recognized suffix." +func parseEntries(data map[string][]byte) (map[string]*hostEntry, error) { + entries := make(map[string]*hostEntry, len(data)) + for key, value := range data { + idx := strings.LastIndex(key, ".") + if idx <= 0 || idx == len(key)-1 { + return nil, fmt.Errorf("secretsource: key %q has no recognized suffix", key) + } + host, suffix := key[:idx], key[idx+1:] + + e := entries[host] + if e == nil { + e = &hostEntry{} + entries[host] = e + } + + val := string(value) + switch suffix { + case suffixUsername: + e.username = &val + case suffixPassword: + e.password = &val + case suffixTOTP: + e.totp = &val + default: + return nil, fmt.Errorf("secretsource: key %q has no recognized suffix", key) + } + } + return entries, nil +} + +// entriesByCanonicalHost parses data (see parseEntries) and then +// cross-validates each host's entry: username and password must both be +// present (a lone one is an orphan, named in the error), and the host +// portion of the key must already be exactly what +// browserpolicy.CanonicalHost would produce from it -- a host key is +// rejected rather than silently lowercased/normalized, because the +// operator typed it and should fix it, not have it reinterpreted. The +// returned map is keyed by that canonical host. +func entriesByCanonicalHost(data map[string][]byte) (map[string]*hostEntry, error) { + raw, err := parseEntries(data) + if err != nil { + return nil, err + } + + out := make(map[string]*hostEntry, len(raw)) + for host, e := range raw { + switch { + case e.username != nil && e.password == nil: + return nil, fmt.Errorf("secretsource: key %q has no matching %q key", + host+"."+suffixUsername, host+"."+suffixPassword) + case e.password != nil && e.username == nil: + return nil, fmt.Errorf("secretsource: key %q has no matching %q key", + host+"."+suffixPassword, host+"."+suffixUsername) + case e.username == nil && e.password == nil: + // Only a .totp key was present for this host -- a one-time code + // with nothing to attach it to is just as orphaned. + return nil, fmt.Errorf("secretsource: key %q has no matching %q/%q keys", + host+"."+suffixTOTP, host+"."+suffixUsername, host+"."+suffixPassword) + } + + canon, err := browserpolicy.CanonicalHost(host) + if err != nil { + return nil, fmt.Errorf("secretsource: host key %q: %w", host, err) + } + if canon != host { + return nil, fmt.Errorf( + "secretsource: host key %q is not canonical (canonical form is %q): fix the key, it will not be normalized silently", + host, canon) + } + + out[canon] = e + } + return out, nil +} diff --git a/pkg/platform/identity/directcred/secretsource/source_test.go b/pkg/platform/identity/directcred/secretsource/source_test.go new file mode 100644 index 00000000..e886f9af --- /dev/null +++ b/pkg/platform/identity/directcred/secretsource/source_test.go @@ -0,0 +1,177 @@ +package secretsource_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" +) + +func TestKind(t *testing.T) { + assert.Equal(t, directcred.SourceSecret, secretsource.New().Kind()) +} + +func TestSecretSourceList(t *testing.T) { + cases := []struct { + name string + data map[string][]byte + wantRefs []directcred.Ref + wantErr string // substring expected in the error; empty means no error + }{ + { + name: "clean data: two sorted Refs, hosts canonical", + data: map[string][]byte{ + "b.example.com.username": []byte("bob"), + "b.example.com.password": []byte("bobpw"), + "a.example.com.username": []byte("alice"), + "a.example.com.password": []byte("alicepw"), + }, + wantRefs: []directcred.Ref{ + { + ID: "a.example.com", + Label: "a.example.com", + Hosts: []directcred.Host{{Name: "a.example.com", Exact: false}}, + }, + { + ID: "b.example.com", + Label: "b.example.com", + Hosts: []directcred.Host{{Name: "b.example.com", Exact: false}}, + }, + }, + }, + { + name: "orphan username key with no matching password: error names the orphan", + data: map[string][]byte{ + "a.example.com.username": []byte("alice"), + "a.example.com.password": []byte("alicepw"), + "orphan.example.com.username": []byte("nopass"), + }, + wantErr: `"orphan.example.com.username"`, + }, + { + name: "orphan password key with no matching username: error names the orphan", + data: map[string][]byte{ + "orphan.example.com.password": []byte("nouser"), + }, + wantErr: `"orphan.example.com.password"`, + }, + { + name: "key with unrecognized suffix: error names the key", + data: map[string][]byte{ + "a.example.com.username": []byte("alice"), + "a.example.com.password": []byte("alicepw"), + "a.example.com.nickname": []byte("al"), + }, + wantErr: `"a.example.com.nickname"`, + }, + { + name: "key with no dot at all: error names the key", + data: map[string][]byte{ + "nodothere": []byte("x"), + }, + wantErr: `"nodothere"`, + }, + { + name: "non-canonical (mixed-case) host key: fail closed, not silently lowercased", + data: map[string][]byte{ + "AA.com.username": []byte("alice"), + "AA.com.password": []byte("alicepw"), + }, + wantErr: `"AA.com"`, + }, + { + name: "host key that fails CanonicalHost outright (contains a scheme): error names the key", + data: map[string][]byte{ + "https://evil.com.username": []byte("alice"), + "https://evil.com.password": []byte("alicepw"), + }, + wantErr: `"https://evil.com"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := secretsource.New() + cfg := directcred.Config{SecretData: tc.data} + + got, err := src.List(context.Background(), cfg) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantRefs, got) + }) + } +} + +func TestSecretSourceVerify(t *testing.T) { + t.Run("clean data: zero Pin, no error", func(t *testing.T) { + src := secretsource.New() + cfg := directcred.Config{SecretData: map[string][]byte{ + "a.example.com.username": []byte("alice"), + "a.example.com.password": []byte("alicepw"), + }} + + pin, err := src.Verify(context.Background(), cfg) + require.NoError(t, err) + assert.Equal(t, directcred.Pin{}, pin) + }) + + t.Run("malformed data: orphan key errors, same as List", func(t *testing.T) { + src := secretsource.New() + cfg := directcred.Config{SecretData: map[string][]byte{ + "a.example.com.username": []byte("alice"), + }} + + _, err := src.Verify(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), `"a.example.com.username"`) + }) +} + +func TestSecretSourceFetch(t *testing.T) { + data := map[string][]byte{ + "a.example.com.username": []byte("alice"), + "a.example.com.password": []byte("alicepw"), + "b.example.com.username": []byte("bob"), + "b.example.com.password": []byte("bobpw"), + "b.example.com.totp": []byte("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"), + } + cfg := directcred.Config{SecretData: data} + + t.Run("round-trip: values match, no .totp means nil OTP", func(t *testing.T) { + src := secretsource.New() + cred, err := src.Fetch(context.Background(), cfg, "a.example.com") + require.NoError(t, err) + assert.Equal(t, "alice", cred.Username) + assert.Equal(t, "alicepw", cred.Password) + assert.Nil(t, cred.OTP, "no .totp key present: OTP must be nil") + }) + + t.Run("round-trip: .totp present means non-nil OTP", func(t *testing.T) { + src := secretsource.New() + cred, err := src.Fetch(context.Background(), cfg, "b.example.com") + require.NoError(t, err) + assert.Equal(t, "bob", cred.Username) + assert.Equal(t, "bobpw", cred.Password) + require.NotNil(t, cred.OTP, ".totp key present: OTP must be non-nil") + + code, err := cred.OTP() + require.NoError(t, err) + assert.Len(t, code, 6, "bare seed defaults to 6 digits") + }) + + t.Run("unknown id: error", func(t *testing.T) { + src := secretsource.New() + _, err := src.Fetch(context.Background(), cfg, "nope.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "nope.example.com") + }) +} diff --git a/pkg/platform/identity/directcred/secretsource/totp.go b/pkg/platform/identity/directcred/secretsource/totp.go new file mode 100644 index 00000000..0ded4f28 --- /dev/null +++ b/pkg/platform/identity/directcred/secretsource/totp.go @@ -0,0 +1,82 @@ +package secretsource + +import ( + "fmt" + "strings" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// newOTPFunc returns a Credential.OTP closure over raw (an otpauth:// URI +// or a bare base32 seed, as found under a host's .totp key). Parsing raw +// is deliberately deferred to the first call: a malformed .totp value +// must not fail Fetch -- it is Phase C's tool-call boundary that turns a +// call-time OTP() error into a user-visible failure, not Fetch itself. +func newOTPFunc(raw string) func() (string, error) { + return func() (string, error) { + return CodeAt(raw, time.Now()) + } +} + +// CodeAt computes the one-time code for raw at the instant t. raw is +// either an otpauth:// URI -- whose algorithm/digits/period query +// parameters are honored -- or a bare base32 seed, which defaults to +// SHA1/6-digit/30-second (RFC 6238 / Google-Authenticator compatible). +// +// Exported so tests can assert against a fixed, independently-derived +// vector without sleeping or mocking time.Now: newOTPFunc's closure (what +// Credential.OTP actually is) is a thin time.Now() wrapper around this. +func CodeAt(raw string, t time.Time) (string, error) { + secret, opts, err := parseTOTPConfig(raw) + if err != nil { + return "", err + } + + code, err := totp.GenerateCodeCustom(secret, t, opts) + if err != nil { + // totp/hotp's errors here are static sentinels (e.g. "Decoding of + // secret as base32 failed.") that never embed the secret itself, + // so wrapping is safe -- unlike the otpauth-URI parse error below. + return "", fmt.Errorf("secretsource: generate OTP code: %w", err) + } + return code, nil +} + +// parseTOTPConfig splits raw into the base32 secret and the +// algorithm/digits/period to generate with. +func parseTOTPConfig(raw string) (string, totp.ValidateOpts, error) { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "otpauth://") { + key, err := otp.NewKeyFromURL(trimmed) + if err != nil { + // Do NOT wrap err directly: a url.Parse failure's Error() text + // embeds the entire input URL -- including the secret query + // parameter -- verbatim. Name only that parsing failed. + return "", totp.ValidateOpts{}, fmt.Errorf("secretsource: malformed otpauth URI") + } + return normalizeSeed(key.Secret()), totp.ValidateOpts{ + Period: uint(key.Period()), + Digits: key.Digits(), + Algorithm: key.Algorithm(), + }, nil + } + + // Not an otpauth:// URI: treat raw as a bare base32 seed with the + // defaults every common authenticator app assumes. + return normalizeSeed(trimmed), totp.ValidateOpts{ + Period: 30, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }, nil +} + +// normalizeSeed uppercases a base32 seed and strips ALL whitespace. +// Operators commonly copy a seed grouped with spaces (an enrollment QR's +// "XXXX XXXX XXXX" display form) or in lowercase; the underlying library +// only trims LEADING/TRAILING whitespace before decoding, so internal +// whitespace and case need handling here. +func normalizeSeed(seed string) string { + return strings.ToUpper(strings.Join(strings.Fields(seed), "")) +} diff --git a/pkg/platform/identity/directcred/secretsource/totp_test.go b/pkg/platform/identity/directcred/secretsource/totp_test.go new file mode 100644 index 00000000..3ed63be4 --- /dev/null +++ b/pkg/platform/identity/directcred/secretsource/totp_test.go @@ -0,0 +1,102 @@ +package secretsource_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" +) + +// Test vectors below are derived two ways, never by calling +// github.com/pquerna/otp/totp.GenerateCodeCustom and hard-coding whatever +// it returns -- that would be circular (it would only prove the library +// agrees with itself, not that this package wires URI params/defaults +// into it correctly). +// +// - The SHA1/digits=6/period=30 vector is the published RFC 6238 +// Appendix B test vector (Time=59s, SHA1, 8-digit code "94287082"), +// truncated to 6 digits. Truncating is valid because both the 6- and +// 8-digit codes are the SAME 31-bit dynamic-truncation "value" modulo +// a different power of ten, and 10^6 divides 10^8 -- so value%10^6 +// is congruent to (value%10^8)%10^6, i.e. the published 8-digit +// code's last 6 digits: "94287082" -> "287082". The RFC's seed is the +// ASCII string "12345678901234567890"; base32-encoding those 20 bytes +// (no padding needed: 20*8=160 bits is a multiple of 5) gives the +// bare base32 seed below. +// +// - The SHA256/digits=8/period=60 vector has no published RFC vector +// (RFC 6238 Appendix B fixes the step at 30s; this test exercises a +// URI that overrides it to 60s). It was derived independently of +// this package and of github.com/pquerna/otp: a standalone Go +// program implemented RFC 4226 HOTP dynamic truncation directly +// against crypto/hmac + crypto/sha256 (no otp import at all), fed +// the RFC 6238 Appendix B SHA256 seed bytes (ASCII +// "12345678901234567890123456789012", 32 bytes) and counter = +// 1700000000/60, and printed "77076628". That independent +// implementation was first sanity-checked against the published +// RFC 6238 SHA1 vector above (reproduced "94287082" exactly) before +// being trusted for the SHA256/period=60 case, and the result was +// then cross-checked by calling the real otp/totp.GenerateCodeCustom +// once offline with the same key/time/options, which agreed +// ("77076628"). The hard-coded expectation below is the independent +// program's output, not a value read back out of this package or +// otp/totp. +const ( + rfc6238SHA1BareSeed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + rfc6238SHA1Time = 59 // seconds; RFC 6238 Appendix B row + + sha256Period60Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA" + sha256Period60Time = 1700000000 // seconds; arbitrary fixed point, independently derived (see above) +) + +func TestTOTP(t *testing.T) { + t.Run("otpauth URI: SHA256/digits=8/period=60 honored, independently-derived vector", func(t *testing.T) { + uri := "otpauth://totp/Example:test@example.com?secret=" + sha256Period60Secret + + "&issuer=Example&algorithm=SHA256&digits=8&period=60" + + code := generateAt(t, uri, time.Unix(sha256Period60Time, 0).UTC()) + assert.Equal(t, "77076628", code) + }) + + t.Run("bare base32 seed: SHA1/6/30 defaults, RFC 6238 vector truncated to 6 digits", func(t *testing.T) { + code := generateAt(t, rfc6238SHA1BareSeed, time.Unix(rfc6238SHA1Time, 0).UTC()) + assert.Equal(t, "287082", code) + }) + + t.Run("bare seed with spaces and lowercase: normalized to the same code", func(t *testing.T) { + messy := "gezd gnbv gy3t qojq\tgezd gnbv gy3t qojq" + code := generateAt(t, messy, time.Unix(rfc6238SHA1Time, 0).UTC()) + assert.Equal(t, "287082", code) + }) + + t.Run("garbage seed: Fetch succeeds, OTP() returns the parse error lazily", func(t *testing.T) { + src := secretsource.New() + cfg := directcred.Config{SecretData: map[string][]byte{ + "g.example.com.username": []byte("gary"), + "g.example.com.password": []byte("garypw"), + "g.example.com.totp": []byte("not-a-valid-base32!!!"), + }} + + cred, err := src.Fetch(context.Background(), cfg, "g.example.com") + require.NoError(t, err, "a garbage seed must not fail Fetch") + require.NotNil(t, cred.OTP) + + _, err = cred.OTP() + assert.Error(t, err, "a garbage seed must fail at OTP() call time, not before") + }) +} + +// generateAt round-trips raw (an otpauth:// URI or bare seed) through a +// Fetch'd Credential's OTP closure, pinned at t via the exported test hook +// so the test is deterministic without sleeping or mocking time.Now. +func generateAt(t *testing.T, raw string, at time.Time) string { + t.Helper() + code, err := secretsource.CodeAt(raw, at) + require.NoError(t, err) + return code +} diff --git a/pkg/platform/manifests/install.yaml b/pkg/platform/manifests/install.yaml index ec9af74a..11b895c6 100644 --- a/pkg/platform/manifests/install.yaml +++ b/pkg/platform/manifests/install.yaml @@ -4098,6 +4098,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -4219,6 +4262,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name @@ -4362,6 +4406,57 @@ spec: reflects. format: int64 type: integer + pinnedVaults: + description: |- + PinnedVaults records, for each type=directCredentials credential whose + source has a vault concept (onepassword), the vault its backing token was + PROVEN to resolve to. The validity reconciler calls the directcred source's + Verify on first sight and records the vault id + name here; it re-verifies + only when the token changes (TokenFingerprint differs), so a rotated token + that now resolves to a DIFFERENT vault surfaces loudly as + Valid=False/DirectCredentialInvalid instead of silently listing another + vault's logins at the browser toolbox. A source with no vault concept + (secret) is never recorded here. + + Operator-owned and derived: sorted by credential name and set once per + (credential, token) pair, so an unchanged token re-writes nothing and the + MergeFrom patch is a no-op. TokenFingerprint is a sha256 digest of the + token value, never the value itself. + items: + description: |- + PinnedVault is one entry of an identity's status.pinnedVaults — the vault a + type=directCredentials credential's token was proven to resolve to, pinned + once per token so a later rotation that redirects to a different vault is + caught rather than silently followed. Never carries the token value, only a + digest of it. + properties: + credential: + description: Credential is the credential name from spec.credentials[].name. + type: string + tokenFingerprint: + description: |- + TokenFingerprint is a hex sha256 digest of the backing token value at the + time the pin was written. It is the set-once key: a reconcile whose token + still hashes to this value carries the pin forward without re-verifying, + and a changed token forces a fresh Verify. It is a digest, never the token. + type: string + vaultID: + description: |- + VaultID is the backend's stable identifier for the pinned vault, as + returned by the source's Verify. + type: string + vaultName: + description: |- + VaultName is the human-readable vault name the backend confirmed. May + differ in case/whitespace from the credential's declared vault. + type: string + required: + - credential + - tokenFingerprint + - vaultID + - vaultName + type: object + type: array resolvedCredentials: description: ResolvedCredentials lists credential names whose Secret + key resolved. @@ -16557,6 +16652,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -16678,6 +16816,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name @@ -23738,6 +23877,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name @@ -24185,6 +24325,49 @@ spec: items: type: string type: array + directCredentials: + description: |- + DirectCredentials is populated when type=directCredentials: a website + login the browser toolbox fills at use time. It never flows through the + broker — Resolve errors unconditionally — so it never projects into an + env var or header the way every other block here does. Valid ONLY on + AgentIdentity: the credential is authored on the bot's own identity, + not passed through from a human. + properties: + secretRef: + description: |- + SecretRef names the source's token Secret (key "token", onepassword) + or the host-keyed login Secret (secret). + properties: + key: + description: Key is the data key within the Secret holding + the value. + type: string + name: + description: Name is the Secret's name, in the referring + object's own namespace. + type: string + required: + - key + - name + type: object + source: + description: |- + Source names the login backend: onepassword resolves through a + 1Password Connect token; secret reads a host-keyed login Secret + written directly. + enum: + - onepassword + - secret + type: string + vault: + description: Vault is the 1Password vault NAME; required + iff source is onepassword. + type: string + required: + - secretRef + - source + type: object federated: description: |- Federated is populated when type=federated. The credential is MINTED @@ -24306,6 +24489,7 @@ spec: - oauth - federated - githubApp + - directCredentials type: string required: - name diff --git a/third_party/licenses/demangle-BSD-3-Clause.txt b/third_party/licenses/demangle-BSD-3-Clause.txt new file mode 100644 index 00000000..d29b3726 --- /dev/null +++ b/third_party/licenses/demangle-BSD-3-Clause.txt @@ -0,0 +1,27 @@ +Copyright (c) 2015 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/extism-go-sdk-BSD-3-Clause.txt b/third_party/licenses/extism-go-sdk-BSD-3-Clause.txt new file mode 100644 index 00000000..9168a9e4 --- /dev/null +++ b/third_party/licenses/extism-go-sdk-BSD-3-Clause.txt @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2023, Extism + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/licenses/gobwas-glob-MIT.txt b/third_party/licenses/gobwas-glob-MIT.txt new file mode 100644 index 00000000..9d4735ca --- /dev/null +++ b/third_party/licenses/gobwas-glob-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Sergey Kamardin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/licenses/observe-sdk-go-Apache-2.0.txt b/third_party/licenses/observe-sdk-go-Apache-2.0.txt new file mode 100644 index 00000000..390f385e --- /dev/null +++ b/third_party/licenses/observe-sdk-go-Apache-2.0.txt @@ -0,0 +1,200 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023-present Dylibso, Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/onepassword-sdk-go-MIT.txt b/third_party/licenses/onepassword-sdk-go-MIT.txt new file mode 100644 index 00000000..67bad5e0 --- /dev/null +++ b/third_party/licenses/onepassword-sdk-go-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 1Password + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/pquerna-otp-Apache-2.0.txt b/third_party/licenses/pquerna-otp-Apache-2.0.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/third_party/licenses/pquerna-otp-Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/wabin-Apache-2.0.txt b/third_party/licenses/wabin-Apache-2.0.txt new file mode 100644 index 00000000..0632df31 --- /dev/null +++ b/third_party/licenses/wabin-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020-2022 wazero authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/licenses/wazero-Apache-2.0.txt b/third_party/licenses/wazero-Apache-2.0.txt new file mode 100644 index 00000000..477f7606 --- /dev/null +++ b/third_party/licenses/wazero-Apache-2.0.txt @@ -0,0 +1,208 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020-2023 wazero authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---------------------------------------------------------------------- +NOTICE (per Apache License 2.0 section 4(d)): + +wazero +Copyright 2020-2023 wazero authors From 7805990a92b1204d7f8303a7e2c6329f4639ed3e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 11 Oct 2026 00:01:25 -0400 Subject: [PATCH 2/2] browser: bind an AgentIdentity to a BrowserToolbox Adds identity-binding fields to BrowserToolbox, reconciles its validity against the identity's own Valid condition, lists the login catalog and identity sites onto session status (carry-forward pinned as gate-policy input), adds the onepassword-service-account setup flow, and trims the RBAC grant to read-only. --- PRIMITIVES.md | 6 + THIRD_PARTY_NOTICES.md | 2 + cmd/oap/main.go | 3 +- ...tprimitives.authzed.com_agentsessions.yaml | 106 ++++- ...imitives.authzed.com_browsertoolboxes.yaml | 44 ++- config/manager/role.yaml | 2 + internal/cmd/operator/main.go | 9 + .../cmd/operator/main_controllers_test.go | 1 + internal/cmd/runner/main.go | 2 + pkg/apis/v1alpha1/agentsession_types.go | 71 +++- pkg/apis/v1alpha1/browsertoolbox_types.go | 32 ++ pkg/apis/v1alpha1/conditions.go | 32 ++ pkg/apis/v1alpha1/zz_generated.deepcopy.go | 42 ++ .../browsertoolbox_validate_test.go | 85 +++- pkg/controllers/agentclass/controller.go | 44 ++- .../agentclass/watches_integration_test.go | 16 +- .../browsercatalog_envtest_test.go | 371 ++++++++++++++++++ ...browsercatalog_fingerprint_envtest_test.go | 180 +++++++++ .../browsercatalog_transient_envtest_test.go | 152 +++++++ pkg/controllers/agentsession/browserpod.go | 269 ++++++++++++- .../agentsession/browserpod_test.go | 29 +- pkg/controllers/agentsession/controller.go | 43 +- pkg/controllers/browsertoolbox/controller.go | 187 +++++++++ .../browsertoolbox/controller_test.go | 239 +++++++++++ .../browsertoolbox/sharedenv_test.go | 12 + .../watches_integration_test.go | 194 +++++++++ .../webhooks/agentsession/webhook.go | 21 +- .../webhooks/agentsession/webhook_test.go | 43 ++ .../identity/directcred/onepassword/sdk.go | 36 ++ .../identity/directcred/onepassword/source.go | 21 + .../directcred/onepassword/source_test.go | 37 ++ .../directcred/secretsource/source.go | 28 +- .../directcred/secretsource/source_test.go | 53 +++ .../setup/builtins/flow_contract_test.go | 13 +- .../identity/setup/builtins/loader/loader.go | 1 + .../setup/builtins/loader/loader_test.go | 2 +- .../onepassword_service_account/flow.go | 294 ++++++++++++++ .../onepassword_service_account/flow_test.go | 340 ++++++++++++++++ .../onepassword_service_account/init.go | 13 + pkg/platform/manifests/install.yaml | 152 ++++++- .../manifests/rbac_sufficiency_test.go | 19 +- pkg/tools/kinds/browsertoolbox/kind.go | 32 ++ pkg/tools/kinds/browsertoolbox/kind_test.go | 98 +++++ providers/onepassword-service-account.yaml | 43 ++ site/content/docs/crd-agentidentity.mdx | 13 +- site/content/docs/crd-agentsession.mdx | 14 +- site/content/docs/crd-browsertoolbox.mdx | 2 + site/content/docs/crd-sessionuseridentity.mdx | 8 +- site/content/docs/crd-toolcall.mdx | 2 +- site/content/docs/crd-useridentity.mdx | 8 +- test/e2e/harness.go | 29 +- .../licenses/boombuler-barcode-MIT.txt | 21 + .../licenses/otel-proto-otlp-Apache-2.0.txt | 201 ++++++++++ 53 files changed, 3587 insertions(+), 130 deletions(-) create mode 100644 pkg/controllers/agentsession/browsercatalog_envtest_test.go create mode 100644 pkg/controllers/agentsession/browsercatalog_fingerprint_envtest_test.go create mode 100644 pkg/controllers/agentsession/browsercatalog_transient_envtest_test.go create mode 100644 pkg/controllers/browsertoolbox/controller.go create mode 100644 pkg/controllers/browsertoolbox/controller_test.go create mode 100644 pkg/controllers/browsertoolbox/sharedenv_test.go create mode 100644 pkg/controllers/browsertoolbox/watches_integration_test.go create mode 100644 pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go create mode 100644 pkg/platform/identity/setup/builtins/onepassword_service_account/flow_test.go create mode 100644 pkg/platform/identity/setup/builtins/onepassword_service_account/init.go create mode 100644 providers/onepassword-service-account.yaml create mode 100644 third_party/licenses/boombuler-barcode-MIT.txt create mode 100644 third_party/licenses/otel-proto-otlp-Apache-2.0.txt diff --git a/PRIMITIVES.md b/PRIMITIVES.md index 8678dbb3..a98fcf7a 100644 --- a/PRIMITIVES.md +++ b/PRIMITIVES.md @@ -94,6 +94,12 @@ MCP servers, validated against a toolspec. `pkg/tools/browserpolicy/` is the shared site-matching decision both the gate and the tool's own pre-check consult. No dedicated controller yet (Phase A) — identity-scoped sites and acting tools (click/type) arrive in later phases. + Phase B adds identity-bound login catalogs: the `directCredentials` credential + type (`pkg/platform/identity/directcred/`, sources `onepassword` and `secret`) + lets a BrowserToolbox bind an AgentIdentity and list its logins as + `{id, label, hosts}` onto `AgentSession.status` — the username/password/OTP + itself never reaches the model, the broker (`Resolve` errors unconditionally + for this type), or status. - **Binaries:** `internal/cmd/streamclient/` (claims a streaming ToolCall via the gateway); tool execution is otherwise driven from `internal/cmd/runner/` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 42868681..d11a63f3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -13,12 +13,14 @@ does not relicense them under the project's license. | Microsoft DeBERTa v3 Base, used as the detector model's base model | Detector image | [MIT License](third_party/licenses/DeBERTa-MIT.txt) | | OWASP Top 10 for Agentic Applications 2026, by the OWASP GenAI Security Project | [`docs/references/OWASP-Top-10-for-Agentic-Applications-2026.pdf`](docs/references/OWASP-Top-10-for-Agentic-Applications-2026.pdf) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/legalcode); [source and attribution](docs/references/README.md) | | pquerna/otp v1.5.0, by Paul Querna | Compiled into the runner binary (`pkg/platform/identity/directcred/secretsource`) | [Apache License 2.0](third_party/licenses/pquerna-otp-Apache-2.0.txt) | +| boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc, by Florian Sundermann | Compiled into the operator and runner binaries (via `pquerna/otp`, `pkg/platform/identity/directcred/secretsource`) | [MIT License](third_party/licenses/boombuler-barcode-MIT.txt) | | 1Password Go SDK v0.4.1, by 1Password | Compiled into any binary linking the 1Password directcred source (`pkg/platform/identity/directcred/onepassword`) | [MIT License](third_party/licenses/onepassword-sdk-go-MIT.txt) | | Extism Go SDK v1.7.1, by Extism | Pulled in by the 1Password Go SDK (wasm host runtime) | [BSD 3-Clause License](third_party/licenses/extism-go-sdk-BSD-3-Clause.txt) | | wazero v1.11.0, by the wazero authors | Pulled in by the 1Password Go SDK (wasm runtime) | [Apache License 2.0](third_party/licenses/wazero-Apache-2.0.txt) | | dylibso/observe-sdk (Go), by Dylibso | Pulled in by the 1Password Go SDK (via Extism) | [Apache License 2.0](third_party/licenses/observe-sdk-go-Apache-2.0.txt) | | tetratelabs/wabin, by Tetrate | Pulled in by the 1Password Go SDK (via Extism) | [Apache License 2.0](third_party/licenses/wabin-Apache-2.0.txt) | | ianlancetaylor/demangle, by The Go Authors | Pulled in by the 1Password Go SDK (via Extism/observe-sdk) | [BSD 3-Clause License](third_party/licenses/demangle-BSD-3-Clause.txt) | +| go.opentelemetry.io/proto/otlp v1.11.0, by the OpenTelemetry Authors | Pulled in by the 1Password Go SDK (via Extism/observe-sdk) | [Apache License 2.0](third_party/licenses/otel-proto-otlp-Apache-2.0.txt) | | gobwas/glob v0.2.3, by Sergey Kamardin | Pulled in by the 1Password Go SDK (via Extism) | [MIT License](third_party/licenses/gobwas-glob-MIT.txt) | Dependency metadata also identifies diff --git a/cmd/oap/main.go b/cmd/oap/main.go index 0fdc6f93..6ada61b6 100644 --- a/cmd/oap/main.go +++ b/cmd/oap/main.go @@ -16,7 +16,8 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/authz/spicedb/relsource/imports" // completes the relsource claim table before `oap memory share` / `oap session capture`'s guarded writers are checked "github.com/authzed/openagentprimitives/pkg/platform/deplogs" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/authkind/loader" - _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated credkind.Kinds the broker dispatches to via registry.Get + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated credkind.Kinds the broker dispatches to via registry.Get + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" // register the "onepassword" directcred.Source the onepassword-service-account setup flow's Verify dispatches to via directcred.Get _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/loader" ) diff --git a/config/crds/agentprimitives.authzed.com_agentsessions.yaml b/config/crds/agentprimitives.authzed.com_agentsessions.yaml index 15127b7e..7b41d461 100644 --- a/config/crds/agentprimitives.authzed.com_agentsessions.yaml +++ b/config/crds/agentprimitives.authzed.com_agentsessions.yaml @@ -2672,18 +2672,80 @@ spec: description: BrowserPodName is the per-session browser pod's name. type: string + catalog: + description: |- + Catalog is the bound identity's directCredentials login references, + listed once per session start and again on wake and carried forward in + between (CatalogFingerprint gates a re-list on a rotated backing token). + Each entry carries only {id, label, hosts} -- never a username, password, + or OTP. Empty when the toolbox binds no identity. + items: + description: |- + BrowserLoginRef is one login the bound identity's directcred source lists, + snapshotted onto status so Phase C's form filler can offer it to a human. + It carries NOTHING from the login item's fields -- only the opaque ID, a + display label, and the hosts it matches (directcred.Ref's own security + contract). No username, password, OTP, or seed ever appears here, which is + what lets the whole status be logged and shown in a UI. + properties: + hosts: + description: Hosts is the set of hostname patterns this + login applies to. + items: + description: |- + BrowserLoginHost is one hostname pattern a BrowserLoginRef is scoped to -- + the status-plane projection of a directcred.Host. A deliberately minimal + {Host, Exact} pair: distinct from BrowserSite (whose user-input + Pattern/MaxLength markers do not belong on an operator-derived status field) + and from BrowserEffectiveSite (which additionally carries a Source). + properties: + exact: + description: |- + Exact is true when Host must match the page host exactly; false when it + also matches subdomains. + type: boolean + host: + description: Host is the canonical hostname this + login applies to. + type: string + required: + - host + type: object + type: array + id: + description: |- + ID is opaque to everyone but the directcred Source that issued it; + callers must not parse or infer structure from it. + type: string + label: + description: Label is the display name shown to a human + picking a login. + type: string + required: + - id + type: object + type: array + catalogFingerprint: + description: |- + CatalogFingerprint is the hex SHA-256 of the credential's token-Secret + value at the time Catalog was listed -- the set-once-per-token key the + carry-forward uses to re-list only when the backing token rotates. A + vault-less secret source has no token key, so this is constant there and + the catalog carries forward until a wake. It is a digest, never the token. + type: string effectiveSites: description: |- EffectiveSites is the resolved site list the running browser's egress - is actually evaluated against -- in Phase A, a straight carry-through - of Spec.Access.Sites with Source "explicit". + is actually evaluated against: Spec.Access.Sites (Source "explicit") + unioned with the bound identity's catalog hosts (Source "identity") when + spec.access.identitySites is set. items: description: |- BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's - EffectiveSites. Source records where the entry came from: Phase A only ever - writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); - "identity" is a Phase B addition once a bound identity can contribute its - own sites to the effective set. + EffectiveSites. Source records where the entry came from: "explicit" for a + BrowserAccess.Sites entry carried through verbatim, "identity" for a host + folded in from the bound identity's directCredentials catalog (only when + spec.access.identitySites is set). properties: exact: description: Exact mirrors BrowserSite.Exact for this @@ -2695,8 +2757,8 @@ spec: type: string source: description: |- - Source is where this entry came from. "explicit" only in Phase A; - "identity" arrives in Phase B. + Source is where this entry came from: "explicit" (spec.access.sites) or + "identity" (a host from the bound identity's login catalog). type: string required: - host @@ -2709,6 +2771,12 @@ spec: browser pod's gate process. format: int32 type: integer + identityName: + description: |- + IdentityName is the AgentIdentity (spec.access.identity) whose + directCredentials credential produced Catalog -- Phase C's form filler + reads it to know whose logins these are. Empty when no identity is bound. + type: string name: description: |- Name is the LLM-facing prefix (the `name` field from @@ -2757,6 +2825,28 @@ spec: Access is the site allowlist/blocklist the browser's egress is evaluated against. properties: + identity: + description: |- + Identity is the name of an AgentIdentity (in this BrowserToolbox's + namespace) carrying a directCredentials credential this browser binds + to -- a Phase B addition. Allowlist-only: the two XValidation rules on + BrowserToolbox.Spec refuse it under Mode=blocklist, because a + general-access browser has no legitimate use for a credential + reference. Identity name syntax and existence are not checked here -- + that is the Task 7 BrowserToolbox reconciler's job (Valid=False when + the reference is missing or has no directCredentials credential). + type: string + identitySites: + default: false + description: |- + IdentitySites, when true, folds every host Identity's directCredentials + catalog lists into this allowlist, alongside any explicit Sites -- a + Phase B addition. Requires Identity to be set (enforced by the second + XValidation rule on BrowserToolbox.Spec). This field only records the + bit; deriving the actual host names from the identity's source happens + in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and + is recorded onto status, not here. + type: boolean mode: description: |- Mode is "allowlist" (only Sites are reachable) or "blocklist" (every diff --git a/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml index e6216e4e..0c0dfc20 100644 --- a/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml +++ b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml @@ -69,19 +69,42 @@ spec: type: object spec: description: |- - BrowserToolboxSpec is deliberately not annotated with a CEL XValidation - rule: this type is embedded by value into AgentSession's - status.resolvedBrowserToolboxes snapshot (see ResolvedBrowserToolbox.Spec), - and a CEL rule reachable from a status path both trips the status-CEL guard - and risks the apiserver's CRD rule-cost budget across that unbounded status - array -- the same reasoning SidecarToolboxSpec documents. Field-level - markers (Enum, Pattern, default) are the validation surface here. + Two Phase B rules live here rather than on BrowserToolboxSpec/BrowserAccess + (see that type's doc comment for why): this field is the ONE place a CRD + validation marker is legal, because BrowserToolboxSpec is also embedded + by value into AgentSession.status.resolvedBrowserToolboxes[].Spec, and a + rule reachable from a status path trips both the status-CEL guard and the + apiserver's rule-cost budget across that unbounded array. Precedent: + workshop_types.go:44-66 attaches its XValidation rules the same way, on + the field, not the type. properties: access: description: |- Access is the site allowlist/blocklist the browser's egress is evaluated against. properties: + identity: + description: |- + Identity is the name of an AgentIdentity (in this BrowserToolbox's + namespace) carrying a directCredentials credential this browser binds + to -- a Phase B addition. Allowlist-only: the two XValidation rules on + BrowserToolbox.Spec refuse it under Mode=blocklist, because a + general-access browser has no legitimate use for a credential + reference. Identity name syntax and existence are not checked here -- + that is the Task 7 BrowserToolbox reconciler's job (Valid=False when + the reference is missing or has no directCredentials credential). + type: string + identitySites: + default: false + description: |- + IdentitySites, when true, folds every host Identity's directCredentials + catalog lists into this allowlist, alongside any explicit Sites -- a + Phase B addition. Requires Identity to be set (enforced by the second + XValidation rule on BrowserToolbox.Spec). This field only records the + bit; deriving the actual host names from the identity's source happens + in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and + is recorded onto status, not here. + type: boolean mode: description: |- Mode is "allowlist" (only Sites are reachable) or "blocklist" (every @@ -147,6 +170,13 @@ spec: required: - access type: object + x-kubernetes-validations: + - message: a blocklist (general-access) browser cannot carry an identity + or identitySites + rule: self.access.mode != 'blocklist' || (!has(self.access.identity) + && !self.access.identitySites) + - message: identitySites requires identity + rule: '!self.access.identitySites || has(self.access.identity)' status: description: |- BrowserToolboxStatus is intentionally minimal in Phase A: a condition set diff --git a/config/manager/role.yaml b/config/manager/role.yaml index 2384b298..1de6402c 100644 --- a/config/manager/role.yaml +++ b/config/manager/role.yaml @@ -118,6 +118,7 @@ rules: - agentsettings/status - agentuis/status - artifactrenders/status + - browsertoolboxes/status - channels/status - clusteragentsettings/status - clusteridentityproviders/status @@ -163,6 +164,7 @@ rules: resources: - agentsessions/finalizers - artifactrenders/finalizers + - browsertoolboxes/finalizers - credentialupdaterequests/finalizers - publicendpoints/finalizers - sidecartoolboxes/finalizers diff --git a/internal/cmd/operator/main.go b/internal/cmd/operator/main.go index d34192bd..bbcd61a0 100644 --- a/internal/cmd/operator/main.go +++ b/internal/cmd/operator/main.go @@ -103,6 +103,7 @@ import ( agentsessionctrl "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" "github.com/authzed/openagentprimitives/pkg/controllers/agentui" "github.com/authzed/openagentprimitives/pkg/controllers/artifactrender" + "github.com/authzed/openagentprimitives/pkg/controllers/browsertoolbox" channelctrl "github.com/authzed/openagentprimitives/pkg/controllers/channel" clusteridpctrl "github.com/authzed/openagentprimitives/pkg/controllers/clusteridentityprovider" clusterskillctrl "github.com/authzed/openagentprimitives/pkg/controllers/clusterskill" @@ -1531,6 +1532,14 @@ func run(cfg *config) { } log.Info("registered controller", "name", "SidecarToolbox") + if err := (&browsertoolbox.Reconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr); err != nil { + log.Error(err, "unable to create controller", "controller", "BrowserToolbox") + os.Exit(1) + } + log.Info("registered controller", "name", "BrowserToolbox") + if err := (&workspacesource.Reconciler{ Client: mgr.GetClient(), BaseStorageClass: cfg.workspaceBaseStorageClass, diff --git a/internal/cmd/operator/main_controllers_test.go b/internal/cmd/operator/main_controllers_test.go index 88fb1b31..a907e1c3 100644 --- a/internal/cmd/operator/main_controllers_test.go +++ b/internal/cmd/operator/main_controllers_test.go @@ -66,6 +66,7 @@ func TestOperatorRegistersExpectedControllers(t *testing.T) { "UserIdentity": true, "UserIdentityRefresh": true, "SidecarToolbox": true, + "BrowserToolbox": true, "ClusterAgentSettings": true, "AgentSettings": true, "ClusterSkillSource": true, diff --git a/internal/cmd/runner/main.go b/internal/cmd/runner/main.go index d9548ad1..cb16e8ce 100644 --- a/internal/cmd/runner/main.go +++ b/internal/cmd/runner/main.go @@ -154,6 +154,8 @@ import ( "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/githubapp" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/imports" // register the static/oauth/federated/githubApp credkind.Kinds the broker dispatches to via registry.Get "github.com/authzed/openagentprimitives/pkg/platform/identity/credresolve" + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/onepassword" // register the "onepassword" directcred.Source Phase C's browser form filler dispatches to via directcred.Get (Fetch at use time) + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" // register the "secret" directcred.Source for the same Phase C Fetch consumer "github.com/authzed/openagentprimitives/pkg/platform/identity/externaltoken" "github.com/authzed/openagentprimitives/pkg/platform/identity/federation/idjag" "github.com/authzed/openagentprimitives/pkg/platform/podspec" diff --git a/pkg/apis/v1alpha1/agentsession_types.go b/pkg/apis/v1alpha1/agentsession_types.go index 4fda1132..86b9c16e 100644 --- a/pkg/apis/v1alpha1/agentsession_types.go +++ b/pkg/apis/v1alpha1/agentsession_types.go @@ -782,21 +782,53 @@ type ResolvedSidecarToolbox struct { } // BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's -// EffectiveSites. Source records where the entry came from: Phase A only ever -// writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); -// "identity" is a Phase B addition once a bound identity can contribute its -// own sites to the effective set. +// EffectiveSites. Source records where the entry came from: "explicit" for a +// BrowserAccess.Sites entry carried through verbatim, "identity" for a host +// folded in from the bound identity's directCredentials catalog (only when +// spec.access.identitySites is set). type BrowserEffectiveSite struct { // Host is the resolved hostname, matched exactly as BrowserSite.Host. Host string `json:"host"` // Exact mirrors BrowserSite.Exact for this entry. // +optional Exact bool `json:"exact,omitempty"` - // Source is where this entry came from. "explicit" only in Phase A; - // "identity" arrives in Phase B. + // Source is where this entry came from: "explicit" (spec.access.sites) or + // "identity" (a host from the bound identity's login catalog). Source string `json:"source"` } +// BrowserLoginHost is one hostname pattern a BrowserLoginRef is scoped to -- +// the status-plane projection of a directcred.Host. A deliberately minimal +// {Host, Exact} pair: distinct from BrowserSite (whose user-input +// Pattern/MaxLength markers do not belong on an operator-derived status field) +// and from BrowserEffectiveSite (which additionally carries a Source). +type BrowserLoginHost struct { + // Host is the canonical hostname this login applies to. + Host string `json:"host"` + // Exact is true when Host must match the page host exactly; false when it + // also matches subdomains. + // +optional + Exact bool `json:"exact,omitempty"` +} + +// BrowserLoginRef is one login the bound identity's directcred source lists, +// snapshotted onto status so Phase C's form filler can offer it to a human. +// It carries NOTHING from the login item's fields -- only the opaque ID, a +// display label, and the hosts it matches (directcred.Ref's own security +// contract). No username, password, OTP, or seed ever appears here, which is +// what lets the whole status be logged and shown in a UI. +type BrowserLoginRef struct { + // ID is opaque to everyone but the directcred Source that issued it; + // callers must not parse or infer structure from it. + ID string `json:"id"` + // Label is the display name shown to a human picking a login. + // +optional + Label string `json:"label,omitempty"` + // Hosts is the set of hostname patterns this login applies to. + // +optional + Hosts []BrowserLoginHost `json:"hosts,omitempty"` +} + // ResolvedBrowserToolbox snapshots one BrowserToolbox CR for a running // AgentSession, mirroring ResolvedSidecarToolbox's shape: the LLM-facing // prefix, the frozen spec, and the operator-allocated pod/gate the runner @@ -814,11 +846,34 @@ type ResolvedBrowserToolbox struct { Spec BrowserToolboxSpec `json:"spec"` // EffectiveSites is the resolved site list the running browser's egress - // is actually evaluated against -- in Phase A, a straight carry-through - // of Spec.Access.Sites with Source "explicit". + // is actually evaluated against: Spec.Access.Sites (Source "explicit") + // unioned with the bound identity's catalog hosts (Source "identity") when + // spec.access.identitySites is set. // +optional EffectiveSites []BrowserEffectiveSite `json:"effectiveSites,omitempty"` + // Catalog is the bound identity's directCredentials login references, + // listed once per session start and again on wake and carried forward in + // between (CatalogFingerprint gates a re-list on a rotated backing token). + // Each entry carries only {id, label, hosts} -- never a username, password, + // or OTP. Empty when the toolbox binds no identity. + // +optional + Catalog []BrowserLoginRef `json:"catalog,omitempty"` + + // IdentityName is the AgentIdentity (spec.access.identity) whose + // directCredentials credential produced Catalog -- Phase C's form filler + // reads it to know whose logins these are. Empty when no identity is bound. + // +optional + IdentityName string `json:"identityName,omitempty"` + + // CatalogFingerprint is the hex SHA-256 of the credential's token-Secret + // value at the time Catalog was listed -- the set-once-per-token key the + // carry-forward uses to re-list only when the backing token rotates. A + // vault-less secret source has no token key, so this is constant there and + // the catalog carries forward until a wake. It is a digest, never the token. + // +optional + CatalogFingerprint string `json:"catalogFingerprint,omitempty"` + // BrowserPodName is the per-session browser pod's name. // +optional BrowserPodName string `json:"browserPodName,omitempty"` diff --git a/pkg/apis/v1alpha1/browsertoolbox_types.go b/pkg/apis/v1alpha1/browsertoolbox_types.go index 36abfb51..aab4868b 100644 --- a/pkg/apis/v1alpha1/browsertoolbox_types.go +++ b/pkg/apis/v1alpha1/browsertoolbox_types.go @@ -32,6 +32,16 @@ type BrowserToolbox struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` + // Two Phase B rules live here rather than on BrowserToolboxSpec/BrowserAccess + // (see that type's doc comment for why): this field is the ONE place a CRD + // validation marker is legal, because BrowserToolboxSpec is also embedded + // by value into AgentSession.status.resolvedBrowserToolboxes[].Spec, and a + // rule reachable from a status path trips both the status-CEL guard and the + // apiserver's rule-cost budget across that unbounded array. Precedent: + // workshop_types.go:44-66 attaches its XValidation rules the same way, on + // the field, not the type. + // +kubebuilder:validation:XValidation:rule="self.access.mode != 'blocklist' || (!has(self.access.identity) && !self.access.identitySites)",message="a blocklist (general-access) browser cannot carry an identity or identitySites" + // +kubebuilder:validation:XValidation:rule="!self.access.identitySites || has(self.access.identity)",message="identitySites requires identity" Spec BrowserToolboxSpec `json:"spec,omitempty"` Status BrowserToolboxStatus `json:"status,omitempty"` } @@ -93,6 +103,28 @@ type BrowserAccess struct { // +listType=map // +listMapKey=host Sites []BrowserSite `json:"sites,omitempty"` + + // Identity is the name of an AgentIdentity (in this BrowserToolbox's + // namespace) carrying a directCredentials credential this browser binds + // to -- a Phase B addition. Allowlist-only: the two XValidation rules on + // BrowserToolbox.Spec refuse it under Mode=blocklist, because a + // general-access browser has no legitimate use for a credential + // reference. Identity name syntax and existence are not checked here -- + // that is the Task 7 BrowserToolbox reconciler's job (Valid=False when + // the reference is missing or has no directCredentials credential). + // +optional + Identity string `json:"identity,omitempty"` + + // IdentitySites, when true, folds every host Identity's directCredentials + // catalog lists into this allowlist, alongside any explicit Sites -- a + // Phase B addition. Requires Identity to be set (enforced by the second + // XValidation rule on BrowserToolbox.Spec). This field only records the + // bit; deriving the actual host names from the identity's source happens + // in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and + // is recorded onto status, not here. + // +optional + // +kubebuilder:default=false + IdentitySites bool `json:"identitySites,omitempty"` } // BrowserSite is one host entry in a BrowserAccess list. diff --git a/pkg/apis/v1alpha1/conditions.go b/pkg/apis/v1alpha1/conditions.go index 674d93d4..86032a40 100644 --- a/pkg/apis/v1alpha1/conditions.go +++ b/pkg/apis/v1alpha1/conditions.go @@ -1604,6 +1604,38 @@ const ( ReasonSidecarToolboxDeletionBlocked = "DeletionBlocked" ) +// BrowserToolbox reasons (Valid condition). The condition type itself lives +// here (rather than on browsertoolbox_types.go, where SidecarToolbox's own +// equivalent constant sits) because, unlike SidecarToolbox, BrowserToolbox's +// validity is purely a function of its spec plus one cross-reference -- there +// is no admission-time probe and no second condition type to anchor a +// separate home for it. +const ( + // BrowserToolboxConditionValid is True when the toolbox's spec passes + // validation. CEL already guarantees structural sanity at admission (a + // blocklist toolbox can never carry Identity/IdentitySites); this + // condition instead carries the identity cross-check: whether + // spec.access.identity, when set, names an AgentIdentity that exists in + // this BrowserToolbox's namespace, carries a directCredentials + // credential, and is itself Valid=True. + BrowserToolboxConditionValid = "Valid" + // ReasonBrowserToolboxValid is the healthy reason: either no identity is + // set, or the named identity exists, carries a directCredentials + // credential, and is itself Valid=True. + ReasonBrowserToolboxValid = "Valid" + // ReasonBrowserToolboxIdentityMissing is set when spec.access.identity + // names an AgentIdentity that does not exist in this BrowserToolbox's + // namespace. + ReasonBrowserToolboxIdentityMissing = "IdentityMissing" + // ReasonBrowserToolboxIdentityUnusable is set when the named AgentIdentity + // exists but cannot serve this browser: it carries no directCredentials + // credential (the one credential type a browser toolbox can bind to), or + // its own Valid condition is not True (backing Secret missing or + // wrong-keyed, vault verify failed, ...). The condition message says + // which, folding in the identity's own condition reason for the latter. + ReasonBrowserToolboxIdentityUnusable = "IdentityUnusable" +) + // AgentSession sidecar-related fatal reasons. const ( ReasonAgentSessionSidecarToolboxMissing = "SidecarToolboxMissing" diff --git a/pkg/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/v1alpha1/zz_generated.deepcopy.go index e4394923..1ffaccb7 100644 --- a/pkg/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/v1alpha1/zz_generated.deepcopy.go @@ -1766,6 +1766,41 @@ func (in *BrowserEffectiveSite) DeepCopy() *BrowserEffectiveSite { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserLoginHost) DeepCopyInto(out *BrowserLoginHost) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserLoginHost. +func (in *BrowserLoginHost) DeepCopy() *BrowserLoginHost { + if in == nil { + return nil + } + out := new(BrowserLoginHost) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserLoginRef) DeepCopyInto(out *BrowserLoginRef) { + *out = *in + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]BrowserLoginHost, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserLoginRef. +func (in *BrowserLoginRef) DeepCopy() *BrowserLoginRef { + if in == nil { + return nil + } + out := new(BrowserLoginRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BrowserSite) DeepCopyInto(out *BrowserSite) { *out = *in @@ -5170,6 +5205,13 @@ func (in *ResolvedBrowserToolbox) DeepCopyInto(out *ResolvedBrowserToolbox) { *out = make([]BrowserEffectiveSite, len(*in)) copy(*out, *in) } + if in.Catalog != nil { + in, out := &in.Catalog, &out.Catalog + *out = make([]BrowserLoginRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.PodFailure != nil { in, out := &in.PodFailure, &out.PodFailure *out = new(SidecarPodFailure) diff --git a/pkg/controllers/agentclass/browsertoolbox_validate_test.go b/pkg/controllers/agentclass/browsertoolbox_validate_test.go index 7db0c119..f5625f1b 100644 --- a/pkg/controllers/agentclass/browsertoolbox_validate_test.go +++ b/pkg/controllers/agentclass/browsertoolbox_validate_test.go @@ -2,11 +2,10 @@ // pkg/controllers/agentclass/browsertoolbox_validate_test.go // -// Phase A validation of AgentClass.spec.browserToolboxes: existence-only. -// Unlike validateSidecarToolboxes, this does NOT read the BrowserToolbox's -// own Valid condition -- Phase A has no BrowserToolbox reconciler to stamp -// one (it arrives in Phase B), and the CRD's field-level markers mean the -// API server already rejects a structurally-invalid BrowserToolbox. +// Phase B validation of AgentClass.spec.browserToolboxes: existence AND the +// referenced BrowserToolbox's own Valid condition, mirroring +// validateSidecarToolboxes exactly now that +// pkg/controllers/browsertoolbox.Reconciler exists to stamp that condition. package agentclass_test import ( @@ -25,11 +24,12 @@ import ( "github.com/authzed/openagentprimitives/pkg/controllers/testenv" ) -// TestAgentClassBrowserToolbox covers the two outcomes of Phase A browser -// toolbox validation: a class referencing a BrowserToolbox that does not -// exist lands Valid=False/AgentClassBrowserToolboxMissing, and creating the -// referenced BrowserToolbox then re-reconciling flips the class to -// Valid=True. +// TestAgentClassBrowserToolbox covers two outcomes of browser toolbox +// validation: a class referencing a BrowserToolbox that does not exist lands +// Valid=False/AgentClassBrowserToolboxMissing, and creating the referenced +// BrowserToolbox and stamping it Valid=True (as +// pkg/controllers/browsertoolbox.Reconciler would for an identity-less +// toolbox) then re-reconciling flips the class to Valid=True. func TestAgentClassBrowserToolbox(t *testing.T) { env := testenv.Shared(t) ctx := context.Background() @@ -58,9 +58,9 @@ func TestAgentClassBrowserToolbox(t *testing.T) { assert.Equal(t, spiceboxv1alpha1.ReasonAgentClassBrowserToolboxMissing, cond.Reason, "Valid reason") assert.Contains(t, cond.Message, "missing-btb", "message should name the missing ref") - // Now create the referenced BrowserToolbox and reconcile again -- the - // class must flip to Valid=True without needing a Valid condition on - // the BrowserToolbox itself (Phase A is existence-only). + // Now create the referenced BrowserToolbox, stamp it Valid=True (as + // pkg/controllers/browsertoolbox.Reconciler would for this identity-less + // toolbox), and reconcile again -- the class must flip to Valid=True. btb := &spiceboxv1alpha1.BrowserToolbox{ ObjectMeta: metav1.ObjectMeta{Name: "missing-btb", Namespace: "default"}, Spec: spiceboxv1alpha1.BrowserToolboxSpec{ @@ -71,6 +71,10 @@ func TestAgentClassBrowserToolbox(t *testing.T) { }, } require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + btb.Status = spiceboxv1alpha1.BrowserToolboxStatus{Conditions: []metav1.Condition{ + {Type: spiceboxv1alpha1.BrowserToolboxConditionValid, Status: metav1.ConditionTrue, Reason: spiceboxv1alpha1.ReasonBrowserToolboxValid, LastTransitionTime: metav1.Now()}, + }} + require.NoError(t, env.Client.Status().Update(ctx, btb), "stamp BrowserToolbox Valid=True") _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "ac-btb-missing"}}) require.NoError(t, err, "Reconcile after BrowserToolbox created") @@ -81,3 +85,58 @@ func TestAgentClassBrowserToolbox(t *testing.T) { hasCondition(got.Status.Conditions, spiceboxv1alpha1.AgentClassConditionValid, metav1.ConditionTrue), "want Valid=True; got conditions=%+v", got.Status.Conditions) } + +// TestAgentClassBrowserToolbox_NotValid covers a BrowserToolbox that exists +// but is itself Valid=False (e.g. pkg/controllers/browsertoolbox.Reconciler +// found its spec.access.identity unusable) -- the class must surface this as +// Valid=False/AgentClassBrowserToolboxInvalid, with the toolbox's own +// condition reason folded into the message, mirroring +// TestAgentClass_InvalidWhenSidecarToolboxNotValid's shape exactly. +func TestAgentClassBrowserToolbox_NotValid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &agentclass.Reconciler{Client: env.Client, APIReader: env.Client} + + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "llm-creds", Namespace: "default"}, + Data: map[string][]byte{"api-key": []byte("sk")}, + }), "create llm-creds") + + btb := &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: "btb-unusable", Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "example.com"}}, + Identity: "no-such-identity", + }, + }, + } + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + btb.Status = spiceboxv1alpha1.BrowserToolboxStatus{Conditions: []metav1.Condition{ + {Type: spiceboxv1alpha1.BrowserToolboxConditionValid, Status: metav1.ConditionFalse, + Reason: spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, Message: "spec.access.identity: AgentIdentity/no-such-identity not found in namespace default", + LastTransitionTime: metav1.Now()}, + }} + require.NoError(t, env.Client.Status().Update(ctx, btb), "stamp BrowserToolbox Valid=False") + + ac := newClass("ac-btb-unusable") + ac.Spec.BrowserToolboxes = []spiceboxv1alpha1.AgentClassBrowserToolboxRef{ + {Name: "web", Ref: "btb-unusable"}, + } + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "ac-btb-unusable"}}) + require.NoError(t, err, "Reconcile") + + var got spiceboxv1alpha1.AgentClass + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "ac-btb-unusable"}, &got), + "Get AgentClass") + cond := findCondition(got.Status.Conditions, spiceboxv1alpha1.AgentClassConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonAgentClassBrowserToolboxInvalid, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "btb-unusable", "message should name the BrowserToolbox") + assert.Contains(t, cond.Message, spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, + "message should fold in the toolbox's own condition reason") +} diff --git a/pkg/controllers/agentclass/controller.go b/pkg/controllers/agentclass/controller.go index 317d9ad9..d19cc7e0 100644 --- a/pkg/controllers/agentclass/controller.go +++ b/pkg/controllers/agentclass/controller.go @@ -387,12 +387,14 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return out } // Re-enqueue any AgentClass whose browserToolboxes[].ref names the - // changed BrowserToolbox. Phase A's validateBrowserToolboxes is - // existence-only (no Valid-condition read), so the race this closes is - // narrower than mapSidecarToolboxToClasses' -- a class parked at - // Valid=False reason=AgentClassBrowserToolboxMissing must re-reconcile - // once the toolbox is created, same install-ordering race as every other - // referenced CR. Namespaced, so list in its namespace only. + // changed BrowserToolbox. Phase B's validateBrowserToolboxes now also + // reads the toolbox's own Valid condition (mirroring + // mapSidecarToolboxToClasses' race exactly), so a class parked at + // Valid=False reason=AgentClassBrowserToolboxInvalid must re-reconcile + // both when the toolbox is first created AND when + // pkg/controllers/browsertoolbox.Reconciler later flips its Valid + // condition (e.g. once its named AgentIdentity becomes usable). Namespaced, + // so list in its namespace only. mapBrowserToolboxToClasses := func(ctx context.Context, o client.Object) []reconcile.Request { var list spiceboxv1alpha1.AgentClassList if err := r.Client.List(ctx, &list, client.InNamespace(o.GetNamespace())); err != nil { @@ -831,9 +833,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, fmt.Errorf("gather sidecarToolboxes: %w", stbErr) } - // Validate browserToolboxes (Phase A of the browser toolbox primitive): - // existence-only, same shape as sidecarToolboxes above but with no - // Valid-condition read -- see validateBrowserToolboxes' doc comment. + // Validate browserToolboxes: existence plus the toolbox's own Valid + // condition, same shape as sidecarToolboxes above -- see + // validateBrowserToolboxes' doc comment. if len(ac.Spec.BrowserToolboxes) > 0 { if reason, msg := validateBrowserToolboxes(ctx, reader, ac.Namespace, ac.Spec.BrowserToolboxes); reason != "" { return r.setInvalid(ctx, &ac, reason, msg) @@ -1809,12 +1811,13 @@ func validateSidecarToolboxes( } // validateBrowserToolboxes checks that each BrowserToolbox a class -// references by name exists in the class's namespace. Phase A deliberately -// stops at existence: there is no BrowserToolbox reconciler yet to stamp a -// Valid condition (that gate, and the identity cross-references it needs, -// arrive in Phase B), and the CRD's field-level markers mean the API server -// already refuses a structurally-invalid BrowserToolbox -- so unlike -// validateSidecarToolboxes, this never reads the referenced object's status. +// references by name exists in the class's namespace AND is itself +// Valid=True -- mirroring validateSidecarToolboxes' condition read exactly. +// Phase A stopped at existence because there was no BrowserToolbox +// reconciler yet to stamp a Valid condition; Phase B's +// pkg/controllers/browsertoolbox.Reconciler now cross-checks +// spec.access.identity, so a class whose toolbox names a missing or unusable +// identity must surface that here too, not just a missing CR. func validateBrowserToolboxes( ctx context.Context, reader client.Reader, @@ -1833,6 +1836,17 @@ func validateBrowserToolboxes( return spiceboxv1alpha1.ReasonAgentClassBrowserToolboxInvalid, fmt.Sprintf("browserToolboxes[%q]: %v", ref.Name, err) } + cond := meta.FindStatusCondition(btb.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + if cond == nil { + return spiceboxv1alpha1.ReasonAgentClassBrowserToolboxInvalid, + fmt.Sprintf("browserToolboxes[%q]: BrowserToolbox/%s has no Valid condition yet", + ref.Name, ref.Ref) + } + if cond.Status != metav1.ConditionTrue { + return spiceboxv1alpha1.ReasonAgentClassBrowserToolboxInvalid, + fmt.Sprintf("browserToolboxes[%q]: BrowserToolbox/%s is not Valid (%s)", + ref.Name, ref.Ref, cond.Reason) + } } return "", "" } diff --git a/pkg/controllers/agentclass/watches_integration_test.go b/pkg/controllers/agentclass/watches_integration_test.go index 83634d70..7a1733b1 100644 --- a/pkg/controllers/agentclass/watches_integration_test.go +++ b/pkg/controllers/agentclass/watches_integration_test.go @@ -528,11 +528,13 @@ func TestAgentClass_ReReconcilesOnSpiceboxToolkitChange(t *testing.T) { // BrowserToolbox watch. The referenced BrowserToolbox does not exist yet, so // the AgentClass lands Valid=False reason=AgentClassBrowserToolboxMissing; // creating the BrowserToolbox must re-reconcile the AgentClass through a -// watch. Unlike the SidecarToolbox/AgentUI watch tests, Phase A validation -// never reads a Valid condition on the BrowserToolbox (there is no -// BrowserToolbox reconciler yet), so existence alone must flip the class to -// Valid=True. Fails (times out) when SetupWithManager does not register a -// watch on BrowserToolbox. +// watch. Task 7 upgraded validateBrowserToolboxes to also read the toolbox's +// own Valid condition (mirroring the SidecarToolbox/AgentUI watch tests), so +// -- same convention as mcpServerWithValid / +// TestAgentClass_ReReconcilesOnSidecarToolboxStatusChange, since the real +// BrowserToolbox reconciler is not registered on this manager -- the +// condition is stamped directly to simulate it. Fails (times out) when +// SetupWithManager does not register a watch on BrowserToolbox. func TestAgentClass_ReReconcilesOnBrowserToolboxCreate(t *testing.T) { env := testenv.Shared(t) startManager(t, env) @@ -561,6 +563,10 @@ func TestAgentClass_ReReconcilesOnBrowserToolboxCreate(t *testing.T) { }, } require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + btb.Status = spiceboxv1alpha1.BrowserToolboxStatus{Conditions: []metav1.Condition{ + {Type: spiceboxv1alpha1.BrowserToolboxConditionValid, Status: metav1.ConditionTrue, Reason: spiceboxv1alpha1.ReasonBrowserToolboxValid, LastTransitionTime: metav1.Now()}, + }} + require.NoError(t, env.Client.Status().Update(ctx, btb), "stamp BrowserToolbox Valid=True") eventuallyValid(t, env.Client, "ac-btb-watch", metav1.ConditionTrue, "", 5*time.Second) } diff --git a/pkg/controllers/agentsession/browsercatalog_envtest_test.go b/pkg/controllers/agentsession/browsercatalog_envtest_test.go new file mode 100644 index 00000000..136627bc --- /dev/null +++ b/pkg/controllers/agentsession/browsercatalog_envtest_test.go @@ -0,0 +1,371 @@ +//go:build integration + +// pkg/controllers/agentsession/browsercatalog_envtest_test.go +// +// Integration tests for Phase B Task 8: listing a bound identity's +// directCredentials login catalog and its identity-derived sites onto +// AgentSession status. These use the REAL `secret` directcred.Source +// (host-keyed login Secret), which is envtest-friendly: its List is a pure +// function of the Secret's data with no network call, so no fake is needed. +// +// What is pinned here: +// - catalog + identity sites land: effectiveSites = explicit ∪ identity with +// the right Source values, canonical; catalog sorted by id; IdentityName +// stamped; the gate policy ConfigMap carries the identity hosts too. +// - the listing cadence: carried forward on a steady-state reconcile (a +// changed login Secret with no wake does NOT change the catalog — proof the +// Source was not re-listed), re-listed on a wake. +// - no credential VALUE ever reaches status (whole-status JSON NotContains the +// fixture password). +// - a wake whose identity has been deleted fails closed with BrowserBootFailed +// naming the identity. +package agentsession_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" + + // Register the real "secret" directcred.Source in THIS test binary — a test + // binary never links the operator's blank imports. No network: its List is + // a pure function of the host-keyed Secret's data. + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred/secretsource" +) + +// browserPassSentinel is a value no catalog or effectiveSites entry may ever +// carry. It is the password behind one login in the fixture Secret; the +// sentinel test asserts the whole marshaled status never contains it. +const browserPassSentinel = "p4ssw0rd-SENTINEL-must-never-leak" + +// browserLoginIdentity builds an AgentIdentity carrying one secret-backed +// directCredentials credential pointing at the host-keyed login Secret. +func browserLoginIdentity(name, loginSecret string) *spiceboxv1alpha1.AgentIdentity { + return &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "logins", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: directcred.SourceSecret, + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: loginSecret}, + }, + }}, + }, + } +} + +// browserLoginSecret builds a host-keyed login Secret (the `secret` backend's +// layout: ".username" / ".password") for one host, with the given +// password. +func browserLoginSecret(name, host, pass string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Data: map[string][]byte{ + host + ".username": []byte("alice@example.com"), + host + ".password": []byte(pass), + }, + } +} + +// browserToolboxBoundTo clones browserToolbox and binds it to an identity with +// identitySites on, so the identity's catalog hosts fold into the allowlist. +func browserToolboxBoundTo(name, identity string, explicitHosts ...string) *spiceboxv1alpha1.BrowserToolbox { + tb := browserToolbox(name, explicitHosts...) + tb.Spec.Access.Identity = identity + tb.Spec.Access.IdentitySites = true + return tb +} + +// firstBrowser returns the single resolved browser toolbox from a session's +// status, failing if absent. +func firstBrowser(t *testing.T, ctx context.Context, env *testenv.Env, name string) spiceboxv1alpha1.ResolvedBrowserToolbox { + t.Helper() + var got spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: name}, &got), "get session") + require.Len(t, got.Status.ResolvedBrowserToolboxes, 1, "one resolved browser toolbox in status") + return got.Status.ResolvedBrowserToolboxes[0] +} + +// TestBrowserCatalogAndIdentitySitesLand proves the happy path: the identity's +// login catalog is listed onto status (sorted, value-free), identityName is +// stamped, effectiveSites is explicit ∪ identity with the right Source values, +// and the gate policy ConfigMap carries the identity hosts the §5.2 SSO +// reachability rides on. +func TestBrowserCatalogAndIdentitySitesLand(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + // Two hosts out of canonical order in the Secret map so the sort is real. + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "btb-cat-logins", Namespace: "default"}, + Data: map[string][]byte{ + "zeta.example.com.username": []byte("alice@example.com"), + "zeta.example.com.password": []byte(browserPassSentinel), + "alpha.example.com.username": []byte("bob@example.com"), + "alpha.example.com.password": []byte("another-secret"), + }, + } + require.NoError(t, env.Client.Create(ctx, sec), "create login Secret") + + id := browserLoginIdentity("btb-cat-id", "btb-cat-logins") + require.NoError(t, env.Client.Create(ctx, id), "create AgentIdentity") + + tb := browserToolboxBoundTo("btb-cat", "btb-cat-id", "explicit.example.org") + require.NoError(t, env.Client.Create(ctx, tb), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-cat", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-cat"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-cat", "ac-browser-cat") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + reconcileToWork(t, ctx, r, "s-browser-cat") + + rt := firstBrowser(t, ctx, env, "s-browser-cat") + + // IdentityName stamped. + assert.Equal(t, "btb-cat-id", rt.IdentityName, "the bound identity is recorded for Phase C's filler") + + // Catalog: one ref per host, sorted by id, value-free. + require.Len(t, rt.Catalog, 2, "one catalog entry per host") + assert.Equal(t, "alpha.example.com", rt.Catalog[0].ID, "catalog sorted by id (alpha before zeta)") + assert.Equal(t, "zeta.example.com", rt.Catalog[1].ID) + assert.Equal(t, "alpha.example.com", rt.Catalog[0].Label, "secret backend labels by host") + require.Len(t, rt.Catalog[0].Hosts, 1) + assert.Equal(t, "alpha.example.com", rt.Catalog[0].Hosts[0].Host) + assert.False(t, rt.Catalog[0].Hosts[0].Exact, "secret backend grants per-host (non-exact)") + + // effectiveSites = explicit (first) ∪ identity (appended), canonical, Source set. + require.Len(t, rt.EffectiveSites, 3, "one explicit + two identity hosts") + assert.Equal(t, spiceboxv1alpha1.BrowserEffectiveSite{Host: "explicit.example.org", Source: "explicit"}, rt.EffectiveSites[0]) + assert.Equal(t, spiceboxv1alpha1.BrowserEffectiveSite{Host: "alpha.example.com", Source: "identity"}, rt.EffectiveSites[1]) + assert.Equal(t, spiceboxv1alpha1.BrowserEffectiveSite{Host: "zeta.example.com", Source: "identity"}, rt.EffectiveSites[2]) + + // The gate policy ConfigMap carries the identity hosts too (what §5.2 SSO + // reachability depends on). + var cm corev1.ConfigMap + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.BrowserPolicyConfigMapName(sess, "btb-cat")}, &cm), + "browser policy ConfigMap must exist") + var doc browserpolicy.PolicyDoc + require.NoError(t, json.Unmarshal([]byte(cm.Data["policy.json"]), &doc), "policy ConfigMap holds valid PolicyDoc JSON") + gateHosts := map[string]bool{} + for _, s := range doc.Sites { + gateHosts[s.Host] = true + } + assert.True(t, gateHosts["explicit.example.org"], "gate doc carries the explicit host") + assert.True(t, gateHosts["alpha.example.com"], "gate doc carries identity host alpha") + assert.True(t, gateHosts["zeta.example.com"], "gate doc carries identity host zeta") +} + +// TestBrowserStatusCarriesNoCredentialValue marshals the WHOLE AgentSession +// status to JSON and asserts it never contains the fixture password — the +// catalog is {id,label,hosts} only, and no value rests on status (spec §1.5, +// §4.4). +func TestBrowserStatusCarriesNoCredentialValue(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + require.NoError(t, env.Client.Create(ctx, browserLoginSecret("btb-leak-logins", "login.example.com", browserPassSentinel)), "create login Secret") + require.NoError(t, env.Client.Create(ctx, browserLoginIdentity("btb-leak-id", "btb-leak-logins")), "create AgentIdentity") + require.NoError(t, env.Client.Create(ctx, browserToolboxBoundTo("btb-leak", "btb-leak-id")), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-leak", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-leak"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-leak", "ac-browser-leak") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + reconcileToWork(t, ctx, r, "s-browser-leak") + + // The catalog must have landed (so we are actually testing a populated status). + rt := firstBrowser(t, ctx, env, "s-browser-leak") + require.Len(t, rt.Catalog, 1, "catalog listed, so the sentinel check is meaningful") + + var got spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "s-browser-leak"}, &got), "get session") + raw, err := json.Marshal(got.Status) + require.NoError(t, err, "marshal whole session status") + assert.NotContains(t, string(raw), browserPassSentinel, + "no credential value may rest on AgentSession status; the catalog carries only {id,label,hosts}") +} + +// TestBrowserCatalogCarryForwardAndWakeRelist pins the listing cadence without a +// call counter: ten steady-state reconciles leave the session/pod/CM +// resourceVersions AND the catalog unchanged (proof of no churn and no +// re-list); replacing the login Secret WITHOUT a wake still does not change the +// catalog (proof the Source was not re-listed); only a wake re-lists it against +// the new Secret. +func TestBrowserCatalogCarryForwardAndWakeRelist(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + loginKey := types.NamespacedName{Namespace: "default", Name: "btb-cf-logins"} + require.NoError(t, env.Client.Create(ctx, browserLoginSecret("btb-cf-logins", "app1.example.com", browserPassSentinel)), "create login Secret") + require.NoError(t, env.Client.Create(ctx, browserLoginIdentity("btb-cf-id", "btb-cf-logins")), "create AgentIdentity") + require.NoError(t, env.Client.Create(ctx, browserToolboxBoundTo("btb-cf", "btb-cf-id")), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-cf", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-cf"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-cf", "ac-browser-cf") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + key := types.NamespacedName{Namespace: "default", Name: "s-browser-cf"} + reconcileToWork(t, ctx, r, "s-browser-cf") + + // Initial list. + rt := firstBrowser(t, ctx, env, "s-browser-cf") + require.Len(t, rt.Catalog, 1) + assert.Equal(t, "app1.example.com", rt.Catalog[0].ID, "first list reflects app1") + + podName := agentsession.BrowserPodName(sess, "btb-cf") + cmName := agentsession.BrowserPolicyConfigMapName(sess, "btb-cf") + rv := func(obj interface{ GetResourceVersion() string }) string { return obj.GetResourceVersion() } + getRVs := func() (string, string, string) { + var s spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &s)) + var pod corev1.Pod + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &pod)) + var cm corev1.ConfigMap + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: cmName}, &cm)) + return rv(&s), rv(&pod), rv(&cm) + } + sRV, pRV, cmRV := getRVs() + + // Ten steady-state reconciles: no churn, no re-list. + for i := 0; i < 10; i++ { + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + } + gotS, gotP, gotCM := getRVs() + assert.Equal(t, sRV, gotS, "session must not be rewritten across 10 steady-state reconciles") + assert.Equal(t, pRV, gotP, "browser pod must not be rewritten") + assert.Equal(t, cmRV, gotCM, "policy ConfigMap must not be rewritten") + after := firstBrowser(t, ctx, env, "s-browser-cf") + assert.Equal(t, rt.Catalog, after.Catalog, "catalog carried forward unchanged (no re-list)") + + // Replace the login Secret with a DIFFERENT host, no wake. The secret + // backend has no token key, so the catalog fingerprint is unchanged and the + // Source is NOT re-listed — the catalog must still name app1. + var liveSec corev1.Secret + require.NoError(t, env.Client.Get(ctx, loginKey, &liveSec), "get login Secret to replace") + liveSec.Data = map[string][]byte{ + "app2.example.com.username": []byte("alice@example.com"), + "app2.example.com.password": []byte("rotated-secret"), + } + require.NoError(t, env.Client.Update(ctx, &liveSec), "replace login Secret data (no wake)") + reconcileToWork(t, ctx, r, "s-browser-cf") + noWake := firstBrowser(t, ctx, env, "s-browser-cf") + require.Len(t, noWake.Catalog, 1) + assert.Equal(t, "app1.example.com", noWake.Catalog[0].ID, + "a changed login Secret WITHOUT a wake must not trigger a re-list (carry-forward)") + + // Drive to the reap path (archive-swept Succeeded), reaping the browser pod + // but keeping both Secrets, then wake: the wake re-lists against the new + // Secret and the catalog now names app2. + var drive spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &drive), "get session to drive to Succeeded") + drive.Status.Phase = spiceboxv1alpha1.AgentSessionPhaseSucceeded + now := metav1.Now() + drive.Status.FinishedAt = &now + meta.SetStatusCondition(&drive.Status.Conditions, metav1.Condition{ + Type: spiceboxv1alpha1.AgentSessionConditionIdle, + Status: metav1.ConditionFalse, + Reason: spiceboxv1alpha1.ReasonAgentSessionArchived, + }) + require.NoError(t, env.Client.Status().Update(ctx, &drive), "mark session archive-swept Succeeded") + reconcileToWork(t, ctx, r, "s-browser-cf") + + var waking spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &waking), "get session to wake") + if waking.Annotations == nil { + waking.Annotations = map[string]string{} + } + waking.Annotations[spiceboxv1alpha1.AnnotationWakeRequestedAt] = time.Now().Add(time.Hour).Format(time.RFC3339Nano) + require.NoError(t, env.Client.Update(ctx, &waking), "stamp wake annotation") + reconcileToWork(t, ctx, r, "s-browser-cf") + + woke := firstBrowser(t, ctx, env, "s-browser-cf") + require.Len(t, woke.Catalog, 1) + assert.Equal(t, "app2.example.com", woke.Catalog[0].ID, "a wake re-lists against the current Secret (app2)") +} + +// TestBrowserWakeWithDeletedIdentityFailsClosed proves the fail-closed contract: +// if the bound identity is gone when a wake re-lists, the session fails with +// BrowserBootFailed naming the identity — never a silently-empty catalog. +func TestBrowserWakeWithDeletedIdentityFailsClosed(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + require.NoError(t, env.Client.Create(ctx, browserLoginSecret("btb-del-logins", "app.example.com", browserPassSentinel)), "create login Secret") + id := browserLoginIdentity("btb-del-id", "btb-del-logins") + require.NoError(t, env.Client.Create(ctx, id), "create AgentIdentity") + require.NoError(t, env.Client.Create(ctx, browserToolboxBoundTo("btb-del", "btb-del-id")), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-del", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-del"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-del", "ac-browser-del") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + key := types.NamespacedName{Namespace: "default", Name: "s-browser-del"} + reconcileToWork(t, ctx, r, "s-browser-del") + require.Len(t, firstBrowser(t, ctx, env, "s-browser-del").Catalog, 1, "catalog listed before the identity is deleted") + + // Park the session archive-swept so the wake path runs, then delete the + // identity and wake. + var drive spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &drive), "get session to drive to Succeeded") + drive.Status.Phase = spiceboxv1alpha1.AgentSessionPhaseSucceeded + now := metav1.Now() + drive.Status.FinishedAt = &now + meta.SetStatusCondition(&drive.Status.Conditions, metav1.Condition{ + Type: spiceboxv1alpha1.AgentSessionConditionIdle, + Status: metav1.ConditionFalse, + Reason: spiceboxv1alpha1.ReasonAgentSessionArchived, + }) + require.NoError(t, env.Client.Status().Update(ctx, &drive), "mark session archive-swept Succeeded") + reconcileToWork(t, ctx, r, "s-browser-del") + + require.NoError(t, env.Client.Delete(ctx, id), "delete the bound AgentIdentity") + + var waking spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &waking), "get session to wake") + if waking.Annotations == nil { + waking.Annotations = map[string]string{} + } + waking.Annotations[spiceboxv1alpha1.AnnotationWakeRequestedAt] = time.Now().Add(time.Hour).Format(time.RFC3339Nano) + require.NoError(t, env.Client.Update(ctx, &waking), "stamp wake annotation") + reconcileToWork(t, ctx, r, "s-browser-del") + + var failed spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &failed), "get session after wake") + assert.Equal(t, spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, failed.Status.FailureReason, + "a wake with a deleted identity must fail closed with BrowserBootFailed") + cond := meta.FindStatusCondition(failed.Status.Conditions, spiceboxv1alpha1.AgentSessionConditionFailed) + require.NotNil(t, cond, "a Failed condition must be set") + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Contains(t, cond.Message, "btb-del-id", "the failure must name the missing identity") +} diff --git a/pkg/controllers/agentsession/browsercatalog_fingerprint_envtest_test.go b/pkg/controllers/agentsession/browsercatalog_fingerprint_envtest_test.go new file mode 100644 index 00000000..5ce7dca6 --- /dev/null +++ b/pkg/controllers/agentsession/browsercatalog_fingerprint_envtest_test.go @@ -0,0 +1,180 @@ +//go:build integration + +// pkg/controllers/agentsession/browsercatalog_fingerprint_envtest_test.go +// +// The one carry-forward trigger browsercatalog_envtest_test.go cannot reach: +// a CHANGED token fingerprint (trigger (c)). The vault-less `secret` source +// has no token key — its fingerprint hashes zero bytes and is constant — so +// those tests prove triggers (a) no-prior and (b) wake, but the +// fingerprint-change re-list branch needs a source whose credential carries a +// real token key. A fake source registered under the "onepassword" kind +// string provides it (the REAL onepassword package is linked only via the +// operator's blank imports, never into this test binary — asserted as a +// precondition, mirroring pkg/controllers/agentidentity's +// direct_credential_test.go), and doubles as a true List CALL COUNTER, so the +// cadence is pinned by counting rather than only by the changed-sentinel +// proxy. +package agentsession_test + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" +) + +// fakeBrowserOPSource stands in for the 1Password source under the same kind +// string. List returns one Ref whose ID encodes the token value it was called +// with, so the stamped catalog shows WHICH token version produced it, and a +// counter proves HOW OFTEN the reconciler listed. It also records the VaultID +// the reconciler passed, pinning the PinnedVaults → Config.VaultID plumbing. +type fakeBrowserOPSource struct { + mu sync.Mutex + listCalls int + lastVaultID string +} + +var fakeBrowserOP = &fakeBrowserOPSource{} + +func init() { directcred.Register(fakeBrowserOP) } + +func (f *fakeBrowserOPSource) Kind() string { return directcred.SourceOnePassword } + +func (f *fakeBrowserOPSource) Verify(context.Context, directcred.Config) (directcred.Pin, error) { + return directcred.Pin{VaultID: "vault-123", VaultName: "Engineering"}, nil +} + +func (f *fakeBrowserOPSource) List(_ context.Context, cfg directcred.Config) ([]directcred.Ref, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.listCalls++ + f.lastVaultID = cfg.VaultID + tok := string(cfg.SecretData["token"]) + return []directcred.Ref{{ + ID: "login-" + tok, + Label: "Login (" + tok + ")", + Hosts: []directcred.Host{{Name: "vaultapp.example.com"}}, + }}, nil +} + +func (f *fakeBrowserOPSource) Fetch(context.Context, directcred.Config, string) (directcred.Credential, error) { + return directcred.Credential{}, nil // never called by the AgentSession reconciler +} + +func (f *fakeBrowserOPSource) snapshot() (int, string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.listCalls, f.lastVaultID +} + +// TestBrowserCatalogRelistsOnTokenFingerprintChange pins carry-forward trigger +// (c): a rotated token Secret — no wake — re-lists the catalog, and nothing +// else does. The call counter makes the cadence claim direct: exactly one List +// per distinct (start, fingerprint-change) cause across many reconciles. +func TestBrowserCatalogRelistsOnTokenFingerprintChange(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + // Precondition (mirrors the agentidentity fake's): the registered + // "onepassword" source must be THIS file's fake, not the real one. + src, ok := directcred.Get(directcred.SourceOnePassword) + require.True(t, ok, "an %q source must be registered", directcred.SourceOnePassword) + _, isFake := src.(*fakeBrowserOPSource) + require.True(t, isFake, "the REAL onepassword source must not be linked into this test binary; got %T", src) + + tokenSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "btb-fp-token", Namespace: "default"}, + Data: map[string][]byte{"token": []byte("tok-v1")}, + } + require.NoError(t, env.Client.Create(ctx, tokenSecret), "create token Secret") + + id := &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: "btb-fp-id", Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "logins", Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: directcred.SourceOnePassword, + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "btb-fp-token", Key: "token"}, + Vault: "Engineering", + }, + }}, + }, + } + require.NoError(t, env.Client.Create(ctx, id), "create AgentIdentity") + // Pin a vault on status so the reconciler's Config.VaultID plumbing is + // observable at the fake. + id.Status.PinnedVaults = []spiceboxv1alpha1.PinnedVault{{ + Credential: "logins", VaultID: "vault-123", VaultName: "Engineering", + TokenFingerprint: agentsession.SecretTokenHash([]byte("tok-v1")), + }} + require.NoError(t, env.Client.Status().Update(ctx, id), "pin vault on identity status") + + require.NoError(t, env.Client.Create(ctx, browserToolboxBoundTo("btb-fp", "btb-fp-id")), "create BrowserToolbox") + ac := classWithBrowsers("ac-browser-fp", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-fp"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + before, _ := fakeBrowserOP.snapshot() + + sess := validSession("s-browser-fp", "ac-browser-fp") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + key := types.NamespacedName{Namespace: "default", Name: "s-browser-fp"} + reconcileToWork(t, ctx, r, "s-browser-fp") + + // Trigger (a), once: reconcileToWork drove FOUR reconciles, and only the + // first to reach the browser block may list — the rest carry forward. + afterStart, vaultID := fakeBrowserOP.snapshot() + assert.Equal(t, before+1, afterStart, "exactly ONE List across the whole session start (carry-forward on every later pass)") + assert.Equal(t, "vault-123", vaultID, "the pinned VaultID must reach the source's Config") + + rt := firstBrowser(t, ctx, env, "s-browser-fp") + require.Len(t, rt.Catalog, 1) + assert.Equal(t, "login-tok-v1", rt.Catalog[0].ID, "catalog listed against token v1") + assert.Equal(t, agentsession.SecretTokenHash([]byte("tok-v1")), rt.CatalogFingerprint, + "fingerprint is the digest of the token value listed against") + + // Steady state: more reconciles, still no List. + for i := 0; i < 5; i++ { + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + } + afterSteady, _ := fakeBrowserOP.snapshot() + assert.Equal(t, afterStart, afterSteady, "steady-state reconciles must not re-list") + + // Trigger (c): rotate the token — NO wake. The changed fingerprint alone + // must force exactly one re-list, and the catalog + fingerprint update. + var liveTok corev1.Secret + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "btb-fp-token"}, &liveTok)) + liveTok.Data["token"] = []byte("tok-v2") + require.NoError(t, env.Client.Update(ctx, &liveTok), "rotate the token Secret (no wake)") + + reconcileToWork(t, ctx, r, "s-browser-fp") + afterRotate, _ := fakeBrowserOP.snapshot() + assert.Equal(t, afterSteady+1, afterRotate, "a rotated token must force exactly ONE re-list (trigger (c)), without a wake") + + rotated := firstBrowser(t, ctx, env, "s-browser-fp") + require.Len(t, rotated.Catalog, 1) + assert.Equal(t, "login-tok-v2", rotated.Catalog[0].ID, "catalog re-listed against token v2") + assert.Equal(t, agentsession.SecretTokenHash([]byte("tok-v2")), rotated.CatalogFingerprint, + "fingerprint updated to the rotated token's digest") + + // And back to carry-forward against the new fingerprint. + for i := 0; i < 5; i++ { + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + } + afterSettle, _ := fakeBrowserOP.snapshot() + assert.Equal(t, afterRotate, afterSettle, "the new fingerprint carries forward; no further re-list") +} diff --git a/pkg/controllers/agentsession/browsercatalog_transient_envtest_test.go b/pkg/controllers/agentsession/browsercatalog_transient_envtest_test.go new file mode 100644 index 00000000..3b6ffb38 --- /dev/null +++ b/pkg/controllers/agentsession/browsercatalog_transient_envtest_test.go @@ -0,0 +1,152 @@ +//go:build integration + +// pkg/controllers/agentsession/browsercatalog_transient_envtest_test.go +// +// Proves the Phase B final-review fix: a TRANSIENT failure resolving the +// browser catalog (a non-NotFound Get error on the bound AgentIdentity) must +// REQUEUE the reconcile rather than terminally failing the session via +// markBootFailed. Companion to TestBrowserWakeWithDeletedIdentityFailsClosed +// in browsercatalog_envtest_test.go, which pins the opposite (NotFound is +// terminal) half of the split in resolveBrowserCatalog / errBrowserCatalogTerminal +// (browserpod.go) and its caller in controller.go's browser block. +// +// Drives the real envtest apiserver through a SEPARATE client.WithWatch built +// from env.Cfg, wrapped with sigs.k8s.io/controller-runtime/pkg/client/interceptor +// so every Get except the one we want to fail passes straight through to the +// real apiserver -- precedent: pkg/controllers/internal/reconcile/load_test.go's +// fake.NewClientBuilder().WithInterceptorFuncs(...) table, adapted here to wrap +// a live client instead of a fake one, since the rest of Reconcile's many +// prerequisite Gets (AgentClass, AgentSession, BrowserToolbox, the browser Pod +// and its ConfigMap/NetworkPolicy) must still hit real envtest-backed objects. +package agentsession_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz/adoptguard" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/memory" + "github.com/authzed/openagentprimitives/pkg/memory/inmem" + "github.com/authzed/openagentprimitives/pkg/memory/tokens" +) + +// newBrowserReconcilerWithClient is newBrowserReconciler, but wired entirely +// off the given client.Client instead of env.Client -- so a test can hand in +// an interceptor-wrapped client and have every reader/writer in the +// reconciler (including the SecretReader/ConfigMapReader adopt-guards and the +// runner factory) see the SAME intercepted Get calls. +func newBrowserReconcilerWithClient(t *testing.T, env *testenv.Env, c client.Client) *agentsession.Reconciler { + t.Helper() + allow := func(types.NamespacedName) bool { return true } + sr := adoptguard.NewSecretReader(c, c, adoptguard.Warn, allow) + cmr := adoptguard.NewConfigMapReader(c, c, adoptguard.Warn, allow) + r := &agentsession.Reconciler{ + Client: c, + APIReader: c, + Tokens: tokens.NewRegistry(), + Memory: memory.NewLocal(inmem.NewBackend()), + SecretReader: sr, + ConfigMapReader: cmr, + } + r.RunnerFactory = &agentsession.PodRunnerFactory{ + Client: c, + RunnerImage: "agentprimitives-runner:dev", + OperatorURL: "http://op:8082", + NATSURL: "nats://spicebox-nats.agentprimitives-system.svc:4222", + SecretReader: sr, + } + r.BrowserImageRef = "ap-browser:dev" + r.Netpol = agentsession.NetpolConfig{ + Enabled: true, + OperatorNamespace: "agentprimitives-system", + NATSPort: 4222, + SpiceDBPort: 50051, + SpiceDBInCluster: true, + } + _ = env // kept for signature symmetry with newBrowserReconciler; unused otherwise + return r +} + +// TestBrowserCatalogTransientIdentityGetErrorRequeues proves the transient +// half of the split: a non-NotFound Get error fetching the bound +// AgentIdentity must come back as a plain reconcile error (requeue), and must +// NOT mark the session Failed/BrowserBootFailed. Every other object Get +// (AgentClass, AgentSession, BrowserToolbox, …) passes through to the real +// envtest apiserver unmodified; only Gets for THIS identity's name are faulted. +func TestBrowserCatalogTransientIdentityGetErrorRequeues(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + + const identityName = "btb-trans-id" + + require.NoError(t, env.Client.Create(ctx, browserLoginSecret("btb-trans-logins", "app.example.com", browserPassSentinel)), "create login Secret") + require.NoError(t, env.Client.Create(ctx, browserLoginIdentity(identityName, "btb-trans-logins")), "create AgentIdentity") + require.NoError(t, env.Client.Create(ctx, browserToolboxBoundTo("btb-trans", identityName)), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-trans", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-trans"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-trans", "ac-browser-trans") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + key := types.NamespacedName{Namespace: "default", Name: "s-browser-trans"} + + // A separate client.WithWatch over the SAME envtest apiserver, wrapped so + // every Get for the target AgentIdentity's name returns a non-NotFound + // apierror (a simulated rate-limit/503) and everything else passes through + // to the real cluster unmodified. + watchClient, err := client.NewWithWatch(env.Cfg, client.Options{Scheme: env.Scheme}) + require.NoError(t, err, "build a second WithWatch client over the shared envtest apiserver") + intercepted := interceptor.NewClient(watchClient, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*spiceboxv1alpha1.AgentIdentity); ok && key.Name == identityName { + return apierrors.NewServiceUnavailable("simulated transient identity lookup failure") + } + return c.Get(ctx, key, obj, opts...) + }, + }) + + r := newBrowserReconcilerWithClient(t, env, intercepted) + + // Drive several reconciles: earlier passes install the finalizer and + // resolve prerequisites untouched by the interceptor; once the browser + // block reaches resolveBrowserCatalog it hits the faulted identity Get on + // every subsequent pass. + var lastErr error + sawError := false + for i := 0; i < 8; i++ { + _, lastErr = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + if lastErr != nil { + sawError = true + break + } + } + require.True(t, sawError, "a transient identity Get error must eventually surface as a reconcile error (requeue), not be silently absorbed") + assert.ErrorContains(t, lastErr, identityName, "the propagated error names the identity whose Get failed") + assert.ErrorContains(t, lastErr, "resolve browser catalog", "the propagated error identifies the browser-catalog resolve step") + + // The session must NOT have been marked terminally Failed: a transient + // Get blip is retried, never a permanent session death. + var got spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &got), "get session after the faulted reconcile passes") + assert.NotEqual(t, spiceboxv1alpha1.AgentSessionPhaseFailed, got.Status.Phase, "a transient catalog error must not move the session to Failed") + assert.Empty(t, got.Status.FailureReason, "a transient catalog error must not set FailureReason") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.AgentSessionConditionFailed) + if cond != nil { + assert.NotEqual(t, metav1.ConditionTrue, cond.Status, "the Failed condition must not be set True by a transient catalog error") + } +} diff --git a/pkg/controllers/agentsession/browserpod.go b/pkg/controllers/agentsession/browserpod.go index 8c41b260..9c32b80f 100644 --- a/pkg/controllers/agentsession/browserpod.go +++ b/pkg/controllers/agentsession/browserpod.go @@ -11,7 +11,9 @@ package agentsession import ( "context" "encoding/json" + stderrors "errors" "fmt" + "sort" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -26,7 +28,10 @@ import ( spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" "github.com/authzed/openagentprimitives/pkg/authz/adoptguard" "github.com/authzed/openagentprimitives/pkg/platform/cloud" + credkindregistry "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" "github.com/authzed/openagentprimitives/pkg/platform/podspec" + "github.com/authzed/openagentprimitives/pkg/tools/adoptkit" "github.com/authzed/openagentprimitives/pkg/tools/browsergate" "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" ) @@ -100,17 +105,21 @@ func BrowserNetworkPolicyName(sess *spiceboxv1alpha1.AgentSession, ref string) s return BrowserPodName(sess, ref) + "-netpol" } -// DeriveBrowserEffectiveSites maps spec.Access.Sites 1:1 into the resolved -// EffectiveSites list, Source "explicit", in order. Phase A has no other -// site source (identity-derived sites arrive in Phase B), so this is a -// straight carry-through -- except for host canonicalization, which is NOT -// optional: browserpolicy.Match (and Decide) assume every Site.Host they -// compare against is ALREADY canonical (lowercased, no trailing dot), and -// this is the one place that guarantee is established before the value -// reaches the gate's PolicyDoc (BuildBrowserPolicyDoc) or any runner-side -// pre-check. +// DeriveBrowserEffectiveSites maps spec.Access.Sites into the resolved +// EffectiveSites list with Source "explicit", then appends identityHosts with +// Source "identity" -- the hosts a bound identity's directCredentials catalog +// contributes, which the caller passes ONLY when spec.access.identitySites is +// set (nil otherwise: the catalog is still listed onto status, but its hosts +// do not widen the allowlist). Explicit entries always precede identity ones. // -// Pure and deterministic: same spec in, same slice out, on every call -- no +// Host canonicalization is NOT optional: browserpolicy.Match (and Decide) +// assume every Site.Host they compare against is ALREADY canonical (lowercased, +// no trailing dot), and this is the one place that guarantee is established +// before the value reaches the gate's PolicyDoc (BuildBrowserPolicyDoc) or any +// runner-side pre-check. Identity hosts arrive canonical from the directcred +// source, but are re-canonicalized here so the one guarantee has one owner. +// +// Pure and deterministic: same inputs in, same slice out, on every call -- no // clock, no randomness, no I/O. That purity is what makes the status write // (ResolvedBrowserToolbox.EffectiveSites) idempotent across re-reconciles. // @@ -124,8 +133,8 @@ func BrowserNetworkPolicyName(sess *spiceboxv1alpha1.AgentSession, ref string) s // would instead WIDEN reachability for that one host, which is exactly why // the CRD pattern exists to keep this branch unreachable in the first place, // rather than relying on this fail-closed default alone. -func DeriveBrowserEffectiveSites(spec spiceboxv1alpha1.BrowserToolboxSpec) []spiceboxv1alpha1.BrowserEffectiveSite { - sites := make([]spiceboxv1alpha1.BrowserEffectiveSite, 0, len(spec.Access.Sites)) +func DeriveBrowserEffectiveSites(spec spiceboxv1alpha1.BrowserToolboxSpec, identityHosts []directcred.Host) []spiceboxv1alpha1.BrowserEffectiveSite { + sites := make([]spiceboxv1alpha1.BrowserEffectiveSite, 0, len(spec.Access.Sites)+len(identityHosts)) for _, s := range spec.Access.Sites { host, err := browserpolicy.CanonicalHost(s.Host) if err != nil { @@ -137,9 +146,243 @@ func DeriveBrowserEffectiveSites(spec spiceboxv1alpha1.BrowserToolboxSpec) []spi Source: "explicit", }) } + for _, h := range identityHosts { + host, err := browserpolicy.CanonicalHost(h.Name) + if err != nil { + continue + } + sites = append(sites, spiceboxv1alpha1.BrowserEffectiveSite{ + Host: host, + Exact: h.Exact, + Source: "identity", + }) + } return sites } +// errBrowserCatalogTerminal marks a resolveBrowserCatalog error as a +// permanent misconfiguration -- the operator must edit the CR; retrying the +// reconcile changes nothing. The caller (controller.go's browser block) does +// errors.Is(err, errBrowserCatalogTerminal) and calls markBootFailed ONLY for +// these; every other error here -- a non-NotFound Get blip on the identity or +// its backing Secret, or a List call that hit a rate limit -- is returned +// UNWRAPPED so the caller instead returns the error and the normal +// controller-runtime requeue-with-backoff retries the reconcile. This mirrors +// pkg/controllers/agentidentity/controller.go's reconcileStatus: known +// permanent-misconfiguration sentinels (ErrSecretKeyMissing, ErrExpired, …) +// route to setInvalid, while its `default:` branch on an unclassified +// checkCredentialSecret error is commented "transient (API error) — requeue". +var errBrowserCatalogTerminal = stderrors.New("browser catalog: permanent misconfiguration") + +// resolveBrowserCatalog resolves the bound identity's login catalog and the +// identity-derived hosts for one BrowserToolbox ref, honoring the +// once-per-start, again-on-wake listing cadence (spec §1.5). It returns empty +// results (no error) for a toolbox that binds no identity. +// +// Listing cadence: the directcred Source.List is called ONLY when there is no +// prior status entry for this ref, when the session is waking this reconcile, +// or when the credential's token-Secret fingerprint has changed since the +// prior list. Otherwise the prior entry's catalog and identity-derived hosts +// are carried forward verbatim, so a steady-state reconcile makes no List call +// and rewrites nothing. Values never leave the Source: List returns only +// {id, label, hosts}. +// +// Fails CLOSED on permanent misconfiguration (a missing AgentIdentity, a +// directCredentials credential or backing Secret that does not exist, an +// unregistered credential type or directcred source kind) -- those are +// wrapped with errBrowserCatalogTerminal and the caller ends the session +// rather than running the browser with a silently-empty catalog. A TRANSIENT +// failure -- a non-NotFound Get error on the identity or the Secret, or a +// source List error (a 1Password rate-limit/network blip, say) -- is +// returned unwrapped instead, so the caller requeues: a credentialed browser +// session must not die permanently over a blip at session start or wake. +func (r *Reconciler) resolveBrowserCatalog( + ctx context.Context, + sess *spiceboxv1alpha1.AgentSession, + ref string, + tbSpec spiceboxv1alpha1.BrowserToolboxSpec, + waking bool, +) (catalog []spiceboxv1alpha1.BrowserLoginRef, fingerprint, identityName string, identityHosts []directcred.Host, err error) { + identityName = tbSpec.Access.Identity + if identityName == "" { + // No identity bound: no catalog, no identity-derived sites (Phase A shape). + return nil, "", "", nil, nil + } + + var id spiceboxv1alpha1.AgentIdentity + if gErr := r.Client.Get(ctx, types.NamespacedName{Namespace: sess.Namespace, Name: identityName}, &id); gErr != nil { + if errors.IsNotFound(gErr) { + // Permanent: the toolbox's identity ref names nothing; the operator + // must fix the ref or recreate the identity. + return nil, "", identityName, nil, fmt.Errorf("get AgentIdentity %q: %w: %v", identityName, errBrowserCatalogTerminal, gErr) + } + // Transient: an apiserver Get blip. Returned unwrapped so the caller + // requeues instead of ending the session. + return nil, "", identityName, nil, fmt.Errorf("get AgentIdentity %q: %w", identityName, gErr) + } + + // The FIRST credential carrying a directCredentials block, by declaration + // order. Multiple directCredentials credentials on one identity is legal; + // the browser binds the first deterministically. Block presence -- not a + // `c.Type == "directCredentials"` literal -- is the credkind's own HasBlock + // test, the same read the browsertoolbox reconciler's hasDirectCredentials + // and the agentidentity reconciler's verifyDirectCredential use. + var cred *spiceboxv1alpha1.AgentCredential + for i := range id.Spec.Credentials { + if id.Spec.Credentials[i].DirectCredentials != nil { + cred = &id.Spec.Credentials[i] + break + } + } + if cred == nil { + // Permanent: a structural problem with the identity's spec -- no amount + // of retrying adds a directCredentials credential to it. + return nil, "", identityName, nil, fmt.Errorf("AgentIdentity %q has no directCredentials credential: %w", identityName, errBrowserCatalogTerminal) + } + + // Classify through the credkind registry (never a cred.Type switch) to + // locate the backing Secret and the key -- if any -- holding the source's + // token. The vault-less secret backend has no single token key (key ""), + // so its fingerprint is over no bytes and is constant. + k, kErr := credkindregistry.Get(cred.Type) + if kErr != nil { + // Permanent: an unregistered/unknown credential type is a structural/config + // error on the credential, not something a requeue resolves. + return nil, "", identityName, nil, fmt.Errorf("classify credential %q: %w: %v", cred.Name, errBrowserCatalogTerminal, kErr) + } + secRef := k.SecretRef(*cred) + if secRef == nil || secRef.Name == "" { + // Permanent: the credential's own shape has no Secret to read. + return nil, "", identityName, nil, fmt.Errorf("credential %q has no backing Secret: %w", cred.Name, errBrowserCatalogTerminal) + } + + secretData, rErr := r.readDirectCredSecret(ctx, sess, id.Namespace, secRef.Name) + if rErr != nil { + if errors.IsNotFound(rErr) { + // Permanent: the credential's secretRef names a Secret that does not + // exist -- a config problem on the CR, not a timing blip. + return nil, "", identityName, nil, fmt.Errorf("read credential Secret %q: %w: %v", secRef.Name, errBrowserCatalogTerminal, rErr) + } + // Transient: an apiserver Get/adopt blip reading an otherwise-present + // Secret. Returned unwrapped so the caller requeues. + return nil, "", identityName, nil, fmt.Errorf("read credential Secret %q: %w", secRef.Name, rErr) + } + + src, ok := directcred.Get(cred.DirectCredentials.Source) + if !ok { + // Permanent: the registry miss the spec calls out explicitly -- the + // binary is missing the source's blank import, or the CR names a kind + // that was never registered. Retrying never fixes either. + return nil, "", identityName, nil, fmt.Errorf( + "no directcred source registered for source kind %q (the binary may be missing its blank import under pkg/platform/identity/directcred/): %w", + cred.DirectCredentials.Source, errBrowserCatalogTerminal) + } + + cfg := directcred.Config{ + SecretData: secretData, + Vault: cred.DirectCredentials.Vault, + VaultID: pinnedVaultID(&id, cred.Name), + } + + // The set-once-per-token key: the hex SHA-256 of the token-Secret value at + // secRef.Key. Empty key (the vault-less secret source) hashes no bytes, so + // the fingerprint is constant and the catalog carries forward until a wake. + fingerprint = SecretTokenHash(secretData[secRef.Key]) + + prior := findResolvedBrowser(sess.Status.ResolvedBrowserToolboxes, ref) + if prior != nil && !waking && prior.CatalogFingerprint == fingerprint { + // Carry forward: no List call, no status churn. Identity hosts come from + // the carried catalog so the derive stays a pure function of it. + return prior.Catalog, prior.CatalogFingerprint, identityName, browserLoginHosts(prior.Catalog), nil + } + + refs, lErr := src.List(ctx, cfg) + if lErr != nil { + // Transient: a rate-limit/network blip against the directcred backend + // (e.g. 1Password). Returned unwrapped so the caller requeues. + return nil, "", identityName, nil, fmt.Errorf("list logins: %w", lErr) + } + catalog = browserLoginRefs(refs) + return catalog, fingerprint, identityName, browserLoginHosts(catalog), nil +} + +// readDirectCredSecret reads a directCredentials backing Secret's data +// verbatim. When the SecretReader guard is wired it adopts the Secret first +// (metadata-only SSA, idempotent) and reads through the guard's LIVE reader -- +// the just-adopted label is not yet in the filtered cache -- mirroring +// materializeSidecarSecret. A missing Secret surfaces as the read error, which +// the caller fails closed on. +func (r *Reconciler) readDirectCredSecret(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, ns, name string) (map[string][]byte, error) { + var reader client.Reader = r.Client + if r.SecretReader != nil { + credRef := types.NamespacedName{Namespace: ns, Name: name} + ownerRef := types.NamespacedName{Namespace: sess.Namespace, Name: sess.Name} + if aErr := adoptkit.AdoptSecret(ctx, r.SecretReader.Reader, r.Client, credRef, ownerRef, "AgentSession"); aErr != nil && !errors.IsNotFound(aErr) { + return nil, fmt.Errorf("adopt: %w", aErr) + } + reader = r.SecretReader.Reader + } + var sec corev1.Secret + if gErr := reader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &sec); gErr != nil { + return nil, gErr + } + return sec.Data, nil +} + +// findResolvedBrowser returns the prior resolved entry for ref, or nil. Used by +// the catalog carry-forward to copy an unchanged catalog rather than re-listing. +func findResolvedBrowser(list []spiceboxv1alpha1.ResolvedBrowserToolbox, ref string) *spiceboxv1alpha1.ResolvedBrowserToolbox { + for i := range list { + if list[i].Ref == ref { + return &list[i] + } + } + return nil +} + +// pinnedVaultID returns the vault id the identity's status pinned for credName, +// or "" when none is recorded (a source with no vault concept, or not yet +// verified). Fed to directcred.Config.VaultID so the list runs against the +// proven vault. +func pinnedVaultID(id *spiceboxv1alpha1.AgentIdentity, credName string) string { + for _, pv := range id.Status.PinnedVaults { + if pv.Credential == credName { + return pv.VaultID + } + } + return "" +} + +// browserLoginRefs projects the directcred source's refs onto the status type, +// sorted by ID so the stamped catalog is deterministic and the status write +// stays idempotent. Carries only {id, label, hosts} -- never a value. +func browserLoginRefs(refs []directcred.Ref) []spiceboxv1alpha1.BrowserLoginRef { + out := make([]spiceboxv1alpha1.BrowserLoginRef, 0, len(refs)) + for _, ref := range refs { + hosts := make([]spiceboxv1alpha1.BrowserLoginHost, 0, len(ref.Hosts)) + for _, h := range ref.Hosts { + hosts = append(hosts, spiceboxv1alpha1.BrowserLoginHost{Host: h.Name, Exact: h.Exact}) + } + out = append(out, spiceboxv1alpha1.BrowserLoginRef{ID: ref.ID, Label: ref.Label, Hosts: hosts}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// browserLoginHosts flattens a catalog's per-ref hosts into the directcred.Host +// slice DeriveBrowserEffectiveSites merges. Used both for a freshly-listed +// catalog and a carried-forward one, so the identity-derived sites are a pure +// function of whichever catalog is in hand. +func browserLoginHosts(catalog []spiceboxv1alpha1.BrowserLoginRef) []directcred.Host { + var hosts []directcred.Host + for _, ref := range catalog { + for _, h := range ref.Hosts { + hosts = append(hosts, directcred.Host{Name: h.Host, Exact: h.Exact}) + } + } + return hosts +} + // BuildBrowserPolicyDoc converts a ResolvedBrowserToolbox's effective sites // (plus its Access mode) into the browserpolicy.PolicyDoc JSON document the // gate reloads from the per-session ConfigMap. @@ -238,7 +481,7 @@ func BuildBrowserPod(sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.Re // HOME points at the profile emptyDir: the image's passwd entry says // /home/chrome, which is read-only at runtime, and chromium writes // $HOME/.config and $HOME/.pki on boot. - Env: []corev1.EnvVar{{Name: "HOME", Value: browserProfileMountPath}}, + Env: []corev1.EnvVar{{Name: "HOME", Value: browserProfileMountPath}}, SecurityContext: chromeSecCtx, VolumeMounts: []corev1.VolumeMount{ {Name: browserProfileVolume, MountPath: browserProfileMountPath}, diff --git a/pkg/controllers/agentsession/browserpod_test.go b/pkg/controllers/agentsession/browserpod_test.go index ea1f0a84..3848c84a 100644 --- a/pkg/controllers/agentsession/browserpod_test.go +++ b/pkg/controllers/agentsession/browserpod_test.go @@ -16,6 +16,7 @@ import ( spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" "github.com/authzed/openagentprimitives/pkg/platform/cloud" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" ) // browserToolboxSpec builds a minimal BrowserToolboxSpec for tests. @@ -35,7 +36,7 @@ func resolvedBrowserToolbox(ref string, spec spiceboxv1alpha1.BrowserToolboxSpec Name: ref, Ref: ref, Spec: spec, - EffectiveSites: agentsession.DeriveBrowserEffectiveSites(spec), + EffectiveSites: agentsession.DeriveBrowserEffectiveSites(spec, nil), GatePort: 9223, } } @@ -106,7 +107,7 @@ func TestDeriveBrowserEffectiveSites(t *testing.T) { spiceboxv1alpha1.BrowserSite{Host: "exact.example.com", Exact: true}, ) - got := agentsession.DeriveBrowserEffectiveSites(spec) + got := agentsession.DeriveBrowserEffectiveSites(spec, nil) require.Len(t, got, 2, "1:1 with spec.Access.Sites") assert.Equal(t, "aa.com", got[0].Host, "host canonicalized (lowercased)") @@ -119,13 +120,31 @@ func TestDeriveBrowserEffectiveSites(t *testing.T) { // Deterministic: repeated calls on the same input produce an identical // result -- this is what makes the status write idempotent. - again := agentsession.DeriveBrowserEffectiveSites(spec) + again := agentsession.DeriveBrowserEffectiveSites(spec, nil) assert.Equal(t, got, again) } +// TestDeriveBrowserEffectiveSites_IdentityHostsAppended proves the Phase B +// merge: identity hosts land AFTER the explicit ones, canonicalized, Source +// "identity"; explicit entries keep Source "explicit" and their order. +func TestDeriveBrowserEffectiveSites_IdentityHostsAppended(t *testing.T) { + spec := browserToolboxSpec("allowlist", + spiceboxv1alpha1.BrowserSite{Host: "explicit.example.com"}, + ) + got := agentsession.DeriveBrowserEffectiveSites(spec, []directcred.Host{ + {Name: "App.Example.com"}, // canonicalized to lowercase + {Name: "exact.example.net", Exact: true}, // Exact preserved + }) + assert.Equal(t, []spiceboxv1alpha1.BrowserEffectiveSite{ + {Host: "explicit.example.com", Source: "explicit"}, + {Host: "app.example.com", Source: "identity"}, + {Host: "exact.example.net", Exact: true, Source: "identity"}, + }, got, "explicit first, then identity hosts canonicalized in order") +} + func TestDeriveBrowserEffectiveSites_Empty(t *testing.T) { spec := browserToolboxSpec("allowlist") - assert.Empty(t, agentsession.DeriveBrowserEffectiveSites(spec)) + assert.Empty(t, agentsession.DeriveBrowserEffectiveSites(spec, nil)) } // TestDeriveBrowserEffectiveSites_DropsUncanonicalizableHost proves the @@ -142,7 +161,7 @@ func TestDeriveBrowserEffectiveSites_DropsUncanonicalizableHost(t *testing.T) { spiceboxv1alpha1.BrowserSite{Host: "EXAMPLE.org."}, // valid: lowercased + trailing dot stripped ) - got := agentsession.DeriveBrowserEffectiveSites(spec) + got := agentsession.DeriveBrowserEffectiveSites(spec, nil) assert.Equal(t, []spiceboxv1alpha1.BrowserEffectiveSite{ {Host: "aa.com", Source: "explicit"}, {Host: "example.org", Source: "explicit"}, diff --git a/pkg/controllers/agentsession/controller.go b/pkg/controllers/agentsession/controller.go index 772d9186..fd6b8dcc 100644 --- a/pkg/controllers/agentsession/controller.go +++ b/pkg/controllers/agentsession/controller.go @@ -53,6 +53,7 @@ import ( "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind" credkindregistry "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" "github.com/authzed/openagentprimitives/pkg/platform/identity/credresolve" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" "github.com/authzed/openagentprimitives/pkg/platform/identity/externaltoken" "github.com/authzed/openagentprimitives/pkg/platform/identity/federation" apnats "github.com/authzed/openagentprimitives/pkg/platform/nats" @@ -2636,6 +2637,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // does not deserve a 2s poll) while the runner surfaces the failure. browserTerminalFailure := false if len(ac.Spec.BrowserToolboxes) > 0 { + // Whether THIS reconcile is a wake. Computed before the wake handler below + // (section 5a) transitions the session, so it reflects the parked state the + // browser block sees: a wake re-lists the identity login catalog, where a + // steady-state reconcile carries the prior one forward (resolveBrowserCatalog). + browserWaking := shouldWake(&sess) for _, ref := range ac.Spec.BrowserToolboxes { var tb spiceboxv1alpha1.BrowserToolbox if err := r.Client.Get(ctx, types.NamespacedName{Namespace: sess.Namespace, Name: ref.Ref}, &tb); err != nil { @@ -2653,12 +2659,39 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // DeepCopy before freezing: tb.Spec's inner slices alias the informer // cache, and the resolved snapshot is mutated/read downstream. tbSpec := *tb.Spec.DeepCopy() + + // Resolve the bound identity's login catalog + identity-derived hosts. + // Permanent misconfiguration (missing identity, no directCredentials + // credential, unregistered credential/source kind, a backing Secret + // that does not exist) ends the session rather than running the + // browser with a silently-empty catalog. A TRANSIENT failure (a + // Get/List blip against the identity, its Secret, or the directcred + // backend) instead requeues the reconcile -- see + // errBrowserCatalogTerminal in browserpod.go. + catalog, catalogFingerprint, identityName, identityHosts, cErr := r.resolveBrowserCatalog(ctx, &sess, ref.Ref, tbSpec, browserWaking) + if cErr != nil { + if stderrors.Is(cErr, errBrowserCatalogTerminal) { + return r.markBootFailed(ctx, &sess, + spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("%s: identity %s: %v", ref.Ref, tbSpec.Access.Identity, cErr)) + } + return ctrl.Result{}, fmt.Errorf("resolve browser catalog for %q: %w", ref.Ref, cErr) + } + // Identity hosts widen the allowlist ONLY when identitySites is set; the + // catalog is listed onto status either way. + var identitySitesParam []directcred.Host + if tbSpec.Access.IdentitySites { + identitySitesParam = identityHosts + } rt := spiceboxv1alpha1.ResolvedBrowserToolbox{ - Name: ref.Name, - Ref: ref.Ref, - Spec: tbSpec, - EffectiveSites: DeriveBrowserEffectiveSites(tbSpec), - GatePort: browserGatePort, + Name: ref.Name, + Ref: ref.Ref, + Spec: tbSpec, + EffectiveSites: DeriveBrowserEffectiveSites(tbSpec, identitySitesParam), + Catalog: catalog, + CatalogFingerprint: catalogFingerprint, + IdentityName: identityName, + GatePort: browserGatePort, } // Read-before-mint: reuse an existing gate token so TokenHash — and the // token the gate pod is injected from — stays byte-stable across diff --git a/pkg/controllers/browsertoolbox/controller.go b/pkg/controllers/browsertoolbox/controller.go new file mode 100644 index 00000000..15af479d --- /dev/null +++ b/pkg/controllers/browsertoolbox/controller.go @@ -0,0 +1,187 @@ +// Package browsertoolbox reconciles BrowserToolbox CRs: an identity-less +// toolbox is Valid=True by construction (the CRD's CEL rules already +// guarantee structural sanity -- see BrowserToolbox's doc comment), and a +// toolbox that names spec.access.identity additionally cross-checks that the +// referenced AgentIdentity exists in the same namespace, carries at least +// one directCredentials credential -- the one credential type a browser +// toolbox can actually bind to -- and is itself Valid=True (a missing or +// wrong-keyed backing Secret, or a failed vault verify, makes the identity +// unusable here exactly as it does everywhere else). +package browsertoolbox + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/conditions" + apreconcile "github.com/authzed/openagentprimitives/pkg/controllers/internal/reconcile" +) + +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=browsertoolboxes,verbs=get;list;watch +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=browsertoolboxes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=browsertoolboxes/finalizers,verbs=update +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=agentidentities,verbs=get;list;watch + +// Reconciler reconciles BrowserToolbox objects. +type Reconciler struct { + Client client.Client +} + +// Reconcile validates one BrowserToolbox. The only failure modes are the +// identity cross-reference: a missing AgentIdentity, one that carries no +// directCredentials credential, or one whose own Valid condition is not +// True. Everything else about the spec is already enforced at admission by +// the CRD's field-level markers and XValidation rules. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var cr spiceboxv1alpha1.BrowserToolbox + if err := r.Client.Get(ctx, req.NamespacedName, &cr); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Stamped unconditionally, before any phase, so an invalid pass still + // records which generation it observed -- the same convention + // pkg/controllers/internal/reconcile.SetInvalid follows for every other + // controller's failure path. + cr.Status.ObservedGeneration = cr.Generation + + identityCheck := func(ctx context.Context) apreconcile.Outcome { + name := cr.Spec.Access.Identity + if name == "" { + // Allowlist-with-no-identity, or a blocklist toolbox (CEL already + // refuses Identity on a blocklist) -- nothing to cross-check. + return apreconcile.Continue() + } + var ai spiceboxv1alpha1.AgentIdentity + err := r.Client.Get(ctx, types.NamespacedName{Namespace: cr.Namespace, Name: name}, &ai) + if apierrors.IsNotFound(err) { + // Terminal until the identity appears; the AgentIdentity watch + // (mapIdentityToToolboxes) re-enqueues this toolbox on create. + conditions.SetFalse(&cr, &cr.Status.Conditions, + spiceboxv1alpha1.BrowserToolboxConditionValid, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, + fmt.Sprintf("spec.access.identity: AgentIdentity/%s not found in namespace %s", name, cr.Namespace)) + return apreconcile.StopAfter() + } + if err != nil { + // Transient API error: fail closed WITHOUT stamping a terminal + // condition -- FailWith makes controller-runtime requeue with + // backoff, which is the only self-heal this reconciler has. + // Collapsing this into IdentityMissing (the way sidecartoolbox's + // classCheck collapses its class Get) would park the toolbox + // permanently: that reconciler revalidates every 5m, this one sets + // no RequeueAfter, and the watch cannot fire for an identity that + // never changed. + return apreconcile.FailWith(fmt.Errorf("get AgentIdentity %q: %w", name, err)) + } + // Two unusable modes, checked shape-first: an identity with no + // directCredentials block can NEVER serve a browser, no matter how + // healthy it is, so that message leads even when the identity is also + // Valid=False -- it names the deeper, generation-stable problem the + // toolbox author must fix. The condition read follows: a right-shaped + // identity whose own reconciler has it at Valid=False (backing Secret + // missing/wrong-keyed, vault verify failed) is unusable TODAY, with a + // message that folds in the identity's own reason. The AgentIdentity + // watch re-fires on its status flips, so recovery self-heals. + if !hasDirectCredentials(&ai) { + conditions.SetFalse(&cr, &cr.Status.Conditions, + spiceboxv1alpha1.BrowserToolboxConditionValid, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, + fmt.Sprintf("spec.access.identity: AgentIdentity/%s has no directCredentials credential", name)) + return apreconcile.StopAfter() + } + cond := meta.FindStatusCondition(ai.Status.Conditions, spiceboxv1alpha1.AgentIdentityConditionValid) + if cond == nil { + conditions.SetFalse(&cr, &cr.Status.Conditions, + spiceboxv1alpha1.BrowserToolboxConditionValid, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, + fmt.Sprintf("spec.access.identity: AgentIdentity/%s has no Valid condition yet", name)) + return apreconcile.StopAfter() + } + if cond.Status != metav1.ConditionTrue { + conditions.SetFalse(&cr, &cr.Status.Conditions, + spiceboxv1alpha1.BrowserToolboxConditionValid, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, + fmt.Sprintf("spec.access.identity: AgentIdentity/%s is not Valid (%s)", name, cond.Reason)) + return apreconcile.StopAfter() + } + return apreconcile.Continue() + } + + finalize := func(_ context.Context) apreconcile.Outcome { + conditions.SetTrue(&cr, &cr.Status.Conditions, + spiceboxv1alpha1.BrowserToolboxConditionValid, + spiceboxv1alpha1.ReasonBrowserToolboxValid) + return apreconcile.Continue() + } + + phases := []apreconcile.Phase{identityCheck, finalize} + if err := apreconcile.RunPhases(ctx, r.Client, &cr, phases); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// hasDirectCredentials reports whether ai carries at least one credential of +// type=directCredentials -- the one credential type a BrowserToolbox can +// bind to (see AgentCredential.DirectCredentials's doc comment: it never +// flows through the broker, so every other credential type is unusable here +// even if present). +// +// The test is union-block presence, not `c.Type == "directCredentials"`: +// comparing against the type literal outside pkg/platform/identity/credkind +// is exactly the branching TestNoCredentialTypeSwitchesOutsideCredkind +// forbids. Block presence is that kind's own HasBlock definition, it is the +// same read the agentidentity controller's verifyDirectCredential uses, and +// exclusivity (block set iff the declared type owns it) is validated by the +// AgentIdentity reconciler via registry.ValidateExclusive -- so for any +// identity that reconciles Valid the two reads agree. +func hasDirectCredentials(ai *spiceboxv1alpha1.AgentIdentity) bool { + for _, c := range ai.Spec.Credentials { + if c.DirectCredentials != nil { + return true + } + } + return false +} + +// SetupWithManager registers the reconciler, watching AgentIdentity so that +// an identity created, updated (e.g. a directCredentials credential added), +// or deleted after the BrowserToolbox re-validates every toolbox that names +// it -- without this watch, a toolbox parked at Valid=False would only +// self-heal on the next unrelated reconcile of the BrowserToolbox itself. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&spiceboxv1alpha1.BrowserToolbox{}). + Watches(&spiceboxv1alpha1.AgentIdentity{}, handler.EnqueueRequestsFromMapFunc(r.mapIdentityToToolboxes)). + Complete(r) +} + +// mapIdentityToToolboxes re-enqueues every BrowserToolbox whose +// spec.access.identity names the changed AgentIdentity. AgentIdentity is +// namespaced, so list in its namespace only. +func (r *Reconciler) mapIdentityToToolboxes(ctx context.Context, o client.Object) []reconcile.Request { + var list spiceboxv1alpha1.BrowserToolboxList + if err := r.Client.List(ctx, &list, client.InNamespace(o.GetNamespace())); err != nil { + log.FromContext(ctx).Info("list BrowserToolboxes for AgentIdentity watch failed; dropping re-enqueue (self-heals on next resync)", + "agentidentity", o.GetName(), "namespace", o.GetNamespace(), "err", err.Error()) + return nil + } + var out []reconcile.Request + for i := range list.Items { + if list.Items[i].Spec.Access.Identity == o.GetName() { + out = append(out, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) + } + } + return out +} diff --git a/pkg/controllers/browsertoolbox/controller_test.go b/pkg/controllers/browsertoolbox/controller_test.go new file mode 100644 index 00000000..1fc3aeb3 --- /dev/null +++ b/pkg/controllers/browsertoolbox/controller_test.go @@ -0,0 +1,239 @@ +//go:build integration + +// Direct-Reconcile envtest coverage for the BrowserToolbox reconciler's +// validity cross-checks: no identity (Valid=True by construction), a missing +// identity, and an identity that exists but carries no directCredentials +// credential. The watch-driven re-validate cases (identity created/deleted +// after the toolbox already reconciled) live in watches_integration_test.go, +// which drives the controller through a real manager instead of calling +// Reconcile directly. +package browsertoolbox_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/browsertoolbox" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" +) + +// allowlistToolbox builds a minimal, structurally-valid BrowserToolbox: +// Mode=allowlist with one site, optionally naming identity. +func allowlistToolbox(name, identity string) *spiceboxv1alpha1.BrowserToolbox { + return &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "example.com"}}, + Identity: identity, + }, + }, + } +} + +func reconcileToolbox(t *testing.T, r *browsertoolbox.Reconciler, name string) { + t.Helper() + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: "default", Name: name}, + }) + require.NoError(t, err, "Reconcile") +} + +func getToolbox(t *testing.T, env *testenv.Env, name string) *spiceboxv1alpha1.BrowserToolbox { + t.Helper() + var got spiceboxv1alpha1.BrowserToolbox + require.NoError(t, env.Client.Get(context.Background(), + types.NamespacedName{Namespace: "default", Name: name}, &got), "Get BrowserToolbox") + return &got +} + +// TestBrowserToolbox_NoIdentity_Valid covers the no-identity path: CEL +// already guarantees structural sanity, so a toolbox with no +// spec.access.identity is Valid=True by construction. +func TestBrowserToolbox_NoIdentity_Valid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + btb := allowlistToolbox("btb-no-identity", "") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-no-identity") + + got := getToolbox(t, env, "btb-no-identity") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionTrue, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxValid, cond.Reason, "Valid reason") + assert.Equal(t, got.Generation, got.Status.ObservedGeneration, "ObservedGeneration stamped") +} + +// TestBrowserToolbox_MissingIdentity_Invalid covers spec.access.identity +// naming an AgentIdentity that does not exist in the toolbox's namespace. +func TestBrowserToolbox_MissingIdentity_Invalid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + btb := allowlistToolbox("btb-missing-identity", "no-such-identity") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-missing-identity") + + got := getToolbox(t, env, "btb-missing-identity") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "no-such-identity", "message should name the missing identity") + assert.Equal(t, got.Generation, got.Status.ObservedGeneration, "ObservedGeneration stamped") +} + +// TestBrowserToolbox_IdentityWithoutDirectCredentials_Invalid covers an +// AgentIdentity that exists but carries no directCredentials credential -- +// the one credential type a BrowserToolbox can bind to. +func TestBrowserToolbox_IdentityWithoutDirectCredentials_Invalid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + ai := &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: "id-no-directcreds", Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "github", + Type: "static", + Static: &spiceboxv1alpha1.StaticCredentialSource{ + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "dummy", Key: "token"}, + }, + }}, + }, + } + require.NoError(t, env.Client.Create(ctx, ai), "create AgentIdentity (no directCredentials)") + + btb := allowlistToolbox("btb-identity-unusable", "id-no-directcreds") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-identity-unusable") + + got := getToolbox(t, env, "btb-identity-unusable") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "id-no-directcreds", "message should name the identity") + assert.Equal(t, got.Generation, got.Status.ObservedGeneration, "ObservedGeneration stamped") +} + +// directCredsIdentity creates an AgentIdentity carrying one directCredentials +// credential and stamps its Valid condition to validStatus with the given +// reason -- the agentidentity reconciler is not running in this test binary, +// so the status is stamped directly, the same convention agentclass's tests +// use for the CRs whose reconcilers they do not run. An empty validStatus +// leaves the identity with no Valid condition at all. +func directCredsIdentity(t *testing.T, env *testenv.Env, name string, validStatus metav1.ConditionStatus, reason string) *spiceboxv1alpha1.AgentIdentity { + t.Helper() + ctx := context.Background() + ai := &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "site-login", + Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "dummy", Key: "password"}, + }, + }}, + }, + } + require.NoError(t, env.Client.Create(ctx, ai), "create AgentIdentity %s", name) + if validStatus != "" { + ai.Status = spiceboxv1alpha1.AgentIdentityStatus{Conditions: []metav1.Condition{{ + Type: spiceboxv1alpha1.AgentIdentityConditionValid, Status: validStatus, + Reason: reason, LastTransitionTime: metav1.Now(), + }}} + require.NoError(t, env.Client.Status().Update(ctx, ai), "stamp AgentIdentity %s Valid=%s", name, validStatus) + } + return ai +} + +// TestBrowserToolbox_IdentityWithDirectCredentials_Valid covers the happy +// identity path: AgentIdentity exists, carries a directCredentials +// credential, and is itself Valid=True, so the toolbox is Valid=True. +func TestBrowserToolbox_IdentityWithDirectCredentials_Valid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + directCredsIdentity(t, env, "id-with-directcreds", metav1.ConditionTrue, "OK") + + btb := allowlistToolbox("btb-identity-valid", "id-with-directcreds") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-identity-valid") + + got := getToolbox(t, env, "btb-identity-valid") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionTrue, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxValid, cond.Reason, "Valid reason") +} + +// TestBrowserToolbox_IdentityNotValid_Invalid covers an AgentIdentity that +// carries a directCredentials credential but whose OWN Valid condition is +// False (e.g. the backing Secret is missing) -- shape alone is not enough, +// and the message folds in the identity's condition reason. +func TestBrowserToolbox_IdentityNotValid_Invalid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + directCredsIdentity(t, env, "id-not-valid", metav1.ConditionFalse, "SecretMissing") + + btb := allowlistToolbox("btb-identity-not-valid", "id-not-valid") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-identity-not-valid") + + got := getToolbox(t, env, "btb-identity-not-valid") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "id-not-valid", "message should name the identity") + assert.Contains(t, cond.Message, "SecretMissing", "message should fold in the identity's own condition reason") +} + +// TestBrowserToolbox_IdentityNoValidConditionYet_Invalid covers the +// install-ordering shape: the identity exists and is right-shaped but its +// reconciler has not stamped any Valid condition yet. Fail closed as +// Unusable; the AgentIdentity watch re-fires when the condition lands. +func TestBrowserToolbox_IdentityNoValidConditionYet_Invalid(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &browsertoolbox.Reconciler{Client: env.Client} + + directCredsIdentity(t, env, "id-no-condition", "", "") + + btb := allowlistToolbox("btb-identity-no-cond", "id-no-condition") + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + reconcileToolbox(t, r, "btb-identity-no-cond") + + got := getToolbox(t, env, "btb-identity-no-cond") + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "no Valid condition yet", "message should say the condition is absent") +} diff --git a/pkg/controllers/browsertoolbox/sharedenv_test.go b/pkg/controllers/browsertoolbox/sharedenv_test.go new file mode 100644 index 00000000..899b47e5 --- /dev/null +++ b/pkg/controllers/browsertoolbox/sharedenv_test.go @@ -0,0 +1,12 @@ +//go:build integration + +package browsertoolbox_test + +import ( + "os" + "testing" + + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" +) + +func TestMain(m *testing.M) { os.Exit(testenv.RunPackage(m)) } diff --git a/pkg/controllers/browsertoolbox/watches_integration_test.go b/pkg/controllers/browsertoolbox/watches_integration_test.go new file mode 100644 index 00000000..ee5f43f9 --- /dev/null +++ b/pkg/controllers/browsertoolbox/watches_integration_test.go @@ -0,0 +1,194 @@ +//go:build integration + +// Watch-wiring integration test for the BrowserToolbox controller. +// +// BrowserToolbox validation cross-checks spec.access.identity against a live +// AgentIdentity (identityCheck in Reconcile). Without an AgentIdentity watch, +// a toolbox that reconciled before its identity existed -- or before the +// identity carried a usable credential -- would stay parked at Valid=False +// until the next unrelated reconcile of the BrowserToolbox itself, since +// nothing about the toolbox's own spec/generation changes when the identity +// it names does. +// +// Each test drives the controller through a real manager (not a direct +// Reconcile call) so a pass is proof the watch fires, not that a test helper +// happened to call Reconcile again. +package browsertoolbox_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlconfig "sigs.k8s.io/controller-runtime/pkg/config" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/browsertoolbox" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" +) + +// startToolboxManager wires the BrowserToolbox reconciler to a real manager +// (so reconciles arrive via watches, not direct calls) and blocks until the +// cache has synced. +func startToolboxManager(t *testing.T, env *testenv.Env) { + t.Helper() + mgr, err := ctrl.NewManager(env.Cfg, ctrl.Options{ + Scheme: env.Scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + Controller: ctrlconfig.Controller{SkipNameValidation: ptr.To(true)}, + LeaderElection: false, + }) + require.NoError(t, err, "ctrl.NewManager") + require.NoError(t, (&browsertoolbox.Reconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr), "browsertoolbox.SetupWithManager") + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = mgr.Start(ctx) }() + require.True(t, mgr.GetCache().WaitForCacheSync(ctx), "cache sync") +} + +// eventuallyToolboxValid polls the named BrowserToolbox until its Valid +// condition matches. An empty wantReason matches any reason. +func eventuallyToolboxValid(t *testing.T, c client.Client, name string, want metav1.ConditionStatus, wantReason string, d time.Duration) { + t.Helper() + deadline := time.Now().Add(d) + var last *metav1.Condition + for time.Now().Before(deadline) { + var got spiceboxv1alpha1.BrowserToolbox + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: name}, &got); err == nil { + cond := meta.FindStatusCondition(got.Status.Conditions, spiceboxv1alpha1.BrowserToolboxConditionValid) + last = cond + if cond != nil && cond.Status == want && (wantReason == "" || cond.Reason == wantReason) { + return + } + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("BrowserToolbox %s never reached Valid=%s reason=%s within %v; last=%+v", name, want, wantReason, d, last) +} + +// toolboxWithIdentity creates a BrowserToolbox naming identityName. +func toolboxWithIdentity(t *testing.T, c client.Client, name, identityName string) *spiceboxv1alpha1.BrowserToolbox { + t.Helper() + btb := &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "example.com"}}, + Identity: identityName, + }, + }, + } + require.NoError(t, c.Create(context.Background(), btb), "create BrowserToolbox %s", name) + return btb +} + +// identityWithDirectCredentials creates an AgentIdentity carrying one +// directCredentials credential and stamps it Valid=validStatus -- the shape +// identityCheck accepts needs BOTH the block and the identity's own +// Valid=True, and the agentidentity reconciler is not on this manager, so +// the condition is stamped directly. +func identityWithDirectCredentials(t *testing.T, c client.Client, name string, validStatus metav1.ConditionStatus, reason string) *spiceboxv1alpha1.AgentIdentity { + t.Helper() + ai := &spiceboxv1alpha1.AgentIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentIdentitySpec{ + Credentials: []spiceboxv1alpha1.AgentCredential{{ + Name: "site-login", + Type: "directCredentials", + DirectCredentials: &spiceboxv1alpha1.DirectCredentialsSource{ + Source: "secret", + SecretRef: spiceboxv1alpha1.SecretKeyRef{Name: "dummy", Key: "password"}, + }, + }}, + }, + } + require.NoError(t, c.Create(context.Background(), ai), "create AgentIdentity %s", name) + ai.Status = spiceboxv1alpha1.AgentIdentityStatus{Conditions: []metav1.Condition{{ + Type: spiceboxv1alpha1.AgentIdentityConditionValid, Status: validStatus, + Reason: reason, LastTransitionTime: metav1.Now(), + }}} + require.NoError(t, c.Status().Update(context.Background(), ai), "stamp AgentIdentity %s Valid=%s", name, validStatus) + return ai +} + +// TestBrowserToolbox_ReReconcilesOnAgentIdentityCreate is the regression +// guard for the install-ordering race: the toolbox reconciles first against +// an AgentIdentity that does not exist yet (IdentityMissing). Creating the +// identity afterward must re-enqueue the toolbox through the AgentIdentity +// watch -- nothing about the toolbox's own spec/generation changes, so only +// a watch (not the periodic requeue, which this reconciler does not even +// set) can flip it to Valid=True. +func TestBrowserToolbox_ReReconcilesOnAgentIdentityCreate(t *testing.T) { + env := testenv.Shared(t) + startToolboxManager(t, env) + + toolboxWithIdentity(t, env.Client, "btb-watch-create", "id-watch-create") + eventuallyToolboxValid(t, env.Client, "btb-watch-create", metav1.ConditionFalse, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, 5*time.Second) + + identityWithDirectCredentials(t, env.Client, "id-watch-create", metav1.ConditionTrue, "OK") + + eventuallyToolboxValid(t, env.Client, "btb-watch-create", metav1.ConditionTrue, + spiceboxv1alpha1.ReasonBrowserToolboxValid, 10*time.Second) +} + +// TestBrowserToolbox_ReReconcilesOnAgentIdentityDelete covers the other +// direction: a toolbox that is Valid=True because its identity exists and is +// usable must fall back to Valid=False/IdentityMissing when that identity is +// deleted -- proving the watch fires on delete, not just create/update. +func TestBrowserToolbox_ReReconcilesOnAgentIdentityDelete(t *testing.T) { + env := testenv.Shared(t) + startToolboxManager(t, env) + ctx := context.Background() + + ai := identityWithDirectCredentials(t, env.Client, "id-watch-delete", metav1.ConditionTrue, "OK") + toolboxWithIdentity(t, env.Client, "btb-watch-delete", "id-watch-delete") + + eventuallyToolboxValid(t, env.Client, "btb-watch-delete", metav1.ConditionTrue, + spiceboxv1alpha1.ReasonBrowserToolboxValid, 5*time.Second) + + require.NoError(t, env.Client.Delete(ctx, ai), "delete AgentIdentity id-watch-delete") + + eventuallyToolboxValid(t, env.Client, "btb-watch-delete", metav1.ConditionFalse, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityMissing, 10*time.Second) +} + +// TestBrowserToolbox_ReReconcilesOnAgentIdentityStatusFlip proves the +// identity-validity half of the gate self-heals through the SAME watch: a +// right-shaped identity at Valid=False parks the toolbox at IdentityUnusable, +// and stamping the identity Valid=True (as its own reconciler would once the +// backing Secret appears) must flip the toolbox to Valid=True without +// anything about the toolbox itself changing -- only the AgentIdentity watch +// can deliver that reconcile. +func TestBrowserToolbox_ReReconcilesOnAgentIdentityStatusFlip(t *testing.T) { + env := testenv.Shared(t) + startToolboxManager(t, env) + ctx := context.Background() + + ai := identityWithDirectCredentials(t, env.Client, "id-watch-flip", metav1.ConditionFalse, "SecretMissing") + toolboxWithIdentity(t, env.Client, "btb-watch-flip", "id-watch-flip") + + eventuallyToolboxValid(t, env.Client, "btb-watch-flip", metav1.ConditionFalse, + spiceboxv1alpha1.ReasonBrowserToolboxIdentityUnusable, 5*time.Second) + + require.NoError(t, env.Client.Get(ctx, client.ObjectKeyFromObject(ai), ai), + "refresh AgentIdentity before status flip") + ai.Status = spiceboxv1alpha1.AgentIdentityStatus{Conditions: []metav1.Condition{{ + Type: spiceboxv1alpha1.AgentIdentityConditionValid, Status: metav1.ConditionTrue, + Reason: "OK", LastTransitionTime: metav1.Now(), + }}} + require.NoError(t, env.Client.Status().Update(ctx, ai), "flip AgentIdentity to Valid=True") + + eventuallyToolboxValid(t, env.Client, "btb-watch-flip", metav1.ConditionTrue, + spiceboxv1alpha1.ReasonBrowserToolboxValid, 10*time.Second) +} diff --git a/pkg/controllers/webhooks/agentsession/webhook.go b/pkg/controllers/webhooks/agentsession/webhook.go index 99b8a581..54f39003 100644 --- a/pkg/controllers/webhooks/agentsession/webhook.go +++ b/pkg/controllers/webhooks/agentsession/webhook.go @@ -437,6 +437,18 @@ var pinnedStatusFields = []pinnedStatusField{ "a rotated-out upstream credential; a blanked hash reads as 'no prior' and the pod is never replaced. It also " + "carries the effectiveNetworkMode/effectiveAllowedHosts snapshot a DNS-aware CNI is meant to consume", }, + { + name: "resolvedBrowserToolboxes", + get: func(s *spiceboxv1alpha1.AgentSessionStatus) any { return s.ResolvedBrowserToolboxes }, + why: "the operator's catalog carry-forward reads the PRIOR entry back on every steady-state reconcile " + + "(resolveBrowserCatalog copies catalog + catalogFingerprint forward instead of re-listing the identity's vault, " + + "and derives the identity half of effectiveSites from the persisted catalog's hosts), and " + + "BuildBrowserPolicyDoc turns effectiveSites into the gate's policy ConfigMap — so a forged catalog host " + + "persists until the next wake and WIDENS the browser's egress allowlist, and a forged catalogFingerprint " + + "suppresses the re-list that would correct it. That is a write path into the per-session policy ConfigMap, " + + "which the runner Role deliberately withholds (no configmaps write verb); the browser gate exists to bound " + + "where a prompt-injected agent can drive its browser, so the party being bounded must not hold the pen", + }, { name: "supersededBy", get: func(s *spiceboxv1alpha1.AgentSessionStatus) any { return s.SupersededBy }, @@ -586,15 +598,6 @@ var runnerWritableStatusFields = map[string]string{ "the detector step, and builds the pod from that fresh local slice, never from what is persisted. The pod's name, " + "namespace and ownerRef are derived from the session (cosidecar.BuildPod), so a forged ref cannot name someone " + "else's. This waiver depends on effectiveSettings being pinned above", - "resolvedBrowserToolboxes": "the operator rebuilds the whole slice every reconcile from the AgentClass's " + - "browserToolboxes refs + each BrowserToolbox CR, and builds the pod/NetworkPolicy/policy-ConfigMap from that " + - "fresh local entry, never from what is persisted — unlike resolvedSidecarToolboxes, nothing reads the PRIOR " + - "tokenHash back (there is no browser token rotation: mintBrowserToken reads the per-session gate-token Secret " + - "itself, so tokenHash is recomputed from the Secret in hand, not trusted from status). Every object it names " + - "(pod, token Secret, ConfigMap, NetworkPolicy) is derived deterministically from the session + ref, so a forged " + - "ref cannot name another session's; the runner reading its OWN entry to reach its browser pod would only mislead " + - "the forging session. effectiveSites, like effectiveAllowedHosts, is a DNS-aware-CNI advisory recomputed from the " + - "CR each reconcile — a forged value is overwritten and, at worst, self-scoped", "interactorTuplesWritten": "the controller's dedup cache of subjects for whom the idempotent " + "agentclass#interactor SpiceDB touch has already landed (writeInteractorTuples); the runner never writes it. " + "can_personalize is derived from the LIVE relationship, not from this cache, so a forged entry grants nothing — " + diff --git a/pkg/controllers/webhooks/agentsession/webhook_test.go b/pkg/controllers/webhooks/agentsession/webhook_test.go index 8e4ddb5d..e18c9650 100644 --- a/pkg/controllers/webhooks/agentsession/webhook_test.go +++ b/pkg/controllers/webhooks/agentsession/webhook_test.go @@ -89,6 +89,16 @@ func session(muts ...func(*spiceboxv1alpha1.AgentSession)) *spiceboxv1alpha1.Age }, PassthroughCredHashes: map[string]string{"linear-oauth": "sha256:abcd"}, ResolvedSidecarToolboxes: []spiceboxv1alpha1.ResolvedSidecarToolbox{{Ref: "k8s-proxy", SecretTokenHash: "sha256:beef"}}, + // One browser entry with a catalog, so a case can perturb the + // EXISTING carry-forward inputs (catalog hosts, fingerprint) — the + // realistic forgery — rather than only appending an entry a rebuild + // would drop. + ResolvedBrowserToolboxes: []spiceboxv1alpha1.ResolvedBrowserToolbox{{ + Name: "web", Ref: "demo-browser", IdentityName: "demo-identity", + EffectiveSites: []spiceboxv1alpha1.BrowserEffectiveSite{{Host: "app.example.com", Source: "identity"}}, + Catalog: []spiceboxv1alpha1.BrowserLoginRef{{ID: "app.example.com", Label: "app.example.com", Hosts: []spiceboxv1alpha1.BrowserLoginHost{{Host: "app.example.com"}}}}, + CatalogFingerprint: "sha256:cafe", + }}, // A non-nil allowedToolkits is the admin ceiling in force; nilling it // is what ToolkitAllowed reads as "unconstrained". EffectiveSettings: &spiceboxv1alpha1.EffectiveSettings{ @@ -668,6 +678,28 @@ func TestRunnerCannotForgeSessionIdentity(t *testing.T) { s.Status.ResolvedSidecarToolboxes[0].SecretTokenHash = "" }, }, + { + // The carry-forward reads the persisted catalog back on every + // steady-state reconcile and derives the identity half of + // effectiveSites from its hosts, which BuildBrowserPolicyDoc turns + // into the gate's policy ConfigMap — so an injected host widens the + // browser's egress allowlist past the ConfigMap write the runner + // Role deliberately withholds. + name: "runner injects a host into its browser login catalog: denied (the carry-forward folds it into the gate's egress allowlist)", + sub: "status", + mut: func(s *spiceboxv1alpha1.AgentSession) { + s.Status.ResolvedBrowserToolboxes[0].Catalog = append( + s.Status.ResolvedBrowserToolboxes[0].Catalog, + spiceboxv1alpha1.BrowserLoginRef{ID: "evil", Label: "evil", Hosts: []spiceboxv1alpha1.BrowserLoginHost{{Host: "exfil.example.net"}}}) + }, + }, + { + name: "runner rewrites its browser catalogFingerprint: denied (a matching forged digest suppresses the re-list that would correct a forged catalog)", + sub: "status", + mut: func(s *spiceboxv1alpha1.AgentSession) { + s.Status.ResolvedBrowserToolboxes[0].CatalogFingerprint = "sha256:f0rged" + }, + }, { // One patch, any non-empty string. ReconcileRestart's first test is // `SupersededBy != ""` → clearPendingRestart, so from here on EVERY @@ -811,6 +843,17 @@ func TestLegitimateAgentSessionWritesAreAllowed(t *testing.T) { s.Status.Phase = spiceboxv1alpha1.AgentSessionPhaseRunning }, }, + { + // The fixture carries a populated resolvedBrowserToolboxes entry, so + // this proves the browser pin compares and passes an UNCHANGED entry + // — a runner's legitimate writes elsewhere in status keep working. + name: "runner writes progress with resolvedBrowserToolboxes populated and untouched: allowed (the pin refuses changes, not presence)", + user: attacker, + sub: "status", + mut: func(s *spiceboxv1alpha1.AgentSession) { + s.Status.Progress = &spiceboxv1alpha1.AgentSessionProgress{TurnCount: 3} + }, + }, { name: "operator clears status.pendingRestart: allowed (not a runner SA; the operator owns the clear)", user: "system:serviceaccount:agentprimitives-system:spicebox-operator", diff --git a/pkg/platform/identity/directcred/onepassword/sdk.go b/pkg/platform/identity/directcred/onepassword/sdk.go index ac9c53ce..78b44b7b 100644 --- a/pkg/platform/identity/directcred/onepassword/sdk.go +++ b/pkg/platform/identity/directcred/onepassword/sdk.go @@ -2,6 +2,7 @@ package onepassword import ( "context" + "fmt" opsdk "github.com/1password/onepassword-sdk-go" @@ -12,6 +13,41 @@ import ( // Paired with apimage.Version so a vault's access log identifies this project. const integrationName = "agentprimitives" +// init asserts, at process start, that source.go's plain-string bridge +// constants (fieldTypeTOTP, autofillAnywhere, autofillExactDomain) still +// match the SDK's own enum values they mirror. source.go deliberately +// duplicates these as plain strings so that file needs no SDK import (see its +// "layout follows a strict seam" doc comment) -- but that duplication is +// exactly the kind of coupling that rots silently: a future SDK rename or +// re-valued constant would otherwise desync source.go's classification (TOTP +// field detection, autofill-behavior matching) with NO error and NO panic -- +// items would just silently stop being treated as fillable or OTP-bearing. +// This panics instead, turning that into an immediate, loud failure on +// process start (and so on every test run and every build-time smoke check), +// not a feature that quietly degrades. sdk.go is the only file importing the +// SDK (package doc comment on source.go), so this is also the only file that +// CAN make this comparison. +// +// Paired with source.go's own comment at the fieldTypeTOTP/autofillAnywhere/ +// autofillExactDomain declarations, and with the analogous textual-coupling +// pin in pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go +// (that one pins Verify's error-message substrings instead of an SDK enum). +func init() { + assertSDKBridge("fieldTypeTOTP", fieldTypeTOTP, string(opsdk.ItemFieldTypeTOTP)) + assertSDKBridge("autofillAnywhere", autofillAnywhere, string(opsdk.AutofillBehaviorAnywhereOnWebsite)) + assertSDKBridge("autofillExactDomain", autofillExactDomain, string(opsdk.AutofillBehaviorExactDomain)) +} + +// assertSDKBridge panics when a source.go plain-string bridge constant has +// drifted from the SDK enum value it mirrors -- see the init() comment above. +func assertSDKBridge(name, local, sdk string) { + if local != sdk { + panic(fmt.Sprintf( + "onepassword: bridge constant %s = %q no longer matches the SDK's %q -- update source.go's plain-string constant to match the 1Password SDK enum rename", + name, local, sdk)) + } +} + // sdkClient is the real client seam implementation: the one place the // 1Password SDK is imported. Every method is an unconditionally boring // projection of an SDK call onto the package-internal neutral types in diff --git a/pkg/platform/identity/directcred/onepassword/source.go b/pkg/platform/identity/directcred/onepassword/source.go index efe3bbe1..3d2268ca 100644 --- a/pkg/platform/identity/directcred/onepassword/source.go +++ b/pkg/platform/identity/directcred/onepassword/source.go @@ -54,6 +54,14 @@ const ( // and is dropped. autofillAnywhere = "AnywhereOnWebsite" autofillExactDomain = "ExactDomain" + + // Bridge note: fieldTypeTOTP/autofillAnywhere/autofillExactDomain above + // are plain-string DUPLICATES of opsdk.ItemFieldTypeTOTP, + // opsdk.AutofillBehaviorAnywhereOnWebsite, and + // opsdk.AutofillBehaviorExactDomain. sdk.go's init() asserts each pair is + // still equal at process start -- a future SDK rename breaks that + // assertion loudly instead of silently desyncing this file's + // classification logic. Keep both sides in sync when touching either. ) // client is the package-internal seam between the mapping/Verify/List/Fetch @@ -153,6 +161,11 @@ func (s *Source) Verify(ctx context.Context, cfg directcred.Config) (directcred. return directcred.Pin{}, scrubToken(wrapErr("listing vaults", err), token) } if len(vaults) != 1 { + // Coupling note: pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go's + // vaultCountRe pattern-matches the "accessible vault, found " wording + // below to classify this failure as VerifyForbidden (vs. a network/SDK + // error). source_test.go's TestVerifyErrorTextMatchesSetupFlowCoupling + // pins this substring on the emission side -- keep both in sync. return directcred.Pin{}, fmt.Errorf( "onepassword: expected exactly one accessible vault, found %d: %v", len(vaults), vaultTitles(vaults)) @@ -244,6 +257,14 @@ func (s *Source) clientFor(ctx context.Context, cfg directcred.Config) (client, } c, err := s.newClient(ctx, token) if err != nil { + // Coupling note: wrapErr("creating client", err) below produces the + // literal prefix "onepassword: creating client:" (see wrapErr's format + // string). pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go's + // sdkClientErrPrefix constant does strings.HasPrefix against exactly + // that text to classify this failure as VerifyRejected (vs. an + // ambiguous network error). source_test.go's + // TestVerifyErrorTextMatchesSetupFlowCoupling pins this prefix on the + // emission side -- keep both in sync. return nil, token, scrubToken(wrapErr("creating client", err), token) } return c, token, nil diff --git a/pkg/platform/identity/directcred/onepassword/source_test.go b/pkg/platform/identity/directcred/onepassword/source_test.go index 66eb659d..3a20be45 100644 --- a/pkg/platform/identity/directcred/onepassword/source_test.go +++ b/pkg/platform/identity/directcred/onepassword/source_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -270,6 +271,42 @@ func TestVerifyAuthErrorNeverLeaksToken(t *testing.T) { assert.NotContains(t, err.Error(), token, "the service-account token must never appear in an error") } +// TestVerifyErrorTextMatchesSetupFlowCoupling pins the two exact substrings +// pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go's +// classifyVerifyErr depends on to tell apart WHY Verify failed, since +// Verify returns a single untyped error for every failure mode: +// +// - vaultCountRe matches "accessible vault, found " to classify a +// zero/multi-vault scoping failure as VerifyForbidden. +// - sdkClientErrPrefix matches the literal prefix "onepassword: creating +// client:" to classify an SDK client-construction failure as +// VerifyRejected. +// +// These are substring/prefix assertions, not full-message equality, so the +// pin tracks only the two fragments the setup flow actually parses. If +// either wording changes here without updating flow.go's constants, THIS +// test fails loudly on the emission side instead of the setup flow silently +// degrading every such failure to VerifyIndeterminate. +func TestVerifyErrorTextMatchesSetupFlowCoupling(t *testing.T) { + t.Run("zero/multi-vault scoping failure contains the vaultCountRe substring", func(t *testing.T) { + fake := &fakeClient{vaults: []vault{{ID: "v1", Title: "Prod"}, {ID: "v2", Title: "Staging"}}} + _, err := sourceWith(fake).Verify(context.Background(), + directcred.Config{SecretData: tokenData(), Vault: "Prod"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "accessible vault, found 2", + "flow.go's vaultCountRe (`accessible vault, found (\\d+)`) depends on this exact wording") + }) + + t.Run("SDK client-construction failure has the sdkClientErrPrefix prefix", func(t *testing.T) { + s := sourceWithClientErr(errors.New("invalid token format")) + _, err := s.Verify(context.Background(), + directcred.Config{SecretData: tokenData(), Vault: "Prod"}) + require.Error(t, err) + assert.True(t, strings.HasPrefix(err.Error(), "onepassword: creating client:"), + "flow.go's sdkClientErrPrefix does strings.HasPrefix against exactly this text; got %q", err.Error()) + }) +} + func TestVerifyMissingToken(t *testing.T) { // newClient must never even be called when the token key is absent. called := false diff --git a/pkg/platform/identity/directcred/secretsource/source.go b/pkg/platform/identity/directcred/secretsource/source.go index 839605fc..353a1ed0 100644 --- a/pkg/platform/identity/directcred/secretsource/source.go +++ b/pkg/platform/identity/directcred/secretsource/source.go @@ -111,9 +111,21 @@ type hostEntry struct { // its suffix (everything after). It performs no cross-validation (orphan // pairing, host canonicalization) -- see entriesByCanonicalHost -- so this // pass alone just explains "which key, if any, has no recognized suffix." +// +// Keys are sorted before iterating so that, when data carries more than one +// malformed key, the FIRST fault returned is always the same one across +// repeated calls -- map iteration order is randomized per Go's spec, and an +// unsorted range here reported a different offending key on every call +// against the same multi-fault Secret. func parseEntries(data map[string][]byte) (map[string]*hostEntry, error) { entries := make(map[string]*hostEntry, len(data)) - for key, value := range data { + keys := make([]string, 0, len(data)) + for key := range data { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value := data[key] idx := strings.LastIndex(key, ".") if idx <= 0 || idx == len(key)-1 { return nil, fmt.Errorf("secretsource: key %q has no recognized suffix", key) @@ -149,14 +161,26 @@ func parseEntries(data map[string][]byte) (map[string]*hostEntry, error) { // rejected rather than silently lowercased/normalized, because the // operator typed it and should fix it, not have it reinterpreted. The // returned map is keyed by that canonical host. +// +// Hosts are sorted before iterating for the same reason parseEntries sorts +// its keys: with more than one faulting host, the first-returned error must +// be deterministic across repeated calls, not whichever host Go's map +// iteration happened to visit first. func entriesByCanonicalHost(data map[string][]byte) (map[string]*hostEntry, error) { raw, err := parseEntries(data) if err != nil { return nil, err } + hosts := make([]string, 0, len(raw)) + for host := range raw { + hosts = append(hosts, host) + } + sort.Strings(hosts) + out := make(map[string]*hostEntry, len(raw)) - for host, e := range raw { + for _, host := range hosts { + e := raw[host] switch { case e.username != nil && e.password == nil: return nil, fmt.Errorf("secretsource: key %q has no matching %q key", diff --git a/pkg/platform/identity/directcred/secretsource/source_test.go b/pkg/platform/identity/directcred/secretsource/source_test.go index e886f9af..c69f6137 100644 --- a/pkg/platform/identity/directcred/secretsource/source_test.go +++ b/pkg/platform/identity/directcred/secretsource/source_test.go @@ -111,6 +111,59 @@ func TestSecretSourceList(t *testing.T) { } } +// TestSecretSourceListMultiFaultErrorIsDeterministic pins the Phase B +// final-review hardening fix: parseEntries and entriesByCanonicalHost +// (source.go) now sort their map keys before iterating, so the FIRST fault +// reported from data carrying more than one malformed key is always the +// SAME one across repeated calls -- not whichever key Go's randomized map +// iteration happened to visit first. Each subtest runs List many times over +// the SAME ≥2-fault data and asserts every call returns byte-identical error +// text naming the lexicographically-first offending key. +func TestSecretSourceListMultiFaultErrorIsDeterministic(t *testing.T) { + const repeats = 25 + + cases := []struct { + name string + data map[string][]byte + wantErr string // exact error message every repeat must match + }{ + { + // Both keys fault in parseEntries (unrecognized suffix) -- the map + // ranged there is cfg.SecretData itself. + name: "two keys with unrecognized suffix: always names the lexicographically-first", + data: map[string][]byte{ + "z.example.com.nickname": []byte("y"), + "a.example.com.nickname": []byte("x"), + }, + wantErr: `secretsource: key "a.example.com.nickname" has no recognized suffix`, + }, + { + // Both keys parse cleanly in parseEntries; the fault is in + // entriesByCanonicalHost's cross-validation -- the map ranged there + // is parseEntries' per-host result, keyed by host (no suffix). + name: "two orphan hosts: always names the lexicographically-first host", + data: map[string][]byte{ + "orphanz.example.com.password": []byte("y"), + "orphana.example.com.username": []byte("x"), + }, + wantErr: `secretsource: key "orphana.example.com.username" has no matching "orphana.example.com.password" key`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := secretsource.New() + cfg := directcred.Config{SecretData: tc.data} + + for i := 0; i < repeats; i++ { + _, err := src.List(context.Background(), cfg) + require.Error(t, err) + require.Equal(t, tc.wantErr, err.Error(), "call %d must report the same fault as every other call", i) + } + }) + } +} + func TestSecretSourceVerify(t *testing.T) { t.Run("clean data: zero Pin, no error", func(t *testing.T) { src := secretsource.New() diff --git a/pkg/platform/identity/setup/builtins/flow_contract_test.go b/pkg/platform/identity/setup/builtins/flow_contract_test.go index 892cee5a..26c2e42b 100644 --- a/pkg/platform/identity/setup/builtins/flow_contract_test.go +++ b/pkg/platform/identity/setup/builtins/flow_contract_test.go @@ -179,12 +179,13 @@ func answerFor(t *testing.T) map[string]string { "current-context: \"\"\n# sentinel-contents-must-not-be-summarised\n"), 0o600)) return map[string]string{ - "anthropic-oauth": "sk-ant-oat01-CONTRACTSENTINELVALUE", - "github-pat": "ghp_CONTRACTSENTINELVALUE0123", - "slack-bot-token": "xoxb-CONTRACTSENTINELVALUE0123", - "onepassword-scim": "op-scim-CONTRACTSENTINELVALUE", - "tailscale-authkey": "tskey-auth-kCONTRACT-SENTINELVALUE", - "kubectl-kubeconfig": kubeconfig, + "anthropic-oauth": "sk-ant-oat01-CONTRACTSENTINELVALUE", + "github-pat": "ghp_CONTRACTSENTINELVALUE0123", + "slack-bot-token": "xoxb-CONTRACTSENTINELVALUE0123", + "onepassword-scim": "op-scim-CONTRACTSENTINELVALUE", + "onepassword-service-account": "ops_CONTRACTSENTINELVALUE0123", + "tailscale-authkey": "tskey-auth-kCONTRACT-SENTINELVALUE", + "kubectl-kubeconfig": kubeconfig, } } diff --git a/pkg/platform/identity/setup/builtins/loader/loader.go b/pkg/platform/identity/setup/builtins/loader/loader.go index 9a83db44..4484f250 100644 --- a/pkg/platform/identity/setup/builtins/loader/loader.go +++ b/pkg/platform/identity/setup/builtins/loader/loader.go @@ -8,6 +8,7 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/kubectl_kubeconfig" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/oauth_mcp" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/onepassword_scim" + _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/onepassword_service_account" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/slack_bot_token" _ "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/tailscale_authkey" ) diff --git a/pkg/platform/identity/setup/builtins/loader/loader_test.go b/pkg/platform/identity/setup/builtins/loader/loader_test.go index 2e4c0a79..ef49a0c6 100644 --- a/pkg/platform/identity/setup/builtins/loader/loader_test.go +++ b/pkg/platform/identity/setup/builtins/loader/loader_test.go @@ -18,6 +18,6 @@ func TestAllBuiltinsRegistered(t *testing.T) { sort.Strings(names) assert.Equal(t, []string{ "anthropic-oauth", "github-pat", "kubectl-kubeconfig", "oauth-mcp", - "onepassword-scim", "slack-bot-token", "tailscale-authkey", + "onepassword-scim", "onepassword-service-account", "slack-bot-token", "tailscale-authkey", }, names) } diff --git a/pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go b/pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go new file mode 100644 index 00000000..304911d1 --- /dev/null +++ b/pkg/platform/identity/setup/builtins/onepassword_service_account/flow.go @@ -0,0 +1,294 @@ +// Package onepassword_service_account is the builtin Go flow for the +// onepassword-service-account provider. Points the user at 1Password's +// service-account docs, captures the pasted token, and stores it — then, on +// demand, verifies the token resolves to exactly one visible vault, which is +// the scoping invariant the browser toolbox's direct-credential path depends +// on (see pkg/platform/identity/directcred's doc comment). +// +// As a screen sequence: +// +// browser open 1Password's service-account documentation +// token say which scope to grant, take the pasted token +// +// Unlike github_pat, which collects a user-scoped PAT, this credential +// authenticates a SERVICE ACCOUNT and is deliberately scoped to read_items on +// exactly one vault. The vault's NAME is authored on the AgentIdentity's +// credential block, not asked for here — and VerifyRequest carries only the +// provider and the about-to-be-stored value, so this flow has no way to see +// it even if it wanted to. The name-match and id-pin happen controller-side, +// against live backend state, once the credential is stored. +package onepassword_service_account + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/authzed/openagentprimitives/pkg/cli/tui" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/provider" + "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins" + "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/flowscreens" + "github.com/authzed/openagentprimitives/pkg/x/credmask" +) + +// KeyToken is the State key the pasted token lands under. Stable: a caller +// answering this flow ahead of time addresses the screen by it. +const KeyToken = "token" + +// setupPage is 1Password's own service-account documentation — the page that +// walks an operator through creating one and scoping it to a single vault. +// Matches providers/onepassword-service-account.yaml's docsURL. +const setupPage = "https://developer.1password.com/docs/service-accounts/" + +// verifyFunc resolves a bearer token to the vault it is scoped to, via the +// onepassword directcred.Source. Injected so Flow.Verify never makes a +// network call in a test: New wires defaultVerify (the real registry lookup); +// NewWithVerify takes a test double. +type verifyFunc func(ctx context.Context, token string) (directcred.Pin, error) + +// Flow is the onepassword-service-account builtin. +type Flow struct { + // open opens a page in the operator's browser. INJECTED, not read from a + // package-level hook — see onepassword_scim.Flow's doc comment for why. + // init.go passes browser.Open; tests pass a recorder. + open func(string) error + + // verify resolves a token to its vault. Defaults to defaultVerify, which + // dispatches through the directcred registry to the real onepassword + // Source — the binary must blank-import + // pkg/platform/identity/directcred/onepassword for that lookup to + // succeed (cmd/oap/main.go does; see that file's import block). + verify verifyFunc +} + +// New returns a Flow that opens pages with open and verifies against the real +// onepassword directcred.Source. +func New(open func(string) error) *Flow { + return &Flow{open: open, verify: defaultVerify} +} + +// NewWithVerify returns a Flow whose Verify calls verify instead of the real +// source lookup. Test-only seam: it is how Flow.Verify is exercised without +// ever reaching the network or the 1Password SDK. +func NewWithVerify(open func(string) error, verify verifyFunc) *Flow { + if verify == nil { + verify = defaultVerify + } + return &Flow{open: open, verify: verify} +} + +// defaultVerify is verify's production value: look up the registered +// "onepassword" directcred.Source and run its Verify with the token as the +// service-account credential. No vault name is passed — this flow never has +// one to pass (see the package doc comment) — so a cfg.Vault mismatch branch +// in the source never triggers here; only the "how many vaults are visible" +// check does. +func defaultVerify(ctx context.Context, token string) (directcred.Pin, error) { + src, ok := directcred.Get(directcred.SourceOnePassword) + if !ok { + return directcred.Pin{}, errors.New( + "onepassword-service-account: no onepassword directcred source registered " + + "(the binary is missing its blank import of pkg/platform/identity/directcred/onepassword)") + } + return src.Verify(ctx, directcred.Config{SecretData: map[string][]byte{"token": []byte(token)}}) +} + +// Name returns the registry name for this flow. +func (*Flow) Name() string { return "onepassword-service-account" } + +// Screens describes the flow: open the service-account documentation, then +// take the token it describes. The browser step is a screen of its own +// because it is work rather than a question — which lets a run that cannot +// prompt refuse it up front instead of opening a tab and failing at the +// screen after it. +// +// req.Provider.Prompt is LLM-system-prompt content for the fallback agent. Do +// NOT show it to the user; the guidance below is composed from structured +// fields. +func (f *Flow) Screens(_ context.Context, req builtins.Request) ([]tui.Screen, error) { + page := flowscreens.NewBrowser(flowscreens.BrowserOpts{ + ID: "browser", + Label: "Browser", + URL: setupPage, + Guard: KeyToken, + Open: f.open, + NoteLabel: "Browser", + }) + + return []tui.Screen{ + page, + tui.NewText(tui.TextOpts{ + QuestionOpts: tui.QuestionOpts{ + ID: "token", + Label: "Token", + Key: KeyToken, + Title: "Paste the service-account token", + Guidance: func(*tui.State) string { return guidance(req, page.Err()) }, + Addresses: []tui.Address{flowscreens.DocsAddress(req.Provider)}, + NoteLabel: "1Password service-account token", + NoteValue: credmask.Mask, + }, + Check: flowscreens.TokenShapeCheck(req.Provider), + }), + }, nil +} + +// Result stores the pasted token as a bearer credential. +// +// The format check runs through provider.ValidateToken against the catalog's +// declared tokenShape (ops_...) before the emptiness guard even matters in +// practice, but the guard stays: huh's accessible renderer cannot report a +// read error, so an input script that runs out reaches here as an unanswered +// State and a nil error, which without this check would store a credential +// authenticating as nobody behind a CLI that reported success. +func (*Flow) Result(ctx context.Context, req builtins.Request, st *tui.State) error { + if st == nil { + return errors.New("onepassword-service-account: no token was supplied") + } + token := strings.TrimSpace(st.Get(KeyToken)) + if token == "" { + return errors.New("onepassword-service-account: no token was supplied") + } + if req.Provider != nil { + if err := provider.ValidateToken(*req.Provider, token); err != nil { + return fmt.Errorf("onepassword-service-account: %w", err) + } + } + if req.Store == nil { + return errors.New("onepassword-service-account: nowhere to store the token") + } + return req.Store(ctx, builtins.StoreValue{Bearer: token}) +} + +// vaultCountRe extracts the vault count from onepassword.Source.Verify's +// exact failure message for its scoping invariant (source.go: "expected +// exactly one accessible vault, found %d: %v"). This is a textual coupling to +// another package's error message, not a typed signal — Source.Verify +// returns a single error for EVERY failure mode (malformed config, SDK +// client-construction failure, a failed vaults listing, and this count +// check), so the text is the only way to tell "the backend answered but the +// scoping invariant failed" apart from "the backend never answered". If +// source.go's wording changes, this regexp needs updating alongside it. +// +// Pinned on the EMISSION side by +// pkg/platform/identity/directcred/onepassword/source_test.go's +// TestVerifyErrorTextMatchesSetupFlowCoupling, so a rewording there breaks +// CI instead of silently degrading this classification to Indeterminate. +var vaultCountRe = regexp.MustCompile(`accessible vault, found (\d+)`) + +// sdkClientErrPrefix is the literal prefix onepassword.Source.Verify puts on +// an error from constructing the 1Password SDK client (source.go: +// wrapErr("creating client", err)). The SDK validates a service-account +// token's structure locally, with no network round trip, when building the +// client — so an error at this exact step is the one case this flow can +// treat as a definitive "the backend's auth layer rejected this token" +// rather than an ambiguous network/timeout/rate-limit failure. Everything +// past this step (listing vaults) crosses the network, so a failure there +// could be either, and is reported as indeterminate rather than guessed at. +// +// Pinned on the EMISSION side by the same +// TestVerifyErrorTextMatchesSetupFlowCoupling referenced above. +const sdkClientErrPrefix = "onepassword: creating client:" + +// Verify live-checks the token via the onepassword directcred.Source, +// confirming it resolves to exactly one visible vault — the invariant the +// browser toolbox's direct-credential path depends on (see +// pkg/platform/identity/directcred's doc comment) — rather than merely that +// some endpoint accepts it. +// +// The token is never placed in Detail or Metadata verbatim: classifyVerifyErr +// redacts it from any underlying error text before building a message, as a +// second line of defense alongside onepassword.Source's own scrubToken. +func (f *Flow) Verify(ctx context.Context, req builtins.VerifyRequest) (builtins.VerifyResult, error) { + token := req.Value.Bearer + if token == "" { + return builtins.VerifyResult{ + Status: builtins.VerifyIndeterminate, + Detail: "no token was supplied to verify", + }, nil + } + + verify := f.verify + if verify == nil { + verify = defaultVerify + } + pin, err := verify(ctx, token) + if err != nil { + return classifyVerifyErr(err, token), nil + } + if pin.VaultName == "" { + return builtins.VerifyResult{ + Status: builtins.VerifyIndeterminate, + Detail: "1Password reported success but returned no vault name", + }, nil + } + return builtins.VerifyResult{ + Status: builtins.VerifyValid, + Detail: fmt.Sprintf("confirmed scoped to exactly one vault: %q", pin.VaultName), + Metadata: map[string]string{"vault": pin.VaultName}, + }, nil +} + +// classifyVerifyErr turns a verifyFn failure into a VerifyResult. See +// vaultCountRe and sdkClientErrPrefix for what each branch recognizes and +// why; anything else is reported as indeterminate rather than guessed at — +// the safe default for a failure mode this flow cannot name. +func classifyVerifyErr(err error, token string) builtins.VerifyResult { + msg := err.Error() + if token != "" { + // Defense in depth: onepassword.Source already scrubs the token from + // its own errors, but this flow never trusts that guarantee alone. + msg = strings.ReplaceAll(msg, token, "[redacted]") + } + + if m := vaultCountRe.FindStringSubmatch(msg); m != nil { + return builtins.VerifyResult{ + Status: builtins.VerifyForbidden, + Detail: fmt.Sprintf("1Password reports %s accessible vault(s) for this token; it must see exactly one", m[1]), + } + } + if strings.HasPrefix(msg, sdkClientErrPrefix) { + return builtins.VerifyResult{ + Status: builtins.VerifyRejected, + Detail: "1Password's SDK rejected this token while authenticating with it", + } + } + return builtins.VerifyResult{ + Status: builtins.VerifyIndeterminate, + Detail: "could not verify this token against 1Password: " + msg, + } +} + +// guidance is what the user reads above the field: where the token is +// minted, the single scoping requirement it must satisfy, and what this +// screen is asking for — repeated in text because a browser that refused to +// open leaves the address as the only way through. +// +// Every line is kept inside the note's column budget, which this package's +// tests assert: the address has to survive being copied, and huh wraps an +// over-long line with no sign the halves belong together. +func guidance(req builtins.Request, browserErr error) string { + var b strings.Builder + if browserErr != nil { + // The reason comes from the operating system and can be any length; on a + // line of its own, a long one cannot drag the next instruction into a wrap. + fmt.Fprintf(&b, "Your browser didn't open:\n %s\nVisit the page below yourself.\n\n", browserErr) + } + fmt.Fprintf(&b, "Create a 1Password service account for %q.\n\n", req.IdentityName) + b.WriteString("Grant it read_items on exactly ONE vault — the one\n") + b.WriteString("holding the logins this agent should use — and\n") + b.WriteString("nothing else. A token scoped to more than one vault,\n") + b.WriteString("or to write access, is rejected at verification time.\n") + b.WriteString("\nPaste the token (starts with ops_) below.\n") + if len(req.ScopeHint) > 0 { + b.WriteString("\nThe token needs:\n") + for _, line := range req.ScopeHint { + b.WriteString(" " + line + "\n") + } + } + b.WriteString("\nPage: " + setupPage + "\n") + return strings.TrimRight(b.String(), "\n") +} diff --git a/pkg/platform/identity/setup/builtins/onepassword_service_account/flow_test.go b/pkg/platform/identity/setup/builtins/onepassword_service_account/flow_test.go new file mode 100644 index 00000000..caffc31b --- /dev/null +++ b/pkg/platform/identity/setup/builtins/onepassword_service_account/flow_test.go @@ -0,0 +1,340 @@ +package onepassword_service_account_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/cli/tui" + "github.com/authzed/openagentprimitives/pkg/platform/identity/directcred" + "github.com/authzed/openagentprimitives/pkg/platform/identity/provider" + "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins" + "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins/onepassword_service_account" +) + +// noBrowser is the opener handed to a Flow whose screens this test never +// presents, so nothing ever calls it. Named rather than nil so every +// construction in this file reads the same way. +func noBrowser(string) error { return nil } + +const goodToken = "ops_eyJhbGciOiJFUzI1NiJ9CONTRACTSENTINELVALUE" + +// run drives the flow end to end over a scripted stdin with a browser that +// opens nothing, and returns what reached Store. +// +// script is one line per prompt the run reaches. Every prompt MUST have a +// line: huh's accessible renderer cannot report a read error, so a short +// script silently answers the remainder from their defaults and completes with +// a nil error. That is why every assertion below is on the STORED credential +// and never on err == nil. +func run(t *testing.T, req builtins.Request, script []string, seed map[string]string) (builtins.StoreValue, string, error) { + t.Helper() + return runWithBrowser(t, func(string) error { return nil }, req, script, seed) +} + +// runWithBrowser is run with the browser opener the caller wants — one that +// records the URL, or one that fails. +// +// Every run in this package builds its Flow with an injected opener, which is +// why none of them can reach a real browser: the opener is a constructor +// argument rather than a package-level hook with a setter, so the safe path is +// the one you get by writing the test at all, not the one you get by +// remembering to opt out of the unsafe one. +func runWithBrowser( + t *testing.T, open func(string) error, + req builtins.Request, script []string, seed map[string]string, +) (builtins.StoreValue, string, error) { + t.Helper() + + var stored builtins.StoreValue + storeCalls := 0 + req.Store = func(_ context.Context, v builtins.StoreValue) error { + storeCalls++ + stored = v + return nil + } + + f := onepassword_service_account.New(open) + screens, err := f.Screens(context.Background(), req) + if err != nil { + return builtins.StoreValue{}, "", err + } + + st := tui.NewState() + for k, v := range seed { + st.Set(k, v) + } + in := "" + if len(script) > 0 { + in = strings.Join(script, "\n") + "\n" + } + var out bytes.Buffer + st, err = tui.RunWith(context.Background(), screens, tui.Options{ + Theme: tui.NewTheme(tui.Caps{}), + In: strings.NewReader(in), + Out: &out, + }, st) + if err != nil { + return builtins.StoreValue{}, out.String(), err + } + + err = f.Result(context.Background(), req, st) + if err == nil { + assert.Equal(t, 1, storeCalls, "a successful Result must store exactly once") + } else { + assert.Zero(t, storeCalls, "a failing Result must not have stored anything") + } + return stored, out.String(), err +} + +// testRequest mirrors the catalog entry this flow serves +// (providers/onepassword-service-account.yaml): a declared tokenShape +// requiring the ops_ prefix. +func testRequest() builtins.Request { + return builtins.Request{ + Provider: &provider.Provider{ + ID: "onepassword-service-account", Builtin: "onepassword-service-account", Shape: "bearer", + Title: "1Password", + DocsURL: "https://developer.1password.com/docs/service-accounts/", + TokenShape: &provider.TokenShape{ + Pattern: `^ops_[A-Za-z0-9_-]+$`, + Description: "service-account tokens start with ops_", + }, + }, + IdentityName: "demo-bot", + Namespace: "demo-ns", + UserIntent: "fill saved logins into pages the agent's browser toolbox visits", + } +} + +func TestFlowName(t *testing.T) { + assert.Equal(t, "onepassword-service-account", onepassword_service_account.New(noBrowser).Name()) +} + +func TestScreensAreStableAndNamed(t *testing.T) { + screens, err := onepassword_service_account.New(noBrowser).Screens(context.Background(), testRequest()) + require.NoError(t, err) + + var ids []string + for _, s := range screens { + ids = append(ids, s.ID()) + } + assert.Equal(t, []string{"browser", "token"}, ids, + "the service-account documentation is opened before the token is asked for, so the user knows where to look") +} + +// TestPastedTokenIsStored is the happy path, asserted on the credential +// rather than on the absence of an error. +func TestPastedTokenIsStored(t *testing.T) { + openedURL := "" + stored, out, err := runWithBrowser(t, + func(u string) error { openedURL = u; return nil }, + testRequest(), []string{goodToken}, nil) + require.NoError(t, err) + assert.Equal(t, goodToken, stored.Bearer) + assert.Equal(t, "https://developer.1password.com/docs/service-accounts/", openedURL) + assert.NotContains(t, out, goodToken, + "the token must never be echoed into the run's own output") +} + +// TestGarbageTokenIsRejected pins that the declared tokenShape (ops_...) is +// enforced before Store is ever called — a value that merely LOOKS like a +// token, but lacks the prefix, must never be persisted. +func TestGarbageTokenIsRejected(t *testing.T) { + cases := []struct { + name string + token string + }{ + {name: "missing ops_ prefix", token: "sk-live-not-a-1password-token"}, + // Not the bare "ops_" prefix: the flow's own guidance text contains + // that literal substring ("starts with ops_"), so a garbage token + // equal to just the prefix would trivially fail the leak check below + // for a reason that has nothing to do with the flow echoing it. + {name: "ops_ prefix with a disallowed character", token: "ops_not!valid"}, + {name: "wrong prefix entirely", token: "ghp_AbCdEfGhIjKlMnOp"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, out, err := run(t, testRequest(), []string{tc.token}, nil) + require.Error(t, err) + assert.NotContains(t, out, tc.token, + "a rejected token must never be echoed into the run's own output") + }) + } +} + +// TestBlankAnswerIsRefused is the fail-closed story for an input script that +// ran out: huh's accessible renderer cannot report a read error, so an +// unanswered screen reaches Result as a nil error unless Result's own +// emptiness guard catches it first. +func TestBlankAnswerIsRefused(t *testing.T) { + cases := []struct { + name string + state func() *tui.State + }{ + { + name: "answered with whitespace: refused, nothing stored", + state: func() *tui.State { + st := tui.NewState() + st.Set(onepassword_service_account.KeyToken, " ") + return st + }, + }, + { + name: "never answered at all: refused, nothing stored", + state: tui.NewState, + }, + { + name: "a nil State from a run that failed partway: refused, no panic", + state: func() *tui.State { return nil }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := testRequest() + var storeCalls int + req.Store = func(context.Context, builtins.StoreValue) error { storeCalls++; return nil } + + assert.NotPanics(t, func() { + assert.Error(t, onepassword_service_account.New(noBrowser).Result(context.Background(), req, tc.state())) + }) + assert.Zero(t, storeCalls, "nothing may reach Store on a flow that was not answered") + }) + } +} + +// TestGuidanceNamesTheSingleVaultRequirement pins the one mistake this flow +// exists to prevent: a service account scoped to more than one vault, or to +// write access, is not what the browser toolbox's direct-credential path +// expects, and the guidance must say so before the user ever creates one. +func TestGuidanceNamesTheSingleVaultRequirement(t *testing.T) { + _, out, err := run(t, testRequest(), []string{goodToken}, nil) + require.NoError(t, err) + assert.Contains(t, out, "read_items", + "the guidance must name the permission the service account needs") + assert.Contains(t, out, "ONE vault", + "the guidance must say the scope is exactly one vault") +} + +// TestGuidanceFitsTheNoteWidth guards the address: it is meant to be copied, +// and huh wraps an over-long note line at the form's column budget with no +// sign the halves belong together. +func TestGuidanceFitsTheNoteWidth(t *testing.T) { + const reason = "an unusually long explanation of why no browser could be started here" + _, out, err := runWithBrowser(t, + func(string) error { return errors.New(reason) }, + testRequest(), []string{goodToken}, nil) + require.NoError(t, err) + for _, line := range tui.RailedNoteBudget().Overflows(out) { + assert.Contains(t, line, reason, + "only the operating system's own failure text may exceed the note width; %q (%d columns) did too", + line, len([]rune(line))) + } + assert.Contains(t, out, "Page: https://developer.1password.com/docs/service-accounts/", + "the address must appear whole, on one line") +} + +// TestVerify is the status-mapping table over the injected verifyFn seam: +// Flow.Verify never makes a network call or touches the SDK in this package's +// tests, by construction (NewWithVerify). +func TestVerify(t *testing.T) { + cases := []struct { + name string + verify func(ctx context.Context, token string) (directcred.Pin, error) + wantStatus builtins.VerifyStatus + wantDetail string // substring + }{ + { + name: "exactly one vault visible: Valid, name in Detail and Metadata", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{VaultID: "vid1", VaultName: "Agents"}, nil + }, + wantStatus: builtins.VerifyValid, + wantDetail: "Agents", + }, + { + name: "two vaults visible: Forbidden, count in Detail", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{}, fmt.Errorf( + "onepassword: expected exactly one accessible vault, found %d: %v", 2, []string{"Agents", "Personal"}) + }, + wantStatus: builtins.VerifyForbidden, + wantDetail: "2", + }, + { + name: "zero vaults visible: Forbidden, count in Detail", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{}, fmt.Errorf( + "onepassword: expected exactly one accessible vault, found %d: %v", 0, []string{}) + }, + wantStatus: builtins.VerifyForbidden, + wantDetail: "0", + }, + { + name: "SDK rejected the token constructing a client: Rejected", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{}, fmt.Errorf("onepassword: creating client: %w", errors.New("invalid service account token")) + }, + wantStatus: builtins.VerifyRejected, + }, + { + name: "listing vaults failed (network/other): Indeterminate", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{}, fmt.Errorf("onepassword: listing vaults: %w", errors.New("dial tcp: i/o timeout")) + }, + wantStatus: builtins.VerifyIndeterminate, + }, + { + name: "success with no vault name: Indeterminate rather than a false Valid", + verify: func(context.Context, string) (directcred.Pin, error) { + return directcred.Pin{}, nil + }, + wantStatus: builtins.VerifyIndeterminate, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := onepassword_service_account.NewWithVerify(noBrowser, tc.verify) + res, err := f.Verify(context.Background(), builtins.VerifyRequest{ + Provider: testRequest().Provider, + Value: builtins.StoreValue{Bearer: goodToken}, + }) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status) + if tc.wantDetail != "" { + assert.Contains(t, res.Detail, tc.wantDetail) + } + assert.NotEmpty(t, res.Detail, "every non-Valid verdict must say why, per VerifyResult.Detail") + assert.NotContains(t, res.Detail, goodToken, "the token must never appear in Detail") + for _, v := range res.Metadata { + assert.NotContains(t, v, goodToken, "the token must never appear in Metadata") + } + }) + } +} + +// TestVerify_EmptyTokenNeverCallsVerifyFn guards the one input Flow.Verify +// must refuse before it ever reaches the injected seam: an empty bearer value +// has nothing to check, so calling verify with it would ask a real backend +// (or, in the default wiring, the registry) a meaningless question. +func TestVerify_EmptyTokenNeverCallsVerifyFn(t *testing.T) { + called := false + f := onepassword_service_account.NewWithVerify(noBrowser, func(context.Context, string) (directcred.Pin, error) { + called = true + return directcred.Pin{}, nil + }) + res, err := f.Verify(context.Background(), builtins.VerifyRequest{ + Provider: testRequest().Provider, + Value: builtins.StoreValue{Bearer: ""}, + }) + require.NoError(t, err) + assert.False(t, called, "an empty token must never reach the verify seam") + assert.Equal(t, builtins.VerifyIndeterminate, res.Status) + assert.NotEmpty(t, res.Detail) +} diff --git a/pkg/platform/identity/setup/builtins/onepassword_service_account/init.go b/pkg/platform/identity/setup/builtins/onepassword_service_account/init.go new file mode 100644 index 00000000..1632d9d6 --- /dev/null +++ b/pkg/platform/identity/setup/builtins/onepassword_service_account/init.go @@ -0,0 +1,13 @@ +package onepassword_service_account + +import ( + "github.com/authzed/openagentprimitives/pkg/platform/identity/setup/builtins" + "github.com/authzed/openagentprimitives/pkg/x/browser" +) + +// The registered flow is the only one that opens a real browser, and this is +// the only line that gives it the ability to. Everything else — every test, +// every caller constructing a Flow of its own — has to supply an opener, so +// "opens a window on somebody's desktop" is a capability that is handed over +// rather than one that has to be taken away. +func init() { builtins.Register(New(browser.Open)) } diff --git a/pkg/platform/manifests/install.yaml b/pkg/platform/manifests/install.yaml index 11b895c6..eb852d8e 100644 --- a/pkg/platform/manifests/install.yaml +++ b/pkg/platform/manifests/install.yaml @@ -7348,18 +7348,80 @@ spec: description: BrowserPodName is the per-session browser pod's name. type: string + catalog: + description: |- + Catalog is the bound identity's directCredentials login references, + listed once per session start and again on wake and carried forward in + between (CatalogFingerprint gates a re-list on a rotated backing token). + Each entry carries only {id, label, hosts} -- never a username, password, + or OTP. Empty when the toolbox binds no identity. + items: + description: |- + BrowserLoginRef is one login the bound identity's directcred source lists, + snapshotted onto status so Phase C's form filler can offer it to a human. + It carries NOTHING from the login item's fields -- only the opaque ID, a + display label, and the hosts it matches (directcred.Ref's own security + contract). No username, password, OTP, or seed ever appears here, which is + what lets the whole status be logged and shown in a UI. + properties: + hosts: + description: Hosts is the set of hostname patterns this + login applies to. + items: + description: |- + BrowserLoginHost is one hostname pattern a BrowserLoginRef is scoped to -- + the status-plane projection of a directcred.Host. A deliberately minimal + {Host, Exact} pair: distinct from BrowserSite (whose user-input + Pattern/MaxLength markers do not belong on an operator-derived status field) + and from BrowserEffectiveSite (which additionally carries a Source). + properties: + exact: + description: |- + Exact is true when Host must match the page host exactly; false when it + also matches subdomains. + type: boolean + host: + description: Host is the canonical hostname this + login applies to. + type: string + required: + - host + type: object + type: array + id: + description: |- + ID is opaque to everyone but the directcred Source that issued it; + callers must not parse or infer structure from it. + type: string + label: + description: Label is the display name shown to a human + picking a login. + type: string + required: + - id + type: object + type: array + catalogFingerprint: + description: |- + CatalogFingerprint is the hex SHA-256 of the credential's token-Secret + value at the time Catalog was listed -- the set-once-per-token key the + carry-forward uses to re-list only when the backing token rotates. A + vault-less secret source has no token key, so this is constant there and + the catalog carries forward until a wake. It is a digest, never the token. + type: string effectiveSites: description: |- EffectiveSites is the resolved site list the running browser's egress - is actually evaluated against -- in Phase A, a straight carry-through - of Spec.Access.Sites with Source "explicit". + is actually evaluated against: Spec.Access.Sites (Source "explicit") + unioned with the bound identity's catalog hosts (Source "identity") when + spec.access.identitySites is set. items: description: |- BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's - EffectiveSites. Source records where the entry came from: Phase A only ever - writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); - "identity" is a Phase B addition once a bound identity can contribute its - own sites to the effective set. + EffectiveSites. Source records where the entry came from: "explicit" for a + BrowserAccess.Sites entry carried through verbatim, "identity" for a host + folded in from the bound identity's directCredentials catalog (only when + spec.access.identitySites is set). properties: exact: description: Exact mirrors BrowserSite.Exact for this @@ -7371,8 +7433,8 @@ spec: type: string source: description: |- - Source is where this entry came from. "explicit" only in Phase A; - "identity" arrives in Phase B. + Source is where this entry came from: "explicit" (spec.access.sites) or + "identity" (a host from the bound identity's login catalog). type: string required: - host @@ -7385,6 +7447,12 @@ spec: browser pod's gate process. format: int32 type: integer + identityName: + description: |- + IdentityName is the AgentIdentity (spec.access.identity) whose + directCredentials credential produced Catalog -- Phase C's form filler + reads it to know whose logins these are. Empty when no identity is bound. + type: string name: description: |- Name is the LLM-facing prefix (the `name` field from @@ -7433,6 +7501,28 @@ spec: Access is the site allowlist/blocklist the browser's egress is evaluated against. properties: + identity: + description: |- + Identity is the name of an AgentIdentity (in this BrowserToolbox's + namespace) carrying a directCredentials credential this browser binds + to -- a Phase B addition. Allowlist-only: the two XValidation rules on + BrowserToolbox.Spec refuse it under Mode=blocklist, because a + general-access browser has no legitimate use for a credential + reference. Identity name syntax and existence are not checked here -- + that is the Task 7 BrowserToolbox reconciler's job (Valid=False when + the reference is missing or has no directCredentials credential). + type: string + identitySites: + default: false + description: |- + IdentitySites, when true, folds every host Identity's directCredentials + catalog lists into this allowlist, alongside any explicit Sites -- a + Phase B addition. Requires Identity to be set (enforced by the second + XValidation rule on BrowserToolbox.Spec). This field only records the + bit; deriving the actual host names from the identity's source happens + in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and + is recorded onto status, not here. + type: boolean mode: description: |- Mode is "allowlist" (only Sites are reachable) or "blocklist" (every @@ -11330,19 +11420,42 @@ spec: type: object spec: description: |- - BrowserToolboxSpec is deliberately not annotated with a CEL XValidation - rule: this type is embedded by value into AgentSession's - status.resolvedBrowserToolboxes snapshot (see ResolvedBrowserToolbox.Spec), - and a CEL rule reachable from a status path both trips the status-CEL guard - and risks the apiserver's CRD rule-cost budget across that unbounded status - array -- the same reasoning SidecarToolboxSpec documents. Field-level - markers (Enum, Pattern, default) are the validation surface here. + Two Phase B rules live here rather than on BrowserToolboxSpec/BrowserAccess + (see that type's doc comment for why): this field is the ONE place a CRD + validation marker is legal, because BrowserToolboxSpec is also embedded + by value into AgentSession.status.resolvedBrowserToolboxes[].Spec, and a + rule reachable from a status path trips both the status-CEL guard and the + apiserver's rule-cost budget across that unbounded array. Precedent: + workshop_types.go:44-66 attaches its XValidation rules the same way, on + the field, not the type. properties: access: description: |- Access is the site allowlist/blocklist the browser's egress is evaluated against. properties: + identity: + description: |- + Identity is the name of an AgentIdentity (in this BrowserToolbox's + namespace) carrying a directCredentials credential this browser binds + to -- a Phase B addition. Allowlist-only: the two XValidation rules on + BrowserToolbox.Spec refuse it under Mode=blocklist, because a + general-access browser has no legitimate use for a credential + reference. Identity name syntax and existence are not checked here -- + that is the Task 7 BrowserToolbox reconciler's job (Valid=False when + the reference is missing or has no directCredentials credential). + type: string + identitySites: + default: false + description: |- + IdentitySites, when true, folds every host Identity's directCredentials + catalog lists into this allowlist, alongside any explicit Sites -- a + Phase B addition. Requires Identity to be set (enforced by the second + XValidation rule on BrowserToolbox.Spec). This field only records the + bit; deriving the actual host names from the identity's source happens + in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and + is recorded onto status, not here. + type: boolean mode: description: |- Mode is "allowlist" (only Sites are reachable) or "blocklist" (every @@ -11408,6 +11521,13 @@ spec: required: - access type: object + x-kubernetes-validations: + - message: a blocklist (general-access) browser cannot carry an identity + or identitySites + rule: self.access.mode != 'blocklist' || (!has(self.access.identity) + && !self.access.identitySites) + - message: identitySites requires identity + rule: '!self.access.identitySites || has(self.access.identity)' status: description: |- BrowserToolboxStatus is intentionally minimal in Phase A: a condition set @@ -26130,6 +26250,7 @@ rules: - agentsettings/status - agentuis/status - artifactrenders/status + - browsertoolboxes/status - channels/status - clusteragentsettings/status - clusteridentityproviders/status @@ -26175,6 +26296,7 @@ rules: resources: - agentsessions/finalizers - artifactrenders/finalizers + - browsertoolboxes/finalizers - credentialupdaterequests/finalizers - publicendpoints/finalizers - sidecartoolboxes/finalizers diff --git a/pkg/platform/manifests/rbac_sufficiency_test.go b/pkg/platform/manifests/rbac_sufficiency_test.go index 62ac4835..256ede70 100644 --- a/pkg/platform/manifests/rbac_sufficiency_test.go +++ b/pkg/platform/manifests/rbac_sufficiency_test.go @@ -370,13 +370,18 @@ func operatorComponent() component { "channels": {"get", "list", "watch", "update", "patch", "create", "delete"}, "mcpservers": {"get", "list", "watch", "update", "patch", "create"}, "sidecartoolboxes": {"get", "list", "watch", "update", "patch", "create"}, - // BrowserToolbox is read-only for the operator: the AgentSession - // reconciler Gets each class-referenced CR at session start to snapshot - // its spec into status.resolvedBrowserToolboxes, and the AgentClass - // reconciler Gets them to validate refs and Watches the kind to - // re-validate (markers in pkg/controllers/agentsession/controller.go and - // pkg/controllers/agentclass/controller.go). Nothing writes one — there - // is no dedicated BrowserToolbox reconciler in Phase A. + // BrowserToolbox's MAIN resource is read-only for the operator: the + // AgentSession reconciler Gets each class-referenced CR at session + // start to snapshot its spec into status.resolvedBrowserToolboxes, the + // AgentClass reconciler Gets them to validate refs (existence + the + // toolbox's own Valid condition) and Watches the kind to re-validate, + // and the BrowserToolbox reconciler itself + // (pkg/controllers/browsertoolbox/controller.go) validates the + // identity cross-reference — but it writes ONLY the status + // subresource (browsertoolboxes/status, filtered from minimality + // below like every other status subresource): no finalizer is ever + // added and no spec/metadata write exists, so update/patch on the + // main resource would be over-broad. "browsertoolboxes": {"get", "list", "watch"}, // The WorkspaceSource reconciler materializes/refreshes a shared base // checkout: get;list;watch to reconcile, update;patch for spec/status. diff --git a/pkg/tools/kinds/browsertoolbox/kind.go b/pkg/tools/kinds/browsertoolbox/kind.go index ea6507d9..88903379 100644 --- a/pkg/tools/kinds/browsertoolbox/kind.go +++ b/pkg/tools/kinds/browsertoolbox/kind.go @@ -156,6 +156,32 @@ func (k Kind) ValidateFile(path string) ([]contract.Diagnostic, error) { seen[canon] = i } + // Mirror the two field-level XValidation rules on BrowserToolbox.Spec + // (pkg/apis/v1alpha1/browsertoolbox_types.go) at authoring time, so an + // author sees the same rejection `oap tools validate` would from the + // apiserver, without a round trip through admission. + if sp.Access.Mode == browserpolicy.ModeBlocklist && sp.Access.Identity != "" { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: "access.identity", + Message: "a blocklist (general-access) browser cannot carry an identity or identitySites", + }) + } + if sp.Access.Mode == browserpolicy.ModeBlocklist && sp.Access.IdentitySites { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: "access.identitySites", + Message: "a blocklist (general-access) browser cannot carry an identity or identitySites", + }) + } + if sp.Access.IdentitySites && sp.Access.Identity == "" { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: "access.identitySites", + Message: "identitySites requires identity", + }) + } + // An allowlist with no sites denies every host — not a structural error, // but almost certainly not what the author intended, so it's a warning // rather than an error. @@ -186,6 +212,12 @@ func (k Kind) LintFile(path string) ([]contract.Diagnostic, error) { return k.Va func renderAccess(a spiceboxv1alpha1.BrowserAccess) string { var b strings.Builder fmt.Fprintf(&b, "mode: %s\n", a.Mode) + if a.Identity != "" { + fmt.Fprintf(&b, "identity: %s\n", a.Identity) + } + if a.IdentitySites { + b.WriteString("identitySites: true\n") + } if len(a.Sites) == 0 { b.WriteString("sites: (none)") return b.String() diff --git a/pkg/tools/kinds/browsertoolbox/kind_test.go b/pkg/tools/kinds/browsertoolbox/kind_test.go index d1cd4431..6d37361f 100644 --- a/pkg/tools/kinds/browsertoolbox/kind_test.go +++ b/pkg/tools/kinds/browsertoolbox/kind_test.go @@ -247,6 +247,76 @@ interaction: write assert.Contains(t, d.Message, "write") }, }, + { + // Mirrors the first CEL rule on BrowserToolbox.Spec: a blocklist + // (general-access) browser cannot carry an identity. + name: "blocklist + identity: errors", + body: ` +access: + mode: blocklist + sites: + - host: example.com + identity: joey-airlines +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.identity") + require.True(t, ok, "expected a diagnostic at path access.identity, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "blocklist") + }, + }, + { + // Mirrors the first CEL rule on BrowserToolbox.Spec: a blocklist + // (general-access) browser cannot carry identitySites either. + name: "blocklist + identitySites: errors", + body: ` +access: + mode: blocklist + sites: + - host: example.com + identitySites: true +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.identitySites") + require.True(t, ok, "expected a diagnostic at path access.identitySites, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "blocklist") + }, + }, + { + // Mirrors the second CEL rule on BrowserToolbox.Spec: identitySites + // requires identity. + name: "identitySites without identity: errors", + body: ` +access: + mode: allowlist + sites: + - host: example.com + identitySites: true +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.identitySites") + require.True(t, ok, "expected a diagnostic at path access.identitySites, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "identity") + }, + }, + { + name: "allowlist + identity + identitySites: clean", + body: ` +access: + mode: allowlist + sites: + - host: example.com + identity: joey-airlines + identitySites: true +interaction: act +state: ephemeral +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + assert.Empty(t, diags) + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -279,6 +349,34 @@ func TestKind_Row(t *testing.T) { assert.Contains(t, row.Summary, "2") } +func TestKind_Detail_ShowsIdentityWhenSet(t *testing.T) { + cr := sampleCR() + cr.Spec.Access.Mode = "allowlist" + cr.Spec.Access.Identity = "joey-airlines" + cr.Spec.Access.IdentitySites = true + + d := New().Detail(cr) + var access string + for _, s := range d.Sections { + if s.Title == "Access" { + access = s.Body + } + } + assert.Contains(t, access, "joey-airlines") + assert.Contains(t, access, "identitySites") +} + +func TestKind_Detail_OmitsIdentityWhenUnset(t *testing.T) { + d := New().Detail(sampleCR()) + var access string + for _, s := range d.Sections { + if s.Title == "Access" { + access = s.Body + } + } + assert.NotContains(t, access, "identity") +} + func TestKind_Detail_HasAllSections(t *testing.T) { d := New().Detail(sampleCR()) assert.Equal(t, "BrowserToolbox", d.Kind) diff --git a/providers/onepassword-service-account.yaml b/providers/onepassword-service-account.yaml new file mode 100644 index 00000000..0096564d --- /dev/null +++ b/providers/onepassword-service-account.yaml @@ -0,0 +1,43 @@ +id: onepassword-service-account +title: 1Password +description: A 1Password service-account token, scoped to exactly one vault, so the agent's browser toolbox can fill the logins it holds. +docsURL: https://developer.1password.com/docs/service-accounts/ +shape: bearer +builtin: onepassword-service-account + +instructions: | + Create a 1Password service account so the agent's browser toolbox can + fill logins from one vault, and nothing else: + + 1. Open https://developer.1password.com/docs/service-accounts/ and + follow its guide to create a service account. + 2. Grant it read_items access to exactly ONE vault — the vault + holding the logins this agent should use — and NO other vault + or permission. A token scoped to more than one vault, or to + write access, is rejected when it is verified. + 3. Copy the token (starts with ops_) and paste it below. + +prompt: | + Walk the user through creating a 1Password service account at + https://developer.1password.com/docs/service-accounts/. It MUST be + scoped to read_items on exactly ONE vault — the vault holding the + logins this agent's browser toolbox should fill — and to no other + vault or permission. After the user pastes the token (starts with + ops_), validate the shape and call store_credential. + +# Format gate — enforced at every entry point (paste forms, put-token CLI, +# setup flow) via ValidateToken. 1Password documents the ops_ prefix for +# service-account tokens; no length is pinned beyond the prefix so an +# unusual-but-valid token is never rejected. +tokenShape: + pattern: '^ops_[A-Za-z0-9_-]+$' + description: "service-account tokens start with ops_" + +# No verify: probe. This provider's builtin flow (onepassword-service-account) +# runs a RICHER live check than the generic declarative bearer probe could +# express: it confirms the token resolves to EXACTLY ONE visible vault — the +# scoping invariant the browser toolbox's direct-credential path depends on +# (see pkg/platform/identity/directcred's doc comment) — not merely that some +# endpoint answers 200. A declarative verify: block here would be a no-op +# anyway: verify.go's VerifyCredential dispatches to a registered builtin +# before it ever reads this block. diff --git a/site/content/docs/crd-agentidentity.mdx b/site/content/docs/crd-agentidentity.mdx index 5ed61ce9..79f489ef 100644 --- a/site/content/docs/crd-agentidentity.mdx +++ b/site/content/docs/crd-agentidentity.mdx @@ -26,6 +26,12 @@ UserIdentity or SessionUserIdentity. | --- | --- | --- | | `spec.credentials` | `[]object` | Credentials are reusable named credential descriptors. Each has a `type` discriminator and exactly one type-specific block populated. | | `spec.credentials[].allowedHosts` | `[]string` | AllowedHosts scopes where this credential may be sent, as host patterns ("github.com", "*.internal.example"). Matching is on the URL authority (host and port), case-insensitively; a leading "*." matches one or more leading labels but never the apex, so an author who wants both lists both. It exists because destination and credential were independent fields on the same tenant-writable object with nothing tying them together: a SkillSource naming spec.repoURL: "https://attacker.example/x.git" and spec.auth pointing at a production PAT made the operator send that PAT to that host as an HTTP Basic password, reading the Secret with its own cluster-wide credentials so the actor never needed `get secrets`. EMPTY MEANS UNSCOPED, which is the only reading that does not break every existing credential on upgrade. That makes the field opt-in, so a caller with a stricter default enforces it itself rather than relying on this: the SkillSource controller refuses an unscoped credential for a clone outright, because there is no safe default destination for one. | +| `spec.credentials[].directCredentials` | `object` | DirectCredentials is populated when type=directCredentials: a website login the browser toolbox fills at use time. It never flows through the broker — Resolve errors unconditionally — so it never projects into an env var or header the way every other block here does. Valid ONLY on AgentIdentity: the credential is authored on the bot's own identity, not passed through from a human. | +| `spec.credentials[].directCredentials.secretRef` \* | `object` | SecretRef names the source's token Secret (key "token", onepassword) or the host-keyed login Secret (secret). | +| `spec.credentials[].directCredentials.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | +| `spec.credentials[].directCredentials.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | +| `spec.credentials[].directCredentials.source` \* | `string` | Source names the login backend: onepassword resolves through a 1Password Connect token; secret reads a host-keyed login Secret written directly. _(enum: onepassword \| secret)_ | +| `spec.credentials[].directCredentials.vault` | `string` | Vault is the 1Password vault NAME; required iff source is onepassword. | | `spec.credentials[].federated` | `object` | Federated is populated when type=federated. The credential is MINTED on demand via ID-JAG from the user's enterprise identity — there is no stored access token. Valid ONLY on UserIdentity / SessionUserIdentity; rejected on AgentIdentity (bot identities have no human subject to assert). | | `spec.credentials[].federated.idpSecretRef` \* | `object` | IdPSecretRef points at the subject-keyed IdP-identity Secret in IdentitiesNamespace (the user's refresh token). | | `spec.credentials[].federated.idpSecretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | @@ -43,7 +49,7 @@ UserIdentity or SessionUserIdentity. | `spec.credentials[].static.secretRef` \* | `object` | SecretRef points at a Secret in the same namespace as the AgentIdentity. | | `spec.credentials[].static.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | | `spec.credentials[].static.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | -| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp)_ | +| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp \| directCredentials)_ | | `spec.description` | `string` | Description is a human-readable summary surfaced in events / status. | | `spec.refreshThreshold` | `string` | RefreshThreshold overrides the operator-wide default refresh threshold for this AgentIdentity. type=oauth credentials whose expires_at is within this duration are refreshed proactively. No effect on type=static credentials. | @@ -69,6 +75,11 @@ Status is controller-owned (observed state). | `status.observedCredentials[].name` \* | `string` | Name is the credential name from spec.credentials[].name. | | `status.observedCredentials[].secretNames` | `[]string` | SecretNames are the backing Secret names for this credential, recorded so a REMOVED credential's revoke key can be reconstructed without re-reading a spec that no longer mentions it. Empty for UserIdentity, whose backing Secret name is derived from (identity name, credential name) rather than referenced by the spec. | | `status.observedGeneration` | `integer (int64)` | ObservedGeneration is the spec generation this status reflects. | +| `status.pinnedVaults` | `[]object` | PinnedVaults records, for each type=directCredentials credential whose source has a vault concept (onepassword), the vault its backing token was PROVEN to resolve to. The validity reconciler calls the directcred source's Verify on first sight and records the vault id + name here; it re-verifies only when the token changes (TokenFingerprint differs), so a rotated token that now resolves to a DIFFERENT vault surfaces loudly as Valid=False/DirectCredentialInvalid instead of silently listing another vault's logins at the browser toolbox. A source with no vault concept (secret) is never recorded here. Operator-owned and derived: sorted by credential name and set once per (credential, token) pair, so an unchanged token re-writes nothing and the MergeFrom patch is a no-op. TokenFingerprint is a sha256 digest of the token value, never the value itself. | +| `status.pinnedVaults[].credential` \* | `string` | Credential is the credential name from spec.credentials[].name. | +| `status.pinnedVaults[].tokenFingerprint` \* | `string` | TokenFingerprint is a hex sha256 digest of the backing token value at the time the pin was written. It is the set-once key: a reconcile whose token still hashes to this value carries the pin forward without re-verifying, and a changed token forces a fresh Verify. It is a digest, never the token. | +| `status.pinnedVaults[].vaultID` \* | `string` | VaultID is the backend's stable identifier for the pinned vault, as returned by the source's Verify. | +| `status.pinnedVaults[].vaultName` \* | `string` | VaultName is the human-readable vault name the backend confirmed. May differ in case/whitespace from the credential's declared vault. | | `status.resolvedCredentials` | `[]string` | ResolvedCredentials lists credential names whose Secret + key resolved. | \* required diff --git a/site/content/docs/crd-agentsession.mdx b/site/content/docs/crd-agentsession.mdx index d65e0c4c..2a8f875f 100644 --- a/site/content/docs/crd-agentsession.mdx +++ b/site/content/docs/crd-agentsession.mdx @@ -359,11 +359,19 @@ Status is controller-owned (observed state). | `status.resolvedBrowserToolboxes` | `[]object` | ResolvedBrowserToolboxes snapshots each referenced BrowserToolbox at session start, frozen for the session lifetime exactly like ResolvedSidecarToolboxes. Empty when AgentClass.spec.browserToolboxes is empty. | | `status.resolvedBrowserToolboxes[].browserPodIP` | `string` | BrowserPodIP is the per-session browser pod's IP, reflected once Ready; the runner reaches its gate at http://<BrowserPodIP>:<GatePort>. | | `status.resolvedBrowserToolboxes[].browserPodName` | `string` | BrowserPodName is the per-session browser pod's name. | -| `status.resolvedBrowserToolboxes[].effectiveSites` | `[]object` | EffectiveSites is the resolved site list the running browser's egress is actually evaluated against -- in Phase A, a straight carry-through of Spec.Access.Sites with Source "explicit". | +| `status.resolvedBrowserToolboxes[].catalog` | `[]object` | Catalog is the bound identity's directCredentials login references, listed once per session start and again on wake and carried forward in between (CatalogFingerprint gates a re-list on a rotated backing token). Each entry carries only {id, label, hosts} -- never a username, password, or OTP. Empty when the toolbox binds no identity. | +| `status.resolvedBrowserToolboxes[].catalog[].hosts` | `[]object` | Hosts is the set of hostname patterns this login applies to. | +| `status.resolvedBrowserToolboxes[].catalog[].hosts[].exact` | `boolean` | Exact is true when Host must match the page host exactly; false when it also matches subdomains. | +| `status.resolvedBrowserToolboxes[].catalog[].hosts[].host` \* | `string` | Host is the canonical hostname this login applies to. | +| `status.resolvedBrowserToolboxes[].catalog[].id` \* | `string` | ID is opaque to everyone but the directcred Source that issued it; callers must not parse or infer structure from it. | +| `status.resolvedBrowserToolboxes[].catalog[].label` | `string` | Label is the display name shown to a human picking a login. | +| `status.resolvedBrowserToolboxes[].catalogFingerprint` | `string` | CatalogFingerprint is the hex SHA-256 of the credential's token-Secret value at the time Catalog was listed -- the set-once-per-token key the carry-forward uses to re-list only when the backing token rotates. A vault-less secret source has no token key, so this is constant there and the catalog carries forward until a wake. It is a digest, never the token. | +| `status.resolvedBrowserToolboxes[].effectiveSites` | `[]object` | EffectiveSites is the resolved site list the running browser's egress is actually evaluated against: Spec.Access.Sites (Source "explicit") unioned with the bound identity's catalog hosts (Source "identity") when spec.access.identitySites is set. | | `status.resolvedBrowserToolboxes[].effectiveSites[].exact` | `boolean` | Exact mirrors BrowserSite.Exact for this entry. | | `status.resolvedBrowserToolboxes[].effectiveSites[].host` \* | `string` | Host is the resolved hostname, matched exactly as BrowserSite.Host. | -| `status.resolvedBrowserToolboxes[].effectiveSites[].source` \* | `string` | Source is where this entry came from. "explicit" only in Phase A; "identity" arrives in Phase B. | +| `status.resolvedBrowserToolboxes[].effectiveSites[].source` \* | `string` | Source is where this entry came from: "explicit" (spec.access.sites) or "identity" (a host from the bound identity's login catalog). | | `status.resolvedBrowserToolboxes[].gatePort` | `integer (int32)` | GatePort is the operator-allocated port the runner uses to reach the browser pod's gate process. | +| `status.resolvedBrowserToolboxes[].identityName` | `string` | IdentityName is the AgentIdentity (spec.access.identity) whose directCredentials credential produced Catalog -- Phase C's form filler reads it to know whose logins these are. Empty when no identity is bound. | | `status.resolvedBrowserToolboxes[].name` \* | `string` | Name is the LLM-facing prefix (the `name` field from AgentClassBrowserToolboxRef). | | `status.resolvedBrowserToolboxes[].podFailure` | `object` | PodFailure records a TERMINAL container state on the browser pod (CrashLoopBackOff, ImagePullBackOff) -- reuses SidecarPodFailure's shape exactly; see that type's doc comment for why it carries no timestamp. | | `status.resolvedBrowserToolboxes[].podFailure.exitCode` | `integer (int32)` | ExitCode is the container's last terminated exit code. Nil when the container never ran (e.g. the image could not be pulled). | @@ -372,6 +380,8 @@ Status is controller-owned (observed state). | `status.resolvedBrowserToolboxes[].ref` \* | `string` | Ref is the BrowserToolbox CR name (the `ref` field from AgentClassBrowserToolboxRef). | | `status.resolvedBrowserToolboxes[].spec` \* | `object` | Spec is the BrowserToolbox spec snapshot at session-start time. | | `status.resolvedBrowserToolboxes[].spec.access` \* | `object` | Access is the site allowlist/blocklist the browser's egress is evaluated against. | +| `status.resolvedBrowserToolboxes[].spec.access.identity` | `string` | Identity is the name of an AgentIdentity (in this BrowserToolbox's namespace) carrying a directCredentials credential this browser binds to -- a Phase B addition. Allowlist-only: the two XValidation rules on BrowserToolbox.Spec refuse it under Mode=blocklist, because a general-access browser has no legitimate use for a credential reference. Identity name syntax and existence are not checked here -- that is the Task 7 BrowserToolbox reconciler's job (Valid=False when the reference is missing or has no directCredentials credential). | +| `status.resolvedBrowserToolboxes[].spec.access.identitySites` | `boolean` | IdentitySites, when true, folds every host Identity's directCredentials catalog lists into this allowlist, alongside any explicit Sites -- a Phase B addition. Requires Identity to be set (enforced by the second XValidation rule on BrowserToolbox.Spec). This field only records the bit; deriving the actual host names from the identity's source happens in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and is recorded onto status, not here. _(default: false)_ | | `status.resolvedBrowserToolboxes[].spec.access.mode` \* | `string` | Mode is "allowlist" (only Sites are reachable) or "blocklist" (every site except Sites is reachable). _(enum: allowlist \| blocklist)_ | | `status.resolvedBrowserToolboxes[].spec.access.sites` | `[]object` | Sites is the set of hosts Mode interprets. Matching is suffix-by-label unless Exact is set: a Site with Host "example.com" also matches "www.example.com" and "a.b.example.com", but never a lookalike like "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary comparison, not a raw string suffix check. Exact pins the entry to the literal host only. | | `status.resolvedBrowserToolboxes[].spec.access.sites[].exact` | `boolean` | Exact pins this entry to the literal Host only, disabling the default suffix-by-label subdomain match. | diff --git a/site/content/docs/crd-browsertoolbox.mdx b/site/content/docs/crd-browsertoolbox.mdx index 3e37e901..9f2a6507 100644 --- a/site/content/docs/crd-browsertoolbox.mdx +++ b/site/content/docs/crd-browsertoolbox.mdx @@ -32,6 +32,8 @@ this spec. | Field | Type | Description | | --- | --- | --- | | `spec.access` \* | `object` | Access is the site allowlist/blocklist the browser's egress is evaluated against. | +| `spec.access.identity` | `string` | Identity is the name of an AgentIdentity (in this BrowserToolbox's namespace) carrying a directCredentials credential this browser binds to -- a Phase B addition. Allowlist-only: the two XValidation rules on BrowserToolbox.Spec refuse it under Mode=blocklist, because a general-access browser has no legitimate use for a credential reference. Identity name syntax and existence are not checked here -- that is the Task 7 BrowserToolbox reconciler's job (Valid=False when the reference is missing or has no directCredentials credential). | +| `spec.access.identitySites` | `boolean` | IdentitySites, when true, folds every host Identity's directCredentials catalog lists into this allowlist, alongside any explicit Sites -- a Phase B addition. Requires Identity to be set (enforced by the second XValidation rule on BrowserToolbox.Spec). This field only records the bit; deriving the actual host names from the identity's source happens in the AgentSession reconciler (DeriveBrowserEffectiveSites, Task 7) and is recorded onto status, not here. _(default: false)_ | | `spec.access.mode` \* | `string` | Mode is "allowlist" (only Sites are reachable) or "blocklist" (every site except Sites is reachable). _(enum: allowlist \| blocklist)_ | | `spec.access.sites` | `[]object` | Sites is the set of hosts Mode interprets. Matching is suffix-by-label unless Exact is set: a Site with Host "example.com" also matches "www.example.com" and "a.b.example.com", but never a lookalike like "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary comparison, not a raw string suffix check. Exact pins the entry to the literal host only. | | `spec.access.sites[].exact` | `boolean` | Exact pins this entry to the literal Host only, disabling the default suffix-by-label subdomain match. | diff --git a/site/content/docs/crd-sessionuseridentity.mdx b/site/content/docs/crd-sessionuseridentity.mdx index 4b245337..480bee7e 100644 --- a/site/content/docs/crd-sessionuseridentity.mdx +++ b/site/content/docs/crd-sessionuseridentity.mdx @@ -27,6 +27,12 @@ means "still waiting for the user to link", not "no credentials needed". | `spec.agentSession` \* | `string` | AgentSession is the name of the owning AgentSession (same namespace). metadata.name equals this value (1:1). | | `spec.credentials` | `[]object` | Credentials is the subset of the UserIdentity's catalog the agent needs. Same shape as AgentIdentity.Spec.Credentials. SecretRefs name Secrets in the agentprimitives-identities namespace. Empty while the session is parked with missing credentials. | | `spec.credentials[].allowedHosts` | `[]string` | AllowedHosts scopes where this credential may be sent, as host patterns ("github.com", "*.internal.example"). Matching is on the URL authority (host and port), case-insensitively; a leading "*." matches one or more leading labels but never the apex, so an author who wants both lists both. It exists because destination and credential were independent fields on the same tenant-writable object with nothing tying them together: a SkillSource naming spec.repoURL: "https://attacker.example/x.git" and spec.auth pointing at a production PAT made the operator send that PAT to that host as an HTTP Basic password, reading the Secret with its own cluster-wide credentials so the actor never needed `get secrets`. EMPTY MEANS UNSCOPED, which is the only reading that does not break every existing credential on upgrade. That makes the field opt-in, so a caller with a stricter default enforces it itself rather than relying on this: the SkillSource controller refuses an unscoped credential for a clone outright, because there is no safe default destination for one. | +| `spec.credentials[].directCredentials` | `object` | DirectCredentials is populated when type=directCredentials: a website login the browser toolbox fills at use time. It never flows through the broker — Resolve errors unconditionally — so it never projects into an env var or header the way every other block here does. Valid ONLY on AgentIdentity: the credential is authored on the bot's own identity, not passed through from a human. | +| `spec.credentials[].directCredentials.secretRef` \* | `object` | SecretRef names the source's token Secret (key "token", onepassword) or the host-keyed login Secret (secret). | +| `spec.credentials[].directCredentials.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | +| `spec.credentials[].directCredentials.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | +| `spec.credentials[].directCredentials.source` \* | `string` | Source names the login backend: onepassword resolves through a 1Password Connect token; secret reads a host-keyed login Secret written directly. _(enum: onepassword \| secret)_ | +| `spec.credentials[].directCredentials.vault` | `string` | Vault is the 1Password vault NAME; required iff source is onepassword. | | `spec.credentials[].federated` | `object` | Federated is populated when type=federated. The credential is MINTED on demand via ID-JAG from the user's enterprise identity — there is no stored access token. Valid ONLY on UserIdentity / SessionUserIdentity; rejected on AgentIdentity (bot identities have no human subject to assert). | | `spec.credentials[].federated.idpSecretRef` \* | `object` | IdPSecretRef points at the subject-keyed IdP-identity Secret in IdentitiesNamespace (the user's refresh token). | | `spec.credentials[].federated.idpSecretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | @@ -44,7 +50,7 @@ means "still waiting for the user to link", not "no credentials needed". | `spec.credentials[].static.secretRef` \* | `object` | SecretRef points at a Secret in the same namespace as the AgentIdentity. | | `spec.credentials[].static.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | | `spec.credentials[].static.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | -| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp)_ | +| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp \| directCredentials)_ | | `spec.subject` \* | `string` | Subject is the starter's canonical SpiceDB subject. | | `spec.userIdentity` \* | `string` | UserIdentity is the name of the cluster-scoped UserIdentity this projection was narrowed from. | diff --git a/site/content/docs/crd-toolcall.mdx b/site/content/docs/crd-toolcall.mdx index 46fe611d..d10edade 100644 --- a/site/content/docs/crd-toolcall.mdx +++ b/site/content/docs/crd-toolcall.mdx @@ -38,7 +38,7 @@ supplied through spec.env are rejected rather than passed through. | `spec.credentials[].source.resource` | `string` | Resource / ResourceServerURL / Scopes are set only for type=federated and drive the ID-JAG mint. | | `spec.credentials[].source.resourceServerURL` | `string` | | | `spec.credentials[].source.scopes` | `[]string` | | -| `spec.credentials[].source.type` \* | `string` | Type drives the read and expiry rules; see the type doc. _(enum: static \| oauth \| federated \| githubApp)_ | +| `spec.credentials[].source.type` \* | `string` | Type drives the read and expiry rules; see the type doc. _(enum: static \| oauth \| federated \| githubApp \| directCredentials)_ | | `spec.env` | `map[string]string` | Env is non-secret configuration for the tool process. Use a bound AgentIdentity for any secret material; supplying secrets here is an error — the operator rejects keys whose name matches the well-known secret-name pattern. | | `spec.idleTimeout` | `string` | IdleTimeout (interactive mode only) ends the session after this much wall-clock with no activity — no tool output and no human input. The runner-side bridge enforces it. Zero means a system default applies. | | `spec.inputArtifacts` | `[]object` | InputArtifacts are stored payloads materialized into the pod before exec. | diff --git a/site/content/docs/crd-useridentity.mdx b/site/content/docs/crd-useridentity.mdx index 65620037..43514f90 100644 --- a/site/content/docs/crd-useridentity.mdx +++ b/site/content/docs/crd-useridentity.mdx @@ -26,6 +26,12 @@ referenced Secrets live in the agentprimitives-identities namespace. | --- | --- | --- | | `spec.credentials` | `[]object` | Credentials are the user's reusable named credentials. Same shape as AgentIdentity.Spec.Credentials. The secretRefs name Secrets in the agentprimitives-identities namespace (IdentitiesNamespace). Credential names must match what tools declare (or the runtime default — see MCPServerAuth.Credential / ToolkitEnvVar.Credential). | | `spec.credentials[].allowedHosts` | `[]string` | AllowedHosts scopes where this credential may be sent, as host patterns ("github.com", "*.internal.example"). Matching is on the URL authority (host and port), case-insensitively; a leading "*." matches one or more leading labels but never the apex, so an author who wants both lists both. It exists because destination and credential were independent fields on the same tenant-writable object with nothing tying them together: a SkillSource naming spec.repoURL: "https://attacker.example/x.git" and spec.auth pointing at a production PAT made the operator send that PAT to that host as an HTTP Basic password, reading the Secret with its own cluster-wide credentials so the actor never needed `get secrets`. EMPTY MEANS UNSCOPED, which is the only reading that does not break every existing credential on upgrade. That makes the field opt-in, so a caller with a stricter default enforces it itself rather than relying on this: the SkillSource controller refuses an unscoped credential for a clone outright, because there is no safe default destination for one. | +| `spec.credentials[].directCredentials` | `object` | DirectCredentials is populated when type=directCredentials: a website login the browser toolbox fills at use time. It never flows through the broker — Resolve errors unconditionally — so it never projects into an env var or header the way every other block here does. Valid ONLY on AgentIdentity: the credential is authored on the bot's own identity, not passed through from a human. | +| `spec.credentials[].directCredentials.secretRef` \* | `object` | SecretRef names the source's token Secret (key "token", onepassword) or the host-keyed login Secret (secret). | +| `spec.credentials[].directCredentials.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | +| `spec.credentials[].directCredentials.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | +| `spec.credentials[].directCredentials.source` \* | `string` | Source names the login backend: onepassword resolves through a 1Password Connect token; secret reads a host-keyed login Secret written directly. _(enum: onepassword \| secret)_ | +| `spec.credentials[].directCredentials.vault` | `string` | Vault is the 1Password vault NAME; required iff source is onepassword. | | `spec.credentials[].federated` | `object` | Federated is populated when type=federated. The credential is MINTED on demand via ID-JAG from the user's enterprise identity — there is no stored access token. Valid ONLY on UserIdentity / SessionUserIdentity; rejected on AgentIdentity (bot identities have no human subject to assert). | | `spec.credentials[].federated.idpSecretRef` \* | `object` | IdPSecretRef points at the subject-keyed IdP-identity Secret in IdentitiesNamespace (the user's refresh token). | | `spec.credentials[].federated.idpSecretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | @@ -43,7 +49,7 @@ referenced Secrets live in the agentprimitives-identities namespace. | `spec.credentials[].static.secretRef` \* | `object` | SecretRef points at a Secret in the same namespace as the AgentIdentity. | | `spec.credentials[].static.secretRef.key` \* | `string` | Key is the data key within the Secret holding the value. | | `spec.credentials[].static.secretRef.name` \* | `string` | Name is the Secret's name, in the referring object's own namespace. | -| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp)_ | +| `spec.credentials[].type` \* | `string` | Type discriminates which of the blocks below is populated. _(enum: static \| oauth \| federated \| githubApp \| directCredentials)_ | | `spec.displayName` | `string` | DisplayName is a human-friendly label (e.g. an email address) surfaced in CLI output and the web UI. | | `spec.refreshThreshold` | `string` | RefreshThreshold overrides the operator-wide default OAuth refresh threshold for this identity. No effect on type=static credentials. | | `spec.subject` \* | `string` | Subject is the canonical SpiceDB subject of the user this catalog belongs to, e.g. "user:YWxpY2VAZXhhbXBsZS5jb20=". metadata.name is a deterministic hash of this value (see pkg/platform/identity/useridentity). | diff --git a/test/e2e/harness.go b/test/e2e/harness.go index 2d54b8ba..8d1354e7 100644 --- a/test/e2e/harness.go +++ b/test/e2e/harness.go @@ -73,6 +73,7 @@ import ( agentclassctrl "github.com/authzed/openagentprimitives/pkg/controllers/agentclass" agentidentityctrl "github.com/authzed/openagentprimitives/pkg/controllers/agentidentity" agentsessionctrl "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + browsertoolboxctrl "github.com/authzed/openagentprimitives/pkg/controllers/browsertoolbox" channelctrl "github.com/authzed/openagentprimitives/pkg/controllers/channel" guardianctrl "github.com/authzed/openagentprimitives/pkg/controllers/guardian" mcpserverctrl "github.com/authzed/openagentprimitives/pkg/controllers/mcpserver" @@ -796,10 +797,12 @@ func (h *Harness) MarkBrowserReady(ns, name, llmName, ref, gateURL, token string base := sess.DeepCopy() entry := spiceboxv1alpha1.ResolvedBrowserToolbox{ - Name: llmName, - Ref: ref, - Spec: spec, - EffectiveSites: agentsessionctrl.DeriveBrowserEffectiveSites(spec), + Name: llmName, + Ref: ref, + Spec: spec, + // nil identity hosts: the harness stamp binds no identity, so the + // effective sites are the explicit spec sites only (Phase A shape). + EffectiveSites: agentsessionctrl.DeriveBrowserEffectiveSites(spec, nil), BrowserPodName: agentsessionctrl.BrowserPodName(&sess, ref), BrowserPodIP: host, GatePort: port, @@ -1147,6 +1150,24 @@ func (h *Harness) startManager(t *testing.T, env *testenv.Env, spdbCli *spicedb. t.Fatalf("e2e: register SidecarToolbox controller: %v", err) } + // BrowserToolbox — sets Valid on any BrowserToolbox a fixture ships, the + // same role SidecarToolbox plays above. The AgentClass reconciler parks + // at Valid=False until every spec.browserToolboxes[].ref resolves to a + // BrowserToolbox that is itself Valid=True (Task 7 upgraded + // validateBrowserToolboxes to read this condition, mirroring + // validateSidecarToolboxes), so without this a fixture carrying a browser + // toolbox can never boot. The REAL controller: it has no probe pod to + // skip (unlike SidecarToolbox, a BrowserToolbox has no image to reach), + // so it needs no envtest-specific flag. A no-op for every bundle that + // ships no BrowserToolbox; every Phase A browser bundle ships an + // identity-less toolbox, which this controller stamps Valid=True by + // construction. + if err := (&browsertoolboxctrl.Reconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr); err != nil { + t.Fatalf("e2e: register BrowserToolbox controller: %v", err) + } + // Guardian — composes MCPServer.spec.spiceDBSchema fragments and // SpiceDBBootstrap CRs into the live SpiceDB schema. The // AgentClass schema-validation step REQUIRES this to have run at diff --git a/third_party/licenses/boombuler-barcode-MIT.txt b/third_party/licenses/boombuler-barcode-MIT.txt new file mode 100644 index 00000000..862b0ddc --- /dev/null +++ b/third_party/licenses/boombuler-barcode-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Florian Sundermann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/otel-proto-otlp-Apache-2.0.txt b/third_party/licenses/otel-proto-otlp-Apache-2.0.txt new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/third_party/licenses/otel-proto-otlp-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.