From 848f913597295640d39a8628c63c6946687c9ab4 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 22:04:43 +0800 Subject: [PATCH] feat: secure organization provider credentials --- ...-design-org-scoped-secret-control-plane.md | 13 +- ...3-implement-org-connection-secret-plane.md | 2 +- AGENTS.md | 4 + ...cret-envelope-and-connection-resolution.md | 143 ++++++ hub/.env.example | 13 +- hub/deploy/README.md | 47 +- hub/deploy/cph-hub.service | 4 + hub/deploy/install_service.sh | 42 +- hub/package.json | 3 +- .../migration.sql | 70 +++ hub/prisma/schema.prisma | 65 +++ hub/src/admin/errors.ts | 6 + hub/src/admin/plugin.ts | 8 + hub/src/admin/routes/orgRoutes.ts | 13 + .../admin/routes/providerConnectionRoutes.ts | 122 +++++ hub/src/agent/runner.ts | 5 +- hub/src/agent/security.ts | 5 +- hub/src/connections/networkFailure.ts | 39 ++ hub/src/connections/providerConnections.ts | 423 ++++++++++++++++++ hub/src/connections/providerProxy.ts | 225 ++++++++++ hub/src/connections/providerReadiness.ts | 65 +++ hub/src/deployment/preflight-cli.ts | 64 ++- hub/src/deployment/preflight.ts | 24 +- hub/src/deployment/rotate-secret-kek.ts | 59 +++ hub/src/feishu/trigger.ts | 124 ++--- hub/src/hub.ts | 27 +- hub/src/security/localKekRotation.ts | 204 +++++++++ hub/src/security/secretEnvelope.ts | 268 +++++++++++ hub/src/server.ts | 6 +- hub/src/settings/runtime.ts | 170 +++++-- hub/test/integration/admin-auth.test.ts | 3 +- hub/test/integration/admin-explorer.test.ts | 3 +- .../integration/admin-members-teams.test.ts | 3 +- .../admin-provider-connections.test.ts | 214 +++++++++ .../integration/agent-sandbox-linux.test.ts | 6 +- .../deployment-preflight-cli.test.ts | 53 ++- hub/test/integration/helpers.ts | 9 + .../integration/local-kek-rotation.test.ts | 127 ++++++ .../provider-runtime-settings.test.ts | 170 +++++++ hub/test/integration/server-start.test.ts | 21 +- hub/test/integration/trigger.test.ts | 23 +- hub/test/unit/agent-security.test.ts | 20 +- hub/test/unit/deployment-preflight.test.ts | 11 +- hub/test/unit/feishu-reactions.test.ts | 11 +- hub/test/unit/provider-proxy.test.ts | 156 +++++++ hub/test/unit/provider-readiness.test.ts | 128 ++++++ hub/test/unit/runner.test.ts | 12 +- hub/test/unit/runtime-settings.test.ts | 36 +- hub/test/unit/secret-envelope.test.ts | 188 ++++++++ spec/Spec/System.lean | 5 +- spec/Spec/System/Organization.lean | 48 +- 51 files changed, 3280 insertions(+), 230 deletions(-) create mode 100644 docs/adr/0024-local-secret-envelope-and-connection-resolution.md create mode 100644 hub/prisma/migrations/20260710220000_org_provider_secret_envelopes/migration.sql create mode 100644 hub/src/admin/routes/providerConnectionRoutes.ts create mode 100644 hub/src/connections/networkFailure.ts create mode 100644 hub/src/connections/providerConnections.ts create mode 100644 hub/src/connections/providerProxy.ts create mode 100644 hub/src/connections/providerReadiness.ts create mode 100644 hub/src/deployment/rotate-secret-kek.ts create mode 100644 hub/src/security/localKekRotation.ts create mode 100644 hub/src/security/secretEnvelope.ts create mode 100644 hub/test/integration/admin-provider-connections.test.ts create mode 100644 hub/test/integration/local-kek-rotation.test.ts create mode 100644 hub/test/integration/provider-runtime-settings.test.ts create mode 100644 hub/test/unit/provider-proxy.test.ts create mode 100644 hub/test/unit/provider-readiness.test.ts create mode 100644 hub/test/unit/secret-envelope.test.ts diff --git a/.scratch/saas-production-readiness/issues/19-design-org-scoped-secret-control-plane.md b/.scratch/saas-production-readiness/issues/19-design-org-scoped-secret-control-plane.md index 798f8b7..505d8e3 100644 --- a/.scratch/saas-production-readiness/issues/19-design-org-scoped-secret-control-plane.md +++ b/.scratch/saas-production-readiness/issues/19-design-org-scoped-secret-control-plane.md @@ -1,7 +1,7 @@ # Design the org-scoped secret and connection control plane Type: grilling -Status: open +Status: resolved ## Question @@ -11,3 +11,14 @@ provider credentials; support one customer-owned Feishu app per Organization; support Organization-managed BYOK and a distinct platform-managed provider connection per Organization; and ensure plaintext credentials never reach business records, logs, or agent tools? + +## Answer + +Use the local master-key envelope, immutable connection-secret versions, +writer-authority split, explicit Organization/Project resolver, staged KEK +rotation, and separately protected keyring recovery contract accepted in +ADR-0024. Production receives the root-owned keyring only through systemd +`LoadCredential`; it has no process-global credential fallback. Agent runs use +a loopback proxy capability rather than the Organization Provider credential; +the offline rotation command atomically retains old/new KEKs, rewraps stored +DEKs, audits the changes, and authenticates the complete envelope set. diff --git a/.scratch/saas-production-readiness/issues/43-implement-org-connection-secret-plane.md b/.scratch/saas-production-readiness/issues/43-implement-org-connection-secret-plane.md index cdf9fde..4e0597d 100644 --- a/.scratch/saas-production-readiness/issues/43-implement-org-connection-secret-plane.md +++ b/.scratch/saas-production-readiness/issues/43-implement-org-connection-secret-plane.md @@ -2,7 +2,7 @@ Type: task Status: open -Blocked by: 19 +Blocked by: none ## Question diff --git a/AGENTS.md b/AGENTS.md index 38ea31c..9a1f435 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,10 @@ - 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org 独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 / `Spec.System.Organization`)。 +- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd + credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退 + process-global credential;Agent child 只接收 run-scoped loopback proxy capability, + 不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。 - 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。 Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 / `Spec.System.Capacity`)。 diff --git a/docs/adr/0024-local-secret-envelope-and-connection-resolution.md b/docs/adr/0024-local-secret-envelope-and-connection-resolution.md new file mode 100644 index 0000000..811f7bc --- /dev/null +++ b/docs/adr/0024-local-secret-envelope-and-connection-resolution.md @@ -0,0 +1,143 @@ +# ADR 0024: Local Secret Envelope And Connection Resolution + +## Status + +Accepted. + +## Context + +ADR-0021 requires every customer Feishu application and model provider +credential to belong to one Organization. It forbids a process-global provider +credential and requires business code to obtain plaintext only through a +resolver. Before the pilot can store real customer credentials, the deployment +also needs a concrete encryption, rotation, recovery, writer-authority, and +connection-identity contract. + +The initial production shape is one Hub service and one PostgreSQL database on +a controlled host. An external KMS would add another remote availability and +bootstrap dependency before the pilot has operational support for it. Keeping +plaintext credentials in environment variables, configuration files readable +by the service account, database columns, logs, or Agent environments is not an +acceptable substitute. + +## Decision + +### Local master-key keyring + +The pilot uses a versioned **local master-key keyring**. Each key is a random +256-bit key-encryption key (KEK) with a stable, non-secret key identifier. The +keyring names exactly one active KEK and may retain previous KEKs while rotation +is in progress. It is not stored in PostgreSQL, a release directory, an +environment variable, or the ordinary database/workspace backup set. + +In production, the source keyring is owned by root, mode `0600`, and delivered +to the unprivileged Hub service by systemd `LoadCredential`. Hub reads the +read-only file under `CREDENTIALS_DIRECTORY`; production startup rejects a +direct arbitrary file path. Development and tests may use an explicit local +keyring file. Startup fails when the keyring is absent, malformed, contains an +invalid key, has no active key, or names an active key that is not present. + +### Envelope format and binding + +Every immutable secret version receives a new random 256-bit data-encryption +key (DEK). AES-256-GCM encrypts the secret payload with that DEK. AES-256-GCM +also wraps the DEK with the keyring's active KEK. Nonces are random and never +reused with the same key. + +The versioned envelope persists only algorithm/version metadata, the KEK +identifier, nonces, authentication tags, the wrapped DEK, and ciphertext. +Authenticated additional data binds both layers to the secret purpose, +Organization identifier, Connection identifier, immutable secret-version +identifier, and envelope version. Copying ciphertext between rows, +Organizations, connections, purposes, or versions therefore fails +authentication. Unknown algorithms or versions, missing KEKs, malformed +envelopes, and failed authentication are hard errors; there is no fallback to +another credential source. + +The encrypted payload is a versioned object specific to its connection type. +For a Provider Connection, provider base URL and authentication material are +inside the payload. For a Feishu Application Connection, application ID, +application secret, verification/encryption material, and bot identity are +inside the payload. HTTP responses, business records, logs, metrics, traces, +audit facts, exceptions, and Agent process environments receive only redacted +metadata, never those plaintext fields or a decrypted DEK. + +### Connection identity, lifecycle, and writer authority + +A connection has a stable opaque identifier and belongs to exactly one +Organization. Secret versions are immutable; the connection points to one +active version. Rotation creates and validates a new version, atomically moves +the active pointer, and retains the previous encrypted version for rollback and +audit until retention policy removes it. A connection is `DRAFT`, `ACTIVE`, or +`DISABLED`; runtime resolution accepts only an active connection with a valid +active secret version. + +There is one customer-owned Feishu Application Connection per Organization. +Its provider-local user, chat, event, and callback identifiers are meaningful +only together with that connection identity. Organization OWNER/ADMIN may +create and rotate that customer-owned connection. Platform repair remains a +separate stepped-up, reason-bearing, fail-closed-audited operation under +ADR-0023, not an Organization API bypass. + +A model Provider Connection is unique by Organization and provider identity. +In `BYOK` mode an Organization OWNER/ADMIN creates and rotates it. In +`PLATFORM_MANAGED` mode only a stepped-up Platform Administrator may create or +rotate it, with the platform mutation and redacted Platform Audit Entry in one +transaction. Switching modes creates or activates the correctly governed +connection version; it never re-labels plaintext or grants the other control +plane write authority. + +### Resolver boundary + +Business code asks one connection resolver for an active connection using an +explicit Organization, or using a Project that is first resolved to its active +Organization. Provider resolution also requires the provider identity; Feishu +resolution requires the intended Feishu Connection identity whenever external +traffic enters the system. Resolution fails for missing, draft, disabled, +cross-Organization, corrupt, or undecryptable records. + +Decrypted values exist only in the resolver result for the duration of the +outbound provider/Feishu operation. They are not cached across connections, +returned through administrative reads, passed to Agent tools, or written back +to business rows. Production has no process-global Feishu or model-provider +credential fallback. + +The Claude Agent child never receives an Organization's Provider credential. +For each run, Hub opens a loopback-only proxy with a random, short-lived run +capability. The child receives only that local endpoint and capability; Hub +validates the capability, replaces it with the Organization credential at the +upstream hop, refuses automatic redirects, and destroys the listener when the +run ends. Agent sandbox credential rules hide even the run capability from +tool subprocesses. + +### KEK rotation, backup, and recovery + +KEK rotation is staged: install a new KEK alongside the old keys, mark it +active, rewrap each stored DEK under the new KEK without decrypting its payload, +verify every envelope, then retire the old KEK only after the separately +protected recovery copy and restore drill are current. Secret rotation and KEK +rotation are distinct operations. + +A recoverable deployment requires both the ordinary database/workspace backup +and a separately protected backup of the local keyring. Neither copy alone can +recover plaintext credentials. Restore preflight verifies key identifiers and +authenticates sample/all envelopes before traffic is enabled. A missing or +incorrect keyring leaves the service unavailable and produces redacted +diagnostics; operators must restore the correct keyring rather than reset or +silently discard credentials. + +## Consequences + +- The database may be restored or copied without exposing plaintext secrets, + but losing the separately held keyring makes encrypted credentials + unrecoverable. +- The service unit, installer, preflight, backup, restore, and rotation tooling + must treat the keyring as an explicit production dependency. +- Administrative writes must accept secret material as write-only input and + return only connection state, version, timestamps, and redacted fingerprints. +- Tests must prove cross-Organization/row substitution failure, wrong/missing + key failure, version rotation, absence of global fallback, and absence of + plaintext from persistence, responses, logs, and Agent environments. +- External KMS/HSM adoption is deferred. It may replace the local KEK provider + without changing connection identity, immutable secret versions, AAD + binding, writer authority, or resolver behavior. diff --git a/hub/.env.example b/hub/.env.example index d324980..001139c 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -6,12 +6,9 @@ NODE_ENV="production" # PostgreSQL connection string. DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm" -# Claude Code SDK auth via OpenRouter's Anthropic Skin. -# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys) -# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts. -ANTHROPIC_BASE_URL="https://openrouter.ai/api" -ANTHROPIC_AUTH_TOKEN="" -ANTHROPIC_API_KEY="" +# Provider URL and credentials are write-only Organization Provider Connection +# data in PostgreSQL, encrypted by ADR-0024. Process-global ANTHROPIC_* provider +# settings are rejected in production and must not be added here. # Model override (optional). Defaults to anthropic/claude-sonnet-5. # Use an OpenRouter model slug, for example: @@ -57,3 +54,7 @@ HUB_SESSION_SECRET="" # Optional OAuth scope (space-separated). Default: contact:user.base:readonly # Apply matching scopes in the Feishu developer console. # HUB_OAUTH_SCOPE="contact:user.base:readonly" + +# Local development/tests only. Production must not set this: systemd injects +# the fixed cph-secret-keyring credential through CREDENTIALS_DIRECTORY. +# HUB_SECRET_KEYRING_FILE="/absolute/path/to/dev-keyring.json" diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 00e5c26..941f25c 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -14,13 +14,52 @@ sudo BASE=/srv/curriculum-project-hub \ ``` On a clean host the first invocation creates -`/srv/curriculum-project-hub/.secrets/platform.env` with every supported -production key and exits with status 78. Fill every required blank, set -`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and -run the same command again. No systemd unit is installed until configuration, +`/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned +`secret-keyring.json`, then exits with status 78. Copy the keyring to a +separately protected recovery location before continuing; it must not live only +on this host or in the ordinary database/workspace backup. Fill every required +environment blank, set `CPH_BIN` to an executable compatible `cph`, keep both +source files root-only at mode `0600`, and run the same command again. No +systemd unit is installed until configuration, paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory ownership all pass preflight. +The service account cannot read the keyring source. The installed unit uses +systemd `LoadCredential` to materialize a read-only `cph-secret-keyring` for the +running service. Hub refuses production startup if this credential is missing, +malformed, or does not contain its declared active key. Provider credentials +are configured through the Organization admin connection API, never through +process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings. + +Rotate the local KEK only from the controlled host console. The command first +atomically adds a new active key while retaining every previous key, then +authenticates and rewraps every stored Provider DEK, writes redacted org audit +events, and verifies the complete set again: + +```sh +sudo bash -c ' + set -euo pipefail + systemctl stop cph-hub.service + set -a + . /srv/curriculum-project-hub/.secrets/platform.env + set +a + node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \ + --keyring-file /srv/curriculum-project-hub/.secrets/secret-keyring.json + systemctl start cph-hub.service +' +``` + +The CLI refuses to rotate while `cph-hub.service` is active; stopping the only +writer prevents a concurrent BYOK version from being appended under the old +in-memory keyring. A PostgreSQL transaction advisory lock excludes a second +rotation and is released automatically if the CLI or host crashes. The +operation is therefore rerunnable after interruption: old and new keys remain +in the atomically replaced keyring, so partially rewrapped rows stay +decryptable. It also refuses success while any envelope still names a +non-active KEK. Back up the updated keyring to the separate recovery location +and complete a restore preflight before considering old-key retirement. The +pilot deliberately does not auto-delete previous keys. + The deployment path also runs `npm run audit:production` from the locked clean install before building or restarting the service. A current high or critical production advisory therefore blocks deployment instead of becoming a warning diff --git a/hub/deploy/cph-hub.service b/hub/deploy/cph-hub.service index 2619f7e..96a7c79 100644 --- a/hub/deploy/cph-hub.service +++ b/hub/deploy/cph-hub.service @@ -7,6 +7,7 @@ Wants=network-online.target # this start-time assertion keeps later package/host changes fail-closed. AssertPathExists=__BWRAP_BIN__ AssertPathExists=__SOCAT_BIN__ +AssertPathExists=__KEYRING_FILE__ [Service] Type=simple @@ -18,6 +19,9 @@ Environment=HOME=__SERVICE_HOME__ Environment=XDG_STATE_HOME=__STATE_DIR__ Environment=XDG_CACHE_HOME=__CACHE_DIR__ Environment=PATH=__RUNTIME_PATH__ +# ADR-0024: the root-owned source remains unreadable by the service account; +# systemd materializes a read-only per-unit credential at runtime. +LoadCredential=cph-secret-keyring:__KEYRING_FILE__ UMask=0027 # Run prisma migrations before each start, then launch the Hub. ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 22ffec5..142bc4d 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -21,6 +21,7 @@ WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8788}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" +KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}" SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}" NODE_BIN="${NODE_BIN:-$(command -v node || true)}" BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}" @@ -63,7 +64,7 @@ if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then fail "the Hub service identity must not be root" fi -for command in getent groupadd useradd install stat systemctl id; do +for command in getent groupadd useradd install stat systemctl id openssl date mktemp; do require_command "$command" done for pair in \ @@ -97,6 +98,7 @@ for pair in \ "CACHE_DIR:$CACHE_DIR" \ "WORKSPACE_ROOT:$WORKSPACE_ROOT" \ "ENV_FILE:$ENV_FILE" \ + "KEYRING_FILE:$KEYRING_FILE" \ "SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \ "NODE_BIN:$NODE_BIN" \ "BWRAP_BIN:$BWRAP_BIN" \ @@ -107,6 +109,32 @@ for pair in \ require_safe_unit_value "${pair%%:*}" "${pair#*:}" done +KEYRING_CREATED=false +if [ -L "$KEYRING_FILE" ]; then + fail "local secret keyring must not be a symlink: $KEYRING_FILE" +fi +if [ ! -e "$KEYRING_FILE" ]; then + install -d -o root -g root -m 0700 "$(dirname "$KEYRING_FILE")" + key_id="local-$(date -u +%Y%m%dT%H%M%SZ)" + key_value="$(openssl rand -base64 32)" + tmp_keyring="$(mktemp "$(dirname "$KEYRING_FILE")/.secret-keyring.XXXXXX")" + trap 'rm -f "${tmp_keyring:-}"' EXIT + chmod 0600 "$tmp_keyring" + printf '{"version":1,"activeKeyId":"%s","keys":{"%s":"%s"}}\n' \ + "$key_id" "$key_id" "$key_value" >"$tmp_keyring" + install -o root -g root -m 0600 "$tmp_keyring" "$KEYRING_FILE" + rm -f "$tmp_keyring" + tmp_keyring="" + trap - EXIT + unset key_value + KEYRING_CREATED=true + echo "[install] generated local secret keyring: $KEYRING_FILE" >&2 +fi +[ -f "$KEYRING_FILE" ] || fail "local secret keyring is not a regular file: $KEYRING_FILE" +keyring_metadata="$(stat -c '%u:%g:%a' -- "$KEYRING_FILE")" +[ "$keyring_metadata" = "0:0:600" ] || \ + fail "local secret keyring must be owned by root:root with mode 0600; found $keyring_metadata" + if [ -L "$ENV_FILE" ]; then fail "environment file must not be a symlink: $ENV_FILE" fi @@ -118,9 +146,6 @@ if [ ! -e "$ENV_FILE" ]; then # rerunning install_service.sh; failed preflight never installs the unit. NODE_ENV=production DATABASE_URL= -ANTHROPIC_BASE_URL=https://openrouter.ai/api -ANTHROPIC_AUTH_TOKEN= -ANTHROPIC_API_KEY= FEISHU_APP_ID= FEISHU_APP_SECRET= FEISHU_BOT_OPEN_ID= @@ -132,18 +157,24 @@ HUB_PUBLIC_BASE_URL= HUB_SESSION_SECRET= HUB_AGENT_MAX_TURNS=25 HUB_FEISHU_LISTENER_ENABLED=true -CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE +CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE EOF chmod 0600 "$ENV_FILE" echo "[install] seeded complete production configuration: $ENV_FILE" >&2 + echo "[install] copy $KEYRING_FILE to the separately protected keyring backup before continuing" >&2 echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2 exit 78 fi [ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE" +if [ "$KEYRING_CREATED" = true ]; then + echo "[install] copy $KEYRING_FILE to the separately protected keyring backup, then rerun" >&2 + exit 78 +fi PREFLIGHT_ARGS=( "$NODE_BIN" "$PREFLIGHT_JS" --env-file "$ENV_FILE" + --keyring-file "$KEYRING_FILE" --node-bin "$NODE_BIN" --runuser-bin "$RUNUSER_BIN" --setpriv-bin "$SETPRIV_BIN" @@ -249,6 +280,7 @@ sed \ -e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \ + -e "s|__KEYRING_FILE__|$KEYRING_FILE|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \ -e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \ diff --git a/hub/package.json b/hub/package.json index 75d0f9f..bdaf067 100644 --- a/hub/package.json +++ b/hub/package.json @@ -27,7 +27,7 @@ "axios": "1.18.1" } }, - "description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).", + "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.", "scripts": { "dev": "npm run prisma:migrate && tsx watch src/server.ts", "build": "tsc -p tsconfig.json", @@ -37,6 +37,7 @@ "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma", "prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma", + "secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js", "deploy": "bash deploy/deploy_platform.sh", "test": "vitest run", "test:watch": "vitest" diff --git a/hub/prisma/migrations/20260710220000_org_provider_secret_envelopes/migration.sql b/hub/prisma/migrations/20260710220000_org_provider_secret_envelopes/migration.sql new file mode 100644 index 0000000..5f1f293 --- /dev/null +++ b/hub/prisma/migrations/20260710220000_org_provider_secret_envelopes/migration.sql @@ -0,0 +1,70 @@ +-- ADR-0024: Organization-scoped provider connections and immutable encrypted +-- credential versions. No provider plaintext column exists in this schema. + +CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED'); +CREATE TYPE "OrganizationConnectionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'DISABLED'); + +CREATE TABLE "OrganizationProviderConnection" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "mode" "ProviderCredentialMode" NOT NULL, + "status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT', + "activeSecretVersionId" TEXT, + "activatedAt" TIMESTAMP(3), + "disabledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ProviderCredentialVersion" ( + "id" TEXT NOT NULL, + "connectionId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "envelopeVersion" INTEGER NOT NULL DEFAULT 1, + "keyId" TEXT NOT NULL, + "envelope" JSONB NOT NULL, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "retiredAt" TIMESTAMP(3), + CONSTRAINT "ProviderCredentialVersion_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "OrganizationProviderConnection_organizationId_providerId_key" + ON "OrganizationProviderConnection"("organizationId", "providerId"); +CREATE UNIQUE INDEX "OrganizationProviderConnection_activeSecretVersionId_key" + ON "OrganizationProviderConnection"("activeSecretVersionId"); +CREATE INDEX "OrganizationProviderConnection_organizationId_status_idx" + ON "OrganizationProviderConnection"("organizationId", "status"); +CREATE INDEX "OrganizationProviderConnection_providerId_status_idx" + ON "OrganizationProviderConnection"("providerId", "status"); + +CREATE UNIQUE INDEX "ProviderCredentialVersion_connectionId_version_key" + ON "ProviderCredentialVersion"("connectionId", "version"); +CREATE INDEX "ProviderCredentialVersion_connectionId_retiredAt_idx" + ON "ProviderCredentialVersion"("connectionId", "retiredAt"); +CREATE INDEX "ProviderCredentialVersion_keyId_idx" + ON "ProviderCredentialVersion"("keyId"); +CREATE INDEX "ProviderCredentialVersion_createdByUserId_idx" + ON "ProviderCredentialVersion"("createdByUserId"); + +ALTER TABLE "OrganizationProviderConnection" + ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProviderCredentialVersion" + ADD CONSTRAINT "ProviderCredentialVersion_connectionId_fkey" + FOREIGN KEY ("connectionId") REFERENCES "OrganizationProviderConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProviderCredentialVersion" + ADD CONSTRAINT "ProviderCredentialVersion_createdByUserId_fkey" + FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OrganizationProviderConnection" + ADD CONSTRAINT "OrganizationProviderConnection_activeSecretVersionId_fkey" + FOREIGN KEY ("activeSecretVersionId") REFERENCES "ProviderCredentialVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "AuditEntry" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "AuditEntry" + ADD CONSTRAINT "AuditEntry_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX "AuditEntry_organizationId_createdAt_idx" + ON "AuditEntry"("organizationId", "createdAt"); diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index e042037..0dd1bd2 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -41,6 +41,8 @@ model Organization { projects Project[] teams Team[] externalDirectoryConnections ExternalDirectoryConnection[] + providerConnections OrganizationProviderConnection[] + auditEntries AuditEntry[] @relation("organizationAudit") @@index([status]) } @@ -106,6 +108,66 @@ model User { permissionGrants PermissionGrant[] @relation("grantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") auditEntries AuditEntry[] @relation("auditActor") + providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") +} + +/// ADR-0024: model-provider connection identity is stable and belongs to one +/// Organization. Both BYOK and platform-managed credentials are tenant-local; +/// only ACTIVE connections with an active immutable secret version resolve. +model OrganizationProviderConnection { + id String @id @default(cuid()) + organizationId String + providerId String + mode ProviderCredentialMode + status OrganizationConnectionStatus @default(DRAFT) + activeSecretVersionId String? @unique + activatedAt DateTime? + disabledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + secretVersions ProviderCredentialVersion[] @relation("providerCredentialVersions") + activeSecretVersion ProviderCredentialVersion? @relation("activeProviderCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) + + @@unique([organizationId, providerId]) + @@index([organizationId, status]) + @@index([providerId, status]) +} + +enum ProviderCredentialMode { + BYOK + PLATFORM_MANAGED +} + +enum OrganizationConnectionStatus { + DRAFT + ACTIVE + DISABLED +} + +/// ADR-0024: write-only provider secret material is one authenticated envelope +/// per immutable version. keyId/envelopeVersion are redacted rotation metadata; +/// all provider URL/token/API-key fields remain inside envelope ciphertext. +model ProviderCredentialVersion { + id String @id @default(cuid()) + connectionId String + version Int + envelopeVersion Int @default(1) + keyId String + envelope Json + createdByUserId String? + createdAt DateTime @default(now()) + retiredAt DateTime? + + connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) + activeFor OrganizationProviderConnection? @relation("activeProviderCredentialVersion") + createdBy User? @relation("providerCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + + @@unique([connectionId, version]) + @@index([connectionId, retiredAt]) + @@index([keyId]) + @@index([createdByUserId]) } /// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole. @@ -501,6 +563,7 @@ model AuditEntry { id String @id @default(cuid()) runId String? projectId String? + organizationId String? actorUserId String? action String metadata Json @@ -508,9 +571,11 @@ model AuditEntry { actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) + organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull) @@index([runId]) @@index([projectId, createdAt]) + @@index([organizationId, createdAt]) @@index([actorUserId]) @@index([createdAt]) } diff --git a/hub/src/admin/errors.ts b/hub/src/admin/errors.ts index 931e36c..7e4854a 100644 --- a/hub/src/admin/errors.ts +++ b/hub/src/admin/errors.ts @@ -23,6 +23,12 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null { const lower = message.toLowerCase(); + if (lower.includes("provider credential readiness check could not reach provider")) { + return { statusCode: 502, code: "provider_unavailable", message }; + } + if (lower.includes("provider credential readiness check failed")) { + return { statusCode: 400, code: "provider_credential_rejected", message }; + } if (lower.includes("not found")) { return { statusCode: 404, code: "not_found", message }; } diff --git a/hub/src/admin/plugin.ts b/hub/src/admin/plugin.ts index 441ca91..e4b9c42 100644 --- a/hub/src/admin/plugin.ts +++ b/hub/src/admin/plugin.ts @@ -6,6 +6,8 @@ import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; import { registerAuthRoutes } from "./routes/authRoutes.js"; import { registerOrgRoutes } from "./routes/orgRoutes.js"; +import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; +import type { ProviderReadinessProbe } from "../connections/providerReadiness.js"; export interface AdminPluginConfig { readonly prisma: PrismaClient; @@ -14,6 +16,8 @@ export interface AdminPluginConfig { readonly feishuAppId: string; readonly feishuAppSecret: string; readonly projectWorkspaceRoot: string; + readonly secretEnvelope: LocalSecretEnvelope; + readonly providerReadinessProbe?: ProviderReadinessProbe; readonly cookieSecure?: boolean; readonly oauthScope?: string; readonly fetchImpl?: typeof fetch; @@ -43,5 +47,9 @@ export async function registerAdminPlugin( prisma: config.prisma, sessionSecret: config.sessionSecret, projectWorkspaceRoot: config.projectWorkspaceRoot, + secretEnvelope: config.secretEnvelope, + ...(config.providerReadinessProbe !== undefined + ? { providerReadinessProbe: config.providerReadinessProbe } + : {}), }); } diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index b971179..1b841c1 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -14,11 +14,16 @@ import { registerExplorerRoutes } from "./explorerRoutes.js"; import { registerMembersRoutes } from "./membersRoutes.js"; import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js"; +import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; +import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; +import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; export interface OrgRouteConfig { readonly prisma: PrismaClient; readonly sessionSecret: string; readonly projectWorkspaceRoot: string; + readonly secretEnvelope: LocalSecretEnvelope; + readonly providerReadinessProbe?: ProviderReadinessProbe; } export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise { @@ -101,4 +106,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo prisma: config.prisma, sessionSecret: config.sessionSecret, }); + await registerProviderConnectionRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + secretEnvelope: config.secretEnvelope, + ...(config.providerReadinessProbe !== undefined + ? { providerReadinessProbe: config.providerReadinessProbe } + : {}), + }); } diff --git a/hub/src/admin/routes/providerConnectionRoutes.ts b/hub/src/admin/routes/providerConnectionRoutes.ts new file mode 100644 index 0000000..7e6a219 --- /dev/null +++ b/hub/src/admin/routes/providerConnectionRoutes.ts @@ -0,0 +1,122 @@ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { ProviderConnectionService } from "../../connections/providerConnections.js"; +import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; +import { ProviderReadinessError, type ProviderReadinessProbe } from "../../connections/providerReadiness.js"; + +export interface ProviderConnectionRouteConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly secretEnvelope: LocalSecretEnvelope; + readonly providerReadinessProbe?: ProviderReadinessProbe; +} + +export async function registerProviderConnectionRoutes( + app: FastifyInstance, + config: ProviderConnectionRouteConfig, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + const connections = new ProviderConnectionService( + config.prisma, + config.secretEnvelope, + config.providerReadinessProbe, + ); + + app.get("/api/org/:orgSlug/provider-connections", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return { + connections: await connections.list(auth.organization.id), + }; + } catch (error) { + request.log.error({ + requestId: request.id, + operation: "provider_connection.list", + ...providerFailureFacts(error), + }, "provider connection read failed"); + return handleRouteError(reply, error); + } + }); + + app.put("/api/org/:orgSlug/provider-connections/:providerId", async (request, reply) => { + try { + const { orgSlug, providerId } = request.params as { orgSlug: string; providerId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = parseCredentialBody(request.body); + const result = await connections.rotateByok({ + organizationId: auth.organization.id, + providerId, + actorUserId: auth.user.id, + ...body, + }); + request.log.info({ + organizationId: auth.organization.id, + connectionId: result.id, + providerId: result.providerId, + mode: result.mode, + status: result.status, + secretVersion: result.activeVersion, + keyId: result.keyId, + }, result.created ? "provider connection created" : "provider connection rotated"); + return reply.status(result.created ? 201 : 200).send(withoutWriteResult(result)); + } catch (error) { + request.log.error({ + requestId: request.id, + operation: "provider_connection.rotate_byok", + ...providerFailureFacts(error), + }, "provider connection mutation failed"); + return handleRouteError(reply, error); + } + }); +} + +function providerFailureFacts(error: unknown): { + readonly errorCode: string; + readonly failureCategory: string; + readonly upstreamStatus?: number; +} { + if (error instanceof ProviderReadinessError) { + return { + errorCode: error.code, + failureCategory: error.category, + ...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}), + }; + } + return { + errorCode: "provider_connection_operation_failed", + failureCategory: error instanceof Error ? error.name : typeof error, + }; +} + +function parseCredentialBody(value: unknown): { + readonly baseUrl: string; + readonly authToken: string; + readonly anthropicApiKey?: string; +} { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("invalid provider credential body"); + } + const body = value as Record; + if (typeof body["baseUrl"] !== "string" || typeof body["authToken"] !== "string") { + throw new Error("provider baseUrl and authToken are required"); + } + const anthropicApiKey = body["anthropicApiKey"]; + if (anthropicApiKey !== undefined && typeof anthropicApiKey !== "string") { + throw new Error("invalid anthropicApiKey"); + } + return { + baseUrl: body["baseUrl"], + authToken: body["authToken"], + ...(anthropicApiKey !== undefined ? { anthropicApiKey } : {}), + }; +} + +function withoutWriteResult(result: T): Omit { + const { created: _created, ...metadata } = result; + return metadata; +} diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 80551bc..32d7601 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -56,7 +56,8 @@ export interface RunRequest { readonly model: string | undefined; readonly project: ProjectContext; readonly systemPrompt: string | undefined; - readonly providerEnv?: Record | undefined; + /** Run-scoped loopback proxy capability; never an Organization provider credential. */ + readonly providerProxyEnv?: Record | undefined; readonly resumeSessionId?: string | undefined; /** * RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids @@ -125,7 +126,7 @@ export async function runAgent(req: RunRequest): Promise { const security = await createAgentSecurityPolicy({ workspaceRoot, workspaceDir: req.project.workspaceDir, - providerEnv: req.providerEnv, + providerProxyEnv: req.providerProxyEnv, }); type QueryOptions = NonNullable[0]["options"]>; diff --git a/hub/src/agent/security.ts b/hub/src/agent/security.ts index 5c2b793..6ab5ce5 100644 --- a/hub/src/agent/security.ts +++ b/hub/src/agent/security.ts @@ -29,7 +29,8 @@ const SANDBOX_HIDDEN_ENV_KEYS = [ export interface AgentSecurityInput { readonly workspaceRoot: string; readonly workspaceDir: string; - readonly providerEnv?: Readonly> | undefined; + /** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */ + readonly providerProxyEnv?: Readonly> | undefined; readonly hostEnv?: Readonly> | undefined; } @@ -103,7 +104,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom env.DO_NOT_TRACK = "1"; env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0"; - for (const [name, value] of Object.entries(input.providerEnv ?? {})) { + for (const [name, value] of Object.entries(input.providerProxyEnv ?? {})) { if (!PROVIDER_ENV_KEYS.has(name)) { throw new Error(`unsupported provider environment variable: ${name}`); } diff --git a/hub/src/connections/networkFailure.ts b/hub/src/connections/networkFailure.ts new file mode 100644 index 0000000..b3e3f79 --- /dev/null +++ b/hub/src/connections/networkFailure.ts @@ -0,0 +1,39 @@ +export type NetworkFailureCategory = + | "timeout" + | "dns" + | "tls" + | "connection" + | "aborted" + | "network"; + +/** Classify transport failures without retaining messages that may contain a URL. */ +export function classifyNetworkFailure(error: unknown): NetworkFailureCategory { + const chain = errorChain(error); + const names = chain.map((candidate) => candidate.name); + const codes = chain.flatMap((candidate) => + typeof candidate.code === "string" ? [candidate.code.toUpperCase()] : [] + ); + if (names.includes("TimeoutError") || codes.some((code) => code === "ETIMEDOUT")) return "timeout"; + if (names.includes("AbortError") || codes.some((code) => code === "ABORT_ERR")) return "aborted"; + if (codes.some((code) => code === "ENOTFOUND" || code === "EAI_AGAIN")) return "dns"; + if (codes.some((code) => code.includes("TLS") || code.includes("CERT") || code.includes("SSL"))) return "tls"; + if (codes.some((code) => ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"].includes(code))) { + return "connection"; + } + return "network"; +} + +function errorChain(error: unknown): Array<{ readonly name: string; readonly code?: unknown }> { + const chain: Array<{ readonly name: string; readonly code?: unknown }> = []; + let candidate = error; + for (let depth = 0; depth < 4; depth += 1) { + if (typeof candidate !== "object" || candidate === null) break; + const record = candidate as { readonly name?: unknown; readonly code?: unknown; readonly cause?: unknown }; + chain.push({ + name: typeof record.name === "string" ? record.name : "Error", + ...(record.code !== undefined ? { code: record.code } : {}), + }); + candidate = record.cause; + } + return chain; +} diff --git a/hub/src/connections/providerConnections.ts b/hub/src/connections/providerConnections.ts new file mode 100644 index 0000000..40ab987 --- /dev/null +++ b/hub/src/connections/providerConnections.ts @@ -0,0 +1,423 @@ +import { randomUUID } from "node:crypto"; +import type { Prisma, PrismaClient } from "@prisma/client"; +import { lockActiveOrganization } from "../org/status.js"; +import { + LocalSecretEnvelope, + type SecretEnvelopeV1, +} from "../security/secretEnvelope.js"; +import { probeOpenRouterCredential, type ProviderReadinessProbe } from "./providerReadiness.js"; + +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +export interface ProviderCredentialInput { + readonly baseUrl: string; + readonly authToken: string; + readonly anthropicApiKey?: string; +} + +export interface RotateByokProviderInput extends ProviderCredentialInput { + readonly organizationId: string; + readonly providerId: string; + readonly actorUserId: string; +} + +export interface ProviderConnectionMetadata { + readonly id: string; + readonly providerId: string; + readonly mode: "BYOK" | "PLATFORM_MANAGED"; + readonly status: "DRAFT" | "ACTIVE" | "DISABLED"; + readonly activeVersion: number | null; + readonly keyId: string | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface ProviderConnectionWriteResult extends ProviderConnectionMetadata { + readonly created: boolean; +} + +export interface ProviderSecretPayloadV1 { + readonly schemaVersion: 1; + readonly baseUrl: string; + readonly authToken: string; + readonly anthropicApiKey: string; +} + +export class ProviderConnectionService { + constructor( + private readonly prisma: PrismaClient, + private readonly secrets: LocalSecretEnvelope, + private readonly readinessProbe: ProviderReadinessProbe = probeOpenRouterCredential, + ) {} + + async rotateByok(input: RotateByokProviderInput): Promise { + const payload = validateProviderCredential(input); + await this.prisma.$transaction(async (tx) => { + await requireByokActor(tx, input); + }); + await this.readinessProbe({ + providerId: input.providerId, + baseUrl: payload.baseUrl, + authToken: payload.authToken, + anthropicApiKey: payload.anthropicApiKey, + }); + + return this.prisma.$transaction(async (tx) => { + await requireByokActor(tx, input); + const connection = await tx.organizationProviderConnection.upsert({ + where: { + organizationId_providerId: { + organizationId: input.organizationId, + providerId: input.providerId, + }, + }, + update: {}, + create: { + id: randomUUID(), + organizationId: input.organizationId, + providerId: input.providerId, + mode: "BYOK", + status: "DRAFT", + }, + }); + + await lockConnection(tx, connection.id); + const locked = await tx.organizationProviderConnection.findUniqueOrThrow({ + where: { id: connection.id }, + include: { + activeSecretVersion: { select: { id: true } }, + secretVersions: { + orderBy: { version: "desc" }, + take: 1, + select: { version: true }, + }, + }, + }); + if (locked.organizationId !== input.organizationId || locked.providerId !== input.providerId) { + throw new Error("provider connection scope changed while rotating credential"); + } + if (locked.mode !== "BYOK") { + throw new Error( + "provider connection is managed by platform administrators and cannot be changed through organization API", + ); + } + + const version = (locked.secretVersions[0]?.version ?? 0) + 1; + const secretVersionId = randomUUID(); + const envelope = this.secrets.encryptJson({ + purpose: "provider-connection", + organizationId: input.organizationId, + connectionId: locked.id, + secretVersionId, + }, payload); + const now = new Date(); + + const secretVersion = await tx.providerCredentialVersion.create({ + data: { + id: secretVersionId, + connectionId: locked.id, + version, + envelopeVersion: envelope.version, + keyId: envelope.keyId, + envelope: envelope as unknown as Prisma.InputJsonValue, + createdByUserId: input.actorUserId, + }, + }); + if (locked.activeSecretVersion !== null) { + await tx.providerCredentialVersion.update({ + where: { id: locked.activeSecretVersion.id }, + data: { retiredAt: now }, + }); + } + const activated = await tx.organizationProviderConnection.update({ + where: { id: locked.id }, + data: { + status: "ACTIVE", + activeSecretVersionId: secretVersion.id, + activatedAt: now, + disabledAt: null, + }, + }); + await tx.auditEntry.create({ + data: { + organizationId: input.organizationId, + actorUserId: input.actorUserId, + action: version === 1 ? "provider_connection.created" : "provider_connection.rotated", + metadata: { + connectionId: locked.id, + providerId: input.providerId, + mode: "BYOK", + status: "ACTIVE", + secretVersion: version, + envelopeVersion: envelope.version, + keyId: envelope.keyId, + }, + }, + }); + + return { + ...metadata(activated, { version, keyId: secretVersion.keyId }), + created: version === 1, + }; + }); + } + + async list(organizationId: string): Promise { + return this.prisma.$transaction(async (tx) => { + await lockActiveOrganization(tx, organizationId); + const connections = await tx.organizationProviderConnection.findMany({ + where: { organizationId }, + orderBy: [{ providerId: "asc" }, { createdAt: "asc" }], + include: { + activeSecretVersion: { + select: { version: true, keyId: true }, + }, + }, + }); + return connections.map((connection) => metadata(connection, connection.activeSecretVersion)); + }); + } +} + +export function decryptStoredProviderCredential( + secrets: LocalSecretEnvelope, + input: { + readonly organizationId: string; + readonly connectionId: string; + readonly providerId: string; + readonly secretVersionId: string; + readonly envelopeVersion: number; + readonly keyId: string; + readonly envelope: Prisma.JsonValue; + }, +): ProviderSecretPayloadV1 { + if (input.envelopeVersion !== 1) { + throw new Error(`unsupported provider secret envelope version: ${input.envelopeVersion}`); + } + const envelope = input.envelope as unknown as SecretEnvelopeV1; + if (envelope.keyId !== input.keyId || envelope.version !== input.envelopeVersion) { + throw new Error("provider secret envelope metadata mismatch"); + } + const payload = secrets.decryptJson({ + purpose: "provider-connection", + organizationId: input.organizationId, + connectionId: input.connectionId, + secretVersionId: input.secretVersionId, + }, envelope); + return validateStoredProviderPayload(payload, input.providerId); +} + +export async function verifyStoredProviderEnvelopes( + prisma: PrismaClient | Prisma.TransactionClient, + secrets: LocalSecretEnvelope, +): Promise { + const versions = await prisma.providerCredentialVersion.findMany({ + select: { + id: true, + envelopeVersion: true, + keyId: true, + envelope: true, + connection: { + select: { id: true, organizationId: true, providerId: true }, + }, + }, + }); + for (const version of versions) { + decryptStoredProviderCredential(secrets, { + organizationId: version.connection.organizationId, + connectionId: version.connection.id, + providerId: version.connection.providerId, + secretVersionId: version.id, + envelopeVersion: version.envelopeVersion, + keyId: version.keyId, + envelope: version.envelope, + }); + } + return versions.length; +} + +export async function rewrapStoredProviderEnvelopes( + prisma: Prisma.TransactionClient, + secrets: LocalSecretEnvelope, +): Promise<{ readonly verified: number; readonly rewrapped: number }> { + const versions = await prisma.providerCredentialVersion.findMany({ + select: { + id: true, + envelopeVersion: true, + keyId: true, + envelope: true, + connection: { + select: { id: true, organizationId: true, providerId: true }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + let rewrapped = 0; + for (const version of versions) { + const binding = { + purpose: "provider-connection", + organizationId: version.connection.organizationId, + connectionId: version.connection.id, + secretVersionId: version.id, + } as const; + decryptStoredProviderCredential(secrets, { + organizationId: version.connection.organizationId, + connectionId: version.connection.id, + providerId: version.connection.providerId, + secretVersionId: version.id, + envelopeVersion: version.envelopeVersion, + keyId: version.keyId, + envelope: version.envelope, + }); + if (version.keyId === secrets.activeKeyId) continue; + + const envelope = version.envelope as unknown as SecretEnvelopeV1; + const nextEnvelope = secrets.rewrap(binding, envelope); + const updated = await prisma.providerCredentialVersion.updateMany({ + where: { id: version.id, keyId: version.keyId }, + data: { + keyId: nextEnvelope.keyId, + envelope: nextEnvelope as unknown as Prisma.InputJsonValue, + }, + }); + if (updated.count !== 1) { + throw new Error(`provider secret envelope changed during KEK rotation: ${version.id}`); + } + await prisma.auditEntry.create({ + data: { + organizationId: version.connection.organizationId, + action: "provider_secret.kek_rewrapped", + metadata: { + connectionId: version.connection.id, + providerId: version.connection.providerId, + secretVersionId: version.id, + previousKeyId: version.keyId, + keyId: nextEnvelope.keyId, + }, + }, + }); + rewrapped += 1; + } + const verified = await verifyStoredProviderEnvelopes(prisma, secrets); + const remaining = await prisma.providerCredentialVersion.count({ + where: { keyId: { not: secrets.activeKeyId } }, + }); + if (remaining !== 0) { + throw new Error(`KEK rotation left ${remaining} provider envelope(s) on a non-active key`); + } + return { verified, rewrapped }; +} + +function validateProviderCredential(input: RotateByokProviderInput): ProviderSecretPayloadV1 { + if (!PROVIDER_ID_PATTERN.test(input.providerId)) { + throw new Error("invalid provider connection id"); + } + let baseUrl: URL; + try { + baseUrl = new URL(input.baseUrl); + } catch (error) { + throw new Error("invalid provider base URL", { cause: error }); + } + if (baseUrl.protocol !== "https:") { + throw new Error("invalid provider base URL: HTTPS is required"); + } + const authToken = input.authToken.trim(); + if (authToken === "") { + throw new Error("provider auth token is required"); + } + return { + schemaVersion: 1, + baseUrl: baseUrl.toString().replace(/\/$/, ""), + authToken, + anthropicApiKey: input.anthropicApiKey?.trim() ?? "", + }; +} + +function metadata( + connection: { + readonly id: string; + readonly providerId: string; + readonly mode: "BYOK" | "PLATFORM_MANAGED"; + readonly status: "DRAFT" | "ACTIVE" | "DISABLED"; + readonly createdAt: Date; + readonly updatedAt: Date; + }, + secret: { readonly version: number; readonly keyId: string } | null, +): ProviderConnectionMetadata { + return { + id: connection.id, + providerId: connection.providerId, + mode: connection.mode, + status: connection.status, + activeVersion: secret?.version ?? null, + keyId: secret?.keyId ?? null, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + }; +} + +function validateStoredProviderPayload(value: unknown, providerId: string): ProviderSecretPayloadV1 { + if (!isRecord(value) || value["schemaVersion"] !== 1 || typeof value["baseUrl"] !== "string" || + typeof value["authToken"] !== "string" || typeof value["anthropicApiKey"] !== "string" || + value["authToken"].trim() === "") { + throw new Error(`invalid encrypted provider credential payload: ${providerId}`); + } + let baseUrl: URL; + try { + baseUrl = new URL(value["baseUrl"]); + } catch (error) { + throw new Error(`invalid encrypted provider credential payload: ${providerId}`, { cause: error }); + } + if (baseUrl.protocol !== "https:") { + throw new Error(`invalid encrypted provider credential payload: ${providerId}`); + } + return { + schemaVersion: 1, + baseUrl: value["baseUrl"], + authToken: value["authToken"], + anthropicApiKey: value["anthropicApiKey"], + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function lockConnection(tx: Prisma.TransactionClient, connectionId: string): Promise { + await tx.$queryRaw`SELECT "id" FROM "OrganizationProviderConnection" WHERE "id" = ${connectionId} FOR UPDATE`; +} + +async function requireByokActor( + tx: Prisma.TransactionClient, + input: Pick, +): Promise { + await lockActiveOrganization(tx, input.organizationId); + const [authorizedActor, existingConnection] = await Promise.all([ + tx.organizationMembership.findFirst({ + where: { + organizationId: input.organizationId, + userId: input.actorUserId, + role: { in: ["OWNER", "ADMIN"] }, + revokedAt: null, + }, + select: { id: true }, + }), + tx.organizationProviderConnection.findUnique({ + where: { + organizationId_providerId: { + organizationId: input.organizationId, + providerId: input.providerId, + }, + }, + select: { mode: true }, + }), + ]); + if (authorizedActor === null) { + throw new Error("BYOK provider rotation requires an active Organization OWNER or ADMIN"); + } + if (existingConnection?.mode === "PLATFORM_MANAGED") { + throw new Error( + "provider connection is managed by platform administrators and cannot be changed through organization API", + ); + } +} diff --git a/hub/src/connections/providerProxy.ts b/hub/src/connections/providerProxy.ts new file mode 100644 index 0000000..271fdb3 --- /dev/null +++ b/hub/src/connections/providerProxy.ts @@ -0,0 +1,225 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http"; +import { once } from "node:events"; +import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js"; + +const MAX_PROVIDER_REQUEST_BYTES = 32 * 1024 * 1024; +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); +const REPLACED_REQUEST_HEADERS = new Set([ + ...HOP_BY_HOP_HEADERS, + "authorization", + "content-length", + "host", + "x-api-key", +]); + +export interface ProviderUpstreamCredential { + readonly baseUrl: string; + readonly authToken: string; + readonly anthropicApiKey: string; +} + +export interface AgentProviderLease { + readonly sdkEnv: Readonly>; + readonly sensitiveValues: readonly string[]; + close(): Promise; +} + +export interface ProviderProxyDiagnostic { + readonly code: + | "provider_proxy_unauthorized" + | "provider_proxy_request_too_large" + | "provider_proxy_redirect_refused" + | "provider_proxy_upstream_failed"; + readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect"; +} + +export interface ProviderProxyOptions { + readonly onDiagnostic?: (diagnostic: ProviderProxyDiagnostic) => void; +} + +/** + * Starts one loopback-only proxy for one Agent run. The Agent receives only a + * random run capability; customer provider credentials stay in this Hub + * closure and are attached immediately before the upstream request. + */ +export async function openProviderProxyLease( + upstream: ProviderUpstreamCredential, + options: ProviderProxyOptions = {}, +): Promise { + const capability = randomBytes(32).toString("base64url"); + const handler = (request: IncomingMessage, response: ServerResponse): void => { + void forwardProviderRequest(request, response, capability, upstream, options); + }; + const server = createServer(handler); + server.requestTimeout = 5 * 60_000; + server.headersTimeout = 30_000; + server.maxRequestsPerSocket = 1_000; + server.listen(0, "127.0.0.1"); + await Promise.race([ + once(server, "listening"), + once(server, "error").then(([error]) => Promise.reject(error)), + ]); + server.unref(); + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + throw new Error("provider proxy did not bind a TCP address"); + } + + let closed = false; + return { + sdkEnv: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, + ANTHROPIC_AUTH_TOKEN: capability, + ANTHROPIC_API_KEY: "", + }, + sensitiveValues: [capability], + async close(): Promise { + if (closed) return; + closed = true; + server.closeAllConnections(); + if (!server.listening) return; + server.close(); + await once(server, "close"); + }, + }; +} + +async function forwardProviderRequest( + request: IncomingMessage, + response: ServerResponse, + capability: string, + upstream: ProviderUpstreamCredential, + options: ProviderProxyOptions, +): Promise { + if (!authorized(request.headers.authorization, capability)) { + options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" }); + response.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); + response.end("unauthorized"); + return; + } + + try { + const requestUrl = new URL(request.url ?? "/", "http://provider-proxy.invalid"); + const upstreamBase = upstream.baseUrl.endsWith("/") ? upstream.baseUrl : `${upstream.baseUrl}/`; + const upstreamUrl = new URL(`${requestUrl.pathname.replace(/^\//, "")}${requestUrl.search}`, upstreamBase); + const body = await readBoundedBody(request); + const headers = forwardedRequestHeaders(request.headers); + headers.set("authorization", `Bearer ${upstream.authToken}`); + if (upstream.anthropicApiKey !== "") { + headers.set("x-api-key", upstream.anthropicApiKey); + } + headers.set("accept-encoding", "identity"); + + const abort = new AbortController(); + request.once("aborted", () => abort.abort()); + const upstreamResponse = await fetch(upstreamUrl, { + method: request.method ?? "GET", + headers, + ...(body.byteLength === 0 ? {} : { body }), + redirect: "manual", + signal: abort.signal, + }); + if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { + options.onDiagnostic?.({ code: "provider_proxy_redirect_refused", category: "redirect" }); + await upstreamResponse.body?.cancel(); + response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + response.end("provider redirect refused"); + return; + } + response.writeHead(upstreamResponse.status, forwardedResponseHeaders(upstreamResponse.headers)); + if (upstreamResponse.body === null) { + response.end(); + return; + } + for await (const chunk of upstreamResponse.body) { + if (!response.write(Buffer.from(chunk))) { + await once(response, "drain"); + } + } + response.end(); + } catch (error) { + const diagnostic = error instanceof ProviderProxyRequestError + ? { code: error.code, category: error.category } as const + : { + code: "provider_proxy_upstream_failed", + category: classifyNetworkFailure(error), + } as const; + options.onDiagnostic?.(diagnostic); + if (!response.headersSent) { + response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + response.end("provider request failed"); + } else { + response.destroy(); + } + } +} + +function authorized(value: string | undefined, capability: string): boolean { + if (value === undefined || !value.startsWith("Bearer ")) return false; + const supplied = Buffer.from(value.slice("Bearer ".length)); + const expected = Buffer.from(capability); + return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected); +} + +function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers { + const result = new Headers(); + for (const [name, value] of Object.entries(source)) { + if (value === undefined || REPLACED_REQUEST_HEADERS.has(name.toLowerCase())) continue; + if (Array.isArray(value)) { + for (const item of value) result.append(name, item); + } else { + result.set(name, value); + } + } + return result; +} + +function forwardedResponseHeaders(source: Headers): Record { + const result: Record = {}; + for (const [name, value] of source.entries()) { + const normalized = name.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "content-length" || + normalized === "content-encoding") continue; + result[name] = value; + } + return result; +} + +async function readBoundedBody(request: IncomingMessage): Promise { + const declared = Number(request.headers["content-length"] ?? 0); + if (Number.isFinite(declared) && declared > MAX_PROVIDER_REQUEST_BYTES) { + throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit"); + } + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + bytes += buffer.byteLength; + if (bytes > MAX_PROVIDER_REQUEST_BYTES) { + throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit"); + } + chunks.push(buffer); + } + return Buffer.concat(chunks, bytes); +} + +class ProviderProxyRequestError extends Error { + constructor( + readonly code: "provider_proxy_request_too_large", + readonly category: "request_limit", + ) { + super(code); + this.name = "ProviderProxyRequestError"; + } +} diff --git a/hub/src/connections/providerReadiness.ts b/hub/src/connections/providerReadiness.ts new file mode 100644 index 0000000..83f481c --- /dev/null +++ b/hub/src/connections/providerReadiness.ts @@ -0,0 +1,65 @@ +import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js"; + +export interface ProviderReadinessInput { + readonly providerId: string; + readonly baseUrl: string; + readonly authToken: string; + readonly anthropicApiKey: string; +} + +export type ProviderReadinessProbe = (input: ProviderReadinessInput) => Promise; + +export class ProviderReadinessError extends Error { + constructor( + readonly code: "provider_readiness_unsupported" | "provider_readiness_unreachable" | "provider_readiness_rejected", + message: string, + readonly category: NetworkFailureCategory | "configuration" | "http", + readonly upstreamStatus?: number, + ) { + super(message); + this.name = "ProviderReadinessError"; + } +} + +/** Verify the pilot's OpenRouter credential without starting or billing a model run. */ +export async function probeOpenRouterCredential(input: ProviderReadinessInput): Promise { + if (input.providerId !== "openrouter") { + throw new ProviderReadinessError( + "provider_readiness_unsupported", + `provider readiness probe is unavailable: ${input.providerId}`, + "configuration", + ); + } + const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`; + const url = new URL("v1/key", base); + const headers = new Headers({ + authorization: `Bearer ${input.authToken}`, + accept: "application/json", + }); + if (input.anthropicApiKey !== "") headers.set("x-api-key", input.anthropicApiKey); + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers, + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + throw new ProviderReadinessError( + "provider_readiness_unreachable", + "provider credential readiness check could not reach provider", + classifyNetworkFailure(error), + ); + } + await response.body?.cancel(); + if (!response.ok) { + throw new ProviderReadinessError( + "provider_readiness_rejected", + `provider credential readiness check failed: status ${response.status}`, + "http", + response.status, + ); + } +} diff --git a/hub/src/deployment/preflight-cli.ts b/hub/src/deployment/preflight-cli.ts index e23296a..5886682 100644 --- a/hub/src/deployment/preflight-cli.ts +++ b/hub/src/deployment/preflight-cli.ts @@ -1,11 +1,13 @@ #!/usr/bin/env node import { execFile } from "node:child_process"; import { constants } from "node:fs"; -import { access, readFile, realpath, stat } from "node:fs/promises"; +import { access, lstat, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { PrismaClient } from "@prisma/client"; import { parse } from "dotenv"; +import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js"; +import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js"; import { DeploymentPreflightError, validateDeploymentPreflight, @@ -18,6 +20,7 @@ const execFileAsync = promisify(execFile); interface Options { readonly envFile: string; + readonly keyringFile: string; readonly nodeBin: string; readonly runuserBin: string; readonly setprivBin: string; @@ -47,6 +50,7 @@ async function main(): Promise { const canonicalInput = await canonicalizeInput(rawInput); const result = validateDeploymentPreflight(canonicalInput); await validateSecretFile(options.envFile); + const secretEnvelope = await validateKeyringFile(options.keyringFile); await validateNodeVersion(options.nodeBin); await validateExecutable(options.runuserBin, "runuser"); await validateExecutable(options.setprivBin, "setpriv"); @@ -56,7 +60,7 @@ async function main(): Promise { await validateExecutable(options.pgIsReadyBin, "pg_isready"); await validateCph(result.cphBin); await validatePostgres(options.pgIsReadyBin, result); - await validatePostgresQuery(result.databaseUrl); + await validatePostgresQuery(result.databaseUrl, secretEnvelope); if (options.serviceUid !== undefined && options.serviceGid !== undefined) { await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid); @@ -125,6 +129,22 @@ async function validateSecretFile(path: string): Promise { } } +async function validateKeyringFile(path: string): Promise { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error(`local secret keyring is not a regular non-symlink file: ${path}`); + } + if ((metadata.mode & 0o077) !== 0) { + throw new Error(`local secret keyring must not be accessible by group or others: ${path}`); + } + const keyring = await loadLocalSecretKeyring({ + NODE_ENV: "preflight", + HUB_SECRET_KEYRING_FILE: path, + }); + console.log("[preflight] local secret keyring is valid"); + return new LocalSecretEnvelope(keyring); +} + async function validateNodeVersion(nodeBin: string): Promise { let stdout: string; try { @@ -189,14 +209,44 @@ async function validatePostgres(pgIsReadyBin: string, result: DeploymentPrefligh console.log("[preflight] PostgreSQL is accepting connections"); } -async function validatePostgresQuery(databaseUrl: string): Promise { +async function validatePostgresQuery( + databaseUrl: string, + secretEnvelope: LocalSecretEnvelope, +): Promise { const client = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [], }); let queryFailure: unknown; + let envelopeFailure: unknown; try { await client.$queryRawUnsafe("SELECT 1"); + const migrationTable = await client.$queryRaw>` + SELECT to_regclass('public."_prisma_migrations"')::text AS "relation" + `; + if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) { + console.log("[preflight] empty database has no migration table or stored provider envelopes"); + } else { + const migration = await client.$queryRaw>` + SELECT "finished_at", "rolled_back_at" + FROM "_prisma_migrations" + WHERE "migration_name" = '20260710220000_org_provider_secret_envelopes' + ORDER BY "started_at" DESC + LIMIT 1 + `; + const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined && + migration[0]?.rolled_back_at === null; + if (!applied) { + console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate"); + } else { + try { + const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope); + console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`); + } catch (error) { + envelopeFailure = error; + } + } + } } catch (error) { queryFailure = error; } @@ -222,6 +272,12 @@ async function validatePostgresQuery(databaseUrl: string): Promise { { cause: disconnectFailure }, ); } + if (envelopeFailure !== undefined) { + throw new Error( + `stored provider envelope verification failed (${formatFailure(envelopeFailure)})`, + { cause: envelopeFailure }, + ); + } console.log("[preflight] PostgreSQL authenticated query succeeded"); } @@ -383,6 +439,7 @@ function parseArgs(argv: readonly string[]): Options { const allowed = new Set([ "--env-file", + "--keyring-file", "--node-bin", "--runuser-bin", "--setpriv-bin", @@ -411,6 +468,7 @@ function parseArgs(argv: readonly string[]): Options { return { envFile: required(values, "--env-file"), + keyringFile: required(values, "--keyring-file"), nodeBin: required(values, "--node-bin"), runuserBin: required(values, "--runuser-bin"), setprivBin: required(values, "--setpriv-bin"), diff --git a/hub/src/deployment/preflight.ts b/hub/src/deployment/preflight.ts index aa6170b..4e8b864 100644 --- a/hub/src/deployment/preflight.ts +++ b/hub/src/deployment/preflight.ts @@ -83,12 +83,11 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL"); } - const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems); - if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) { - problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL"); + for (const name of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) { + if (Object.hasOwn(input.env, name)) { + problems.push(`${name} must not be set; configure Organization Provider Connections`); + } } - readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems); - requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems); readRequired(input.env, "FEISHU_APP_ID", problems); readRequired(input.env, "FEISHU_APP_SECRET", problems); @@ -139,12 +138,6 @@ function readRequired(env: DeploymentEnv, name: string, problems: string[]): str return value; } -function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void { - if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") { - problems.push(`${name} must be present and empty`); - } -} - function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void { const raw = env[name]?.trim(); if (raw === undefined || raw === "") return; @@ -184,15 +177,6 @@ function isPostgresUrl(value: string): boolean { } } -function isHttpUrl(value: string): boolean { - try { - const url = new URL(value); - return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== ""; - } catch { - return false; - } -} - function isHttpsUrl(value: string): boolean { try { const url = new URL(value); diff --git a/hub/src/deployment/rotate-secret-kek.ts b/hub/src/deployment/rotate-secret-kek.ts new file mode 100644 index 0000000..001f498 --- /dev/null +++ b/hub/src/deployment/rotate-secret-kek.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { PrismaClient } from "@prisma/client"; +import { rotateLocalKek } from "../security/localKekRotation.js"; + +const execFileAsync = promisify(execFile); +const SERVICE_UNIT = "cph-hub.service"; + +async function main(): Promise { + if (process.getuid?.() !== 0) { + throw new Error("local KEK rotation must run as root"); + } + await requireStoppedService(); + const keyringFile = parseKeyringFile(process.argv.slice(2)); + const databaseUrl = process.env["DATABASE_URL"]?.trim(); + if (databaseUrl === undefined || databaseUrl === "") { + throw new Error("DATABASE_URL is required for local KEK rotation"); + } + const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] }); + try { + const result = await rotateLocalKek({ keyringFile, prisma }); + console.log( + `[secret-kek] active=${result.activeKeyId} rewrapped=${result.rewrapped} verified=${result.verified}`, + ); + console.log("[secret-kek] back up the updated keyring separately before retiring any previous key"); + } finally { + await prisma.$disconnect(); + } +} + +async function requireStoppedService(): Promise { + let activeState: string; + try { + const { stdout } = await execFileAsync( + "systemctl", + ["show", "--property=ActiveState", "--value", SERVICE_UNIT], + { timeout: 10_000 }, + ); + activeState = stdout.trim(); + } catch { + throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`); + } + if (activeState !== "inactive" && activeState !== "failed") { + throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`); + } +} + +function parseKeyringFile(argv: readonly string[]): string { + if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") { + throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json"); + } + return argv[1]; +} + +main().catch((error: unknown) => { + console.error(`[secret-kek] failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index e6fc108..56e0ff7 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -410,63 +410,72 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // stop the SDK query. Cleared in the finally below. const abortController = new AbortController(); activeRuns.set(run.id, abortController); - const sdkStderr = createAgentSdkStderrSink({ - logger: deps.logger, - runId: run.id, - projectId, - sensitiveValues: Object.values(provider.sdkEnv), - }); - runAgent({ - prompt: promptForAgent, - model, - project: projectCtx, - systemPrompt, - providerEnv: provider.sdkEnv, - resumeSessionId: sessionMetadata.claudeSessionId, - tools: roleTools, - mcpServers: { cph_hub: fileDeliveryMcpServer }, - maxTurns: runPolicy.maxTurns, - runId: run.id, - sessionId: session.id, - prisma: deps.prisma, - abortController, - onSdkStderr: sdkStderr.write, - onStream: (event) => { - switch (event.type) { - case "text-delta": - card.appendText(event.text); - break; - case "thinking-delta": - card.appendReasoning(event.text); - break; - case "tool-start": - card.onToolStart(event.toolName, event.toolUseId); - break; - case "tool-end": - card.onToolEnd({ - toolName: event.toolName, - toolUseId: event.toolUseId, - input: event.input, - result: undefined, - error: undefined, - durationMs: event.durationMs, - }); - break; - case "tool-result": - card.onToolEnd({ - toolName: event.toolName, - toolUseId: event.toolUseId, - input: undefined, - result: event.result, - error: event.isError ? event.result : undefined, - durationMs: event.durationMs, - }); - break; - case "finish": - break; - } - }, - }) + const agentExecution = (async () => { + const providerLease = await provider.openAgentLease({ runId: run.id }); + const sdkStderr = createAgentSdkStderrSink({ + logger: deps.logger, + runId: run.id, + projectId, + sensitiveValues: providerLease.sensitiveValues, + }); + try { + return await runAgent({ + prompt: promptForAgent, + model, + project: projectCtx, + systemPrompt, + providerProxyEnv: { ...providerLease.sdkEnv }, + resumeSessionId: sessionMetadata.claudeSessionId, + tools: roleTools, + mcpServers: { cph_hub: fileDeliveryMcpServer }, + maxTurns: runPolicy.maxTurns, + runId: run.id, + sessionId: session.id, + prisma: deps.prisma, + abortController, + onSdkStderr: sdkStderr.write, + onStream: (event) => { + switch (event.type) { + case "text-delta": + card.appendText(event.text); + break; + case "thinking-delta": + card.appendReasoning(event.text); + break; + case "tool-start": + card.onToolStart(event.toolName, event.toolUseId); + break; + case "tool-end": + card.onToolEnd({ + toolName: event.toolName, + toolUseId: event.toolUseId, + input: event.input, + result: undefined, + error: undefined, + durationMs: event.durationMs, + }); + break; + case "tool-result": + card.onToolEnd({ + toolName: event.toolName, + toolUseId: event.toolUseId, + input: undefined, + result: event.result, + error: event.isError ? event.result : undefined, + durationMs: event.durationMs, + }); + break; + case "finish": + break; + } + }, + }); + } finally { + sdkStderr.flush(); + await providerLease.close(); + } + })(); + agentExecution .then(async (result) => { const interrupted = result.status === "interrupted"; const finalText = @@ -519,7 +528,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } }); }) .finally(async () => { - sdkStderr.flush(); try { await releaseLock(deps.prisma, run.id); } catch (e) { diff --git a/hub/src/hub.ts b/hub/src/hub.ts index 4a75443..c9b03cb 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -5,7 +5,10 @@ import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client import { makeTriggerHandler } from "./feishu/trigger.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; import { triggerQueue } from "./feishu/triggerQueue.js"; -import { createEnvRuntimeSettings } from "./settings/runtime.js"; +import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEnvelope.js"; +import { createDatabaseRuntimeSettings } from "./settings/runtime.js"; +import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js"; +import { openProviderProxyLease } from "./connections/providerProxy.js"; import { readServerBinding } from "./settings/server.js"; function requireEnv(name: string): string { @@ -31,8 +34,25 @@ export async function startHub(): Promise { const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot); app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages"); - const runtimeSettings = createEnvRuntimeSettings(); - await runtimeSettings.provider("openrouter"); + const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring()); + const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope); + app.log.info({ verified: verifiedProviderEnvelopes }, "startup: authenticated stored provider envelopes"); + const runtimeSettings = createDatabaseRuntimeSettings( + prisma, + secretEnvelope, + process.env, + (credential, context) => openProviderProxyLease(credential, { + onDiagnostic: (diagnostic) => { + app.log.error({ + runId: context.runId, + projectId: context.projectId, + providerId: context.providerId, + errorCode: diagnostic.code, + failureCategory: diagnostic.category, + }, "provider proxy diagnostic"); + }, + }), + ); const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); @@ -58,6 +78,7 @@ export async function startHub(): Promise { feishuAppId, feishuAppSecret, projectWorkspaceRoot, + secretEnvelope, }); const address = await app.listen(bind); diff --git a/hub/src/security/localKekRotation.ts b/hub/src/security/localKekRotation.ts new file mode 100644 index 0000000..5748de8 --- /dev/null +++ b/hub/src/security/localKekRotation.ts @@ -0,0 +1,204 @@ +import { randomBytes } from "node:crypto"; +import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import type { Prisma, PrismaClient } from "@prisma/client"; +import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js"; +import { + LocalSecretEnvelope, + loadLocalSecretKeyringFile, + type LocalSecretKeyring, +} from "./secretEnvelope.js"; + +export interface RotateLocalKekInput { + readonly keyringFile: string; + readonly prisma: PrismaClient; + readonly now?: Date; + readonly generatedKey?: Buffer; + readonly expectedOwner?: { readonly uid: number; readonly gid: number }; +} + +export interface RotateLocalKekResult { + readonly previousActiveKeyId: string; + readonly activeKeyId: string; + readonly verified: number; + readonly rewrapped: number; +} + +export const LOCAL_KEK_ROTATION_ADVISORY_LOCK = 7_310_579_862_737_525_041n; + +/** + * Atomically installs a new active KEK while retaining all previous keys, then + * rewraps database DEKs. A crash after the file rename is recoverable because + * both old and new KEKs remain in the keyring and the operation is rerunnable. + */ +export async function rotateLocalKek(input: RotateLocalKekInput): Promise { + if (!isAbsolute(input.keyringFile)) { + throw new Error("local secret keyring path must be absolute"); + } + const expectedOwner = input.expectedOwner ?? { uid: 0, gid: 0 }; + const initialMetadata = await lstat(input.keyringFile); + assertKeyringMetadata(initialMetadata, expectedOwner); + return input.prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw>` + SELECT pg_try_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK}) AS "locked" + `; + if (rows[0]?.locked !== true) { + throw new Error("local KEK rotation lock is already held"); + } + return rotateUnderLock(input, expectedOwner, tx); + }, { maxWait: 5_000, timeout: 300_000 }); +} + +async function rotateUnderLock( + input: RotateLocalKekInput, + expectedOwner: { readonly uid: number; readonly gid: number }, + tx: Prisma.TransactionClient, +): Promise { + const metadata = await lstat(input.keyringFile); + assertKeyringMetadata(metadata, expectedOwner); + + const previous = await loadLocalSecretKeyringFile(input.keyringFile); + const generatedKey = input.generatedKey === undefined + ? randomBytes(32) + : Buffer.from(input.generatedKey); + if (generatedKey.byteLength !== 32) { + generatedKey.fill(0); + throw new Error("generated local KEK must contain exactly 32 bytes"); + } + const keyId = nextKeyId(input.now ?? new Date()); + if (previous.keys.has(keyId)) { + generatedKey.fill(0); + throw new Error(`generated local KEK identifier already exists: ${keyId}`); + } + + const keys = new Map(previous.keys); + keys.set(keyId, generatedKey); + const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys }; + try { + await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid); + const result = await rewrapStoredProviderEnvelopes( + tx, + new LocalSecretEnvelope(keyring), + ); + return { + previousActiveKeyId: previous.activeKeyId, + activeKeyId: keyId, + ...result, + }; + } finally { + generatedKey.fill(0); + } +} + +function assertKeyringMetadata( + metadata: { + readonly mode: number; + readonly uid: number; + readonly gid: number; + isFile(): boolean; + isSymbolicLink(): boolean; + }, + expectedOwner: { readonly uid: number; readonly gid: number }, +): void { + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error("local secret keyring must be a regular non-symlink file"); + } + if (metadata.uid !== expectedOwner.uid || metadata.gid !== expectedOwner.gid) { + throw new Error( + `local secret keyring must be owned by ${expectedOwner.uid}:${expectedOwner.gid}`, + ); + } + if ((metadata.mode & 0o777) !== 0o600) { + throw new Error("local secret keyring must have mode 0600"); + } +} + +async function replaceKeyringAtomically( + file: string, + keyring: LocalSecretKeyring, + uid: number, + gid: number, +): Promise { + const directory = dirname(file); + const temporary = join(directory, `.${basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`); + const encodedKeys = Object.fromEntries( + [...keyring.keys].map(([keyId, key]) => [keyId, key.toString("base64")]), + ); + const contents = `${JSON.stringify({ version: 1, activeKeyId: keyring.activeKeyId, keys: encodedKeys })}\n`; + let handle: FileHandle | undefined; + try { + handle = await open(temporary, "wx", 0o600); + await handle.chown(uid, gid); + await handle.writeFile(contents, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, file); + await syncDirectory(directory); + } catch (error) { + const cleanupFailures: unknown[] = []; + if (handle !== undefined) { + try { + await handle.close(); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + } + try { + await removeIfExists(temporary); + } catch (cleanupError) { + cleanupFailures.push(cleanupError); + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + "local keyring replacement and cleanup both failed", + { cause: error }, + ); + } + throw error; + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, "r"); + let syncFailure: unknown; + try { + await handle.sync(); + } catch (error) { + syncFailure = error; + } + let closeFailure: unknown; + try { + await handle.close(); + } catch (error) { + closeFailure = error; + } + if (syncFailure !== undefined && closeFailure !== undefined) { + throw new AggregateError( + [syncFailure, closeFailure], + "keyring directory sync and handle close both failed", + { cause: syncFailure }, + ); + } + if (syncFailure !== undefined) throw syncFailure; + if (closeFailure !== undefined) throw closeFailure; +} + +async function removeIfExists(file: string): Promise { + try { + await unlink(file); + } catch (error) { + if (isErrno(error, "ENOENT")) return; + throw error; + } +} + +function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} + +function nextKeyId(now: Date): string { + const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + return `local-${timestamp}-${randomBytes(4).toString("hex")}`; +} diff --git a/hub/src/security/secretEnvelope.ts b/hub/src/security/secretEnvelope.ts new file mode 100644 index 0000000..9c35881 --- /dev/null +++ b/hub/src/security/secretEnvelope.ts @@ -0,0 +1,268 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const KEY_BYTES = 32; +const NONCE_BYTES = 12; +const AUTH_TAG_BYTES = 16; +const PRODUCTION_CREDENTIAL_NAME = "cph-secret-keyring"; + +export interface SecretBinding { + readonly purpose: string; + readonly organizationId: string; + readonly connectionId: string; + readonly secretVersionId: string; +} + +export interface EncryptedPart { + readonly nonce: string; + readonly ciphertext: string; + readonly authTag: string; +} + +export interface SecretEnvelopeV1 { + readonly version: 1; + readonly algorithm: "AES-256-GCM"; + readonly keyId: string; + readonly wrappedDek: EncryptedPart; + readonly payload: EncryptedPart; +} + +export interface LocalSecretKeyring { + readonly activeKeyId: string; + readonly keys: ReadonlyMap; +} + +export type SecretKeyringEnv = Readonly>; + +/** + * Loads the one local KEK source accepted by the current runtime. Production + * deliberately has no file-path fallback: systemd must materialize the fixed + * credential below CREDENTIALS_DIRECTORY. + */ +export async function loadLocalSecretKeyring( + env: SecretKeyringEnv = process.env, +): Promise { + const nodeEnv = env["NODE_ENV"]?.trim(); + const configuredFile = env["HUB_SECRET_KEYRING_FILE"]?.trim(); + let file: string; + + if (nodeEnv === "production") { + if (configuredFile !== undefined && configuredFile !== "") { + throw new Error("production secret keyring must use the systemd credential"); + } + const credentialsDirectory = env["CREDENTIALS_DIRECTORY"]?.trim(); + if (credentialsDirectory === undefined || credentialsDirectory === "") { + throw new Error("missing required secret keyring setting: CREDENTIALS_DIRECTORY"); + } + file = path.join(credentialsDirectory, PRODUCTION_CREDENTIAL_NAME); + } else { + if (configuredFile === undefined || configuredFile === "") { + throw new Error("missing required secret keyring setting: HUB_SECRET_KEYRING_FILE"); + } + file = configuredFile; + } + + return loadLocalSecretKeyringFile(file); +} + +export async function loadLocalSecretKeyringFile(file: string): Promise { + let source: string; + try { + source = await readFile(file, "utf8"); + } catch (error) { + throw new Error("local secret keyring is unavailable", { cause: error }); + } + + return parseKeyring(source); +} + +export class LocalSecretEnvelope { + constructor(private readonly keyring: LocalSecretKeyring) {} + + get activeKeyId(): string { + return this.keyring.activeKeyId; + } + + encryptJson(binding: SecretBinding, value: unknown): SecretEnvelopeV1 { + validateBinding(binding); + const kek = requireKey(this.keyring, this.keyring.activeKeyId); + const dek = randomBytes(KEY_BYTES); + const payloadBytes = Buffer.from(JSON.stringify(value), "utf8"); + + try { + return { + version: 1, + algorithm: "AES-256-GCM", + keyId: this.keyring.activeKeyId, + wrappedDek: encryptPart(kek, dek, additionalData(binding, "dek")), + payload: encryptPart(dek, payloadBytes, additionalData(binding, "payload")), + }; + } finally { + dek.fill(0); + payloadBytes.fill(0); + } + } + + decryptJson(binding: SecretBinding, envelope: SecretEnvelopeV1): T { + validateBinding(binding); + validateEnvelope(envelope); + const kek = requireKey(this.keyring, envelope.keyId); + const dek = decryptPart(kek, envelope.wrappedDek, additionalData(binding, "dek")); + + try { + const plaintext = decryptPart(dek, envelope.payload, additionalData(binding, "payload")); + try { + return JSON.parse(plaintext.toString("utf8")) as T; + } catch (error) { + throw new Error("secret envelope payload is invalid", { cause: error }); + } finally { + plaintext.fill(0); + } + } finally { + dek.fill(0); + } + } + + rewrap(binding: SecretBinding, envelope: SecretEnvelopeV1): SecretEnvelopeV1 { + validateBinding(binding); + validateEnvelope(envelope); + const previousKek = requireKey(this.keyring, envelope.keyId); + const activeKek = requireKey(this.keyring, this.keyring.activeKeyId); + const dek = decryptPart(previousKek, envelope.wrappedDek, additionalData(binding, "dek")); + + try { + return { + ...envelope, + keyId: this.keyring.activeKeyId, + wrappedDek: encryptPart(activeKek, dek, additionalData(binding, "dek")), + }; + } finally { + dek.fill(0); + } + } +} + +function parseKeyring(source: string): LocalSecretKeyring { + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch (error) { + throw new Error("invalid local secret keyring", { cause: error }); + } + + if (!isRecord(value) || value["version"] !== 1 || !isNonEmptyString(value["activeKeyId"]) || + !isRecord(value["keys"])) { + throw new Error("invalid local secret keyring"); + } + + const keys = new Map(); + for (const [keyId, encodedKey] of Object.entries(value["keys"])) { + if (!isNonEmptyString(keyId) || !isNonEmptyString(encodedKey)) { + throw new Error("invalid local secret keyring"); + } + const key = decodeBase64(encodedKey, "invalid local secret keyring"); + if (key.byteLength !== KEY_BYTES) { + throw new Error("invalid local secret keyring"); + } + keys.set(keyId, key); + } + + const activeKeyId = value["activeKeyId"]; + if (!keys.has(activeKeyId)) { + throw new Error("local secret keyring active key is unavailable"); + } + return { activeKeyId, keys }; +} + +function encryptPart(key: Buffer, plaintext: Buffer, aad: Buffer): EncryptedPart { + const nonce = randomBytes(NONCE_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES }); + cipher.setAAD(aad); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + authTag: authTag.toString("base64"), + }; +} + +function decryptPart(key: Buffer, encrypted: EncryptedPart, aad: Buffer): Buffer { + try { + const nonce = decodeBase64(encrypted.nonce, "secret envelope is malformed"); + const ciphertext = decodeBase64(encrypted.ciphertext, "secret envelope is malformed"); + const authTag = decodeBase64(encrypted.authTag, "secret envelope is malformed"); + if (nonce.byteLength !== NONCE_BYTES || authTag.byteLength !== AUTH_TAG_BYTES) { + throw new Error("secret envelope is malformed"); + } + const decipher = createDecipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES }); + decipher.setAAD(aad); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } catch (error) { + if (error instanceof Error && error.message === "secret envelope is malformed") { + throw error; + } + throw new Error("secret envelope authentication failed", { cause: error }); + } +} + +function additionalData(binding: SecretBinding, layer: "dek" | "payload"): Buffer { + return Buffer.from(JSON.stringify([ + "cph-secret-envelope", + 1, + layer, + binding.purpose, + binding.organizationId, + binding.connectionId, + binding.secretVersionId, + ]), "utf8"); +} + +function requireKey(keyring: LocalSecretKeyring, keyId: string): Buffer { + const key = keyring.keys.get(keyId); + if (key === undefined) { + throw new Error("secret envelope key is unavailable"); + } + return key; +} + +function validateBinding(binding: SecretBinding): void { + if (!isNonEmptyString(binding.purpose) || !isNonEmptyString(binding.organizationId) || + !isNonEmptyString(binding.connectionId) || !isNonEmptyString(binding.secretVersionId)) { + throw new Error("secret envelope binding is invalid"); + } +} + +function validateEnvelope(envelope: SecretEnvelopeV1): void { + if (!isRecord(envelope) || envelope["version"] !== 1 || envelope["algorithm"] !== "AES-256-GCM" || + !isNonEmptyString(envelope["keyId"]) || !isEncryptedPart(envelope["wrappedDek"]) || + !isEncryptedPart(envelope["payload"])) { + throw new Error("secret envelope is malformed"); + } +} + +function isEncryptedPart(value: unknown): value is EncryptedPart { + return isRecord(value) && isNonEmptyString(value["nonce"]) && + isNonEmptyString(value["ciphertext"]) && isNonEmptyString(value["authTag"]); +} + +function decodeBase64(value: string, errorMessage: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(errorMessage); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.toString("base64") !== value) { + throw new Error(errorMessage); + } + return decoded; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} diff --git a/hub/src/server.ts b/hub/src/server.ts index d7b4a15..6ab6e45 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -10,8 +10,10 @@ * Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`. * * Env (see .env.example): - * DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT, - * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL + * DATABASE_URL, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT, + * HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL. + * Provider credentials are org-scoped encrypted records; production receives + * the local master-key keyring only through systemd CREDENTIALS_DIRECTORY. */ import "dotenv/config"; import { startHub } from "./hub.js"; diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts index 30345d8..c8a410c 100644 --- a/hub/src/settings/runtime.ts +++ b/hub/src/settings/runtime.ts @@ -1,22 +1,37 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js"; +import { lockActiveOrganization } from "../org/status.js"; +import { decryptStoredProviderCredential } from "../connections/providerConnections.js"; +import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js"; +import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; export type Env = Readonly>; type EnvSource = Env | (() => Env); -const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api"; const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5"; const DEFAULT_SONNET_LABEL = "Claude Sonnet 5"; const DEFAULT_AGENT_MAX_TURNS = 25; export interface ProviderRuntimeSettings { readonly id: string; - readonly baseUrl: string; - readonly authToken: string; - readonly anthropicApiKey: string; - readonly sdkEnv: Record; + openAgentLease(input: { readonly runId: string }): Promise; } +export interface ProviderLeaseContext { + readonly providerId: string; + readonly projectId: string; + readonly runId: string; +} + +export type ProviderLeaseFactory = ( + credential: ProviderUpstreamCredential, + context: ProviderLeaseContext, +) => Promise; + +const DEFAULT_PROVIDER_LEASE_FACTORY: ProviderLeaseFactory = (credential) => + openProviderProxyLease(credential); + export interface RuntimeScope { readonly projectId?: string; } @@ -44,26 +59,11 @@ export class EnvRuntimeSettings implements RuntimeSettings { } async provider(providerId: string, scope?: RuntimeScope): Promise { + void providerId; void scope; - if (providerId !== "openrouter") { - throw new Error(`unknown provider runtime settings: ${providerId}`); - } - const env = this.envSource(); - const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL; - const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN"); - const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? ""; - - return { - id: providerId, - baseUrl, - authToken, - anthropicApiKey, - sdkEnv: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: authToken, - ANTHROPIC_API_KEY: anthropicApiKey, - }, - }; + throw new Error( + "process-global provider credentials are disabled; use the project-scoped provider resolver", + ); } async modelRegistry(scope?: RuntimeScope): Promise { @@ -79,10 +79,124 @@ export class EnvRuntimeSettings implements RuntimeSettings { } } +export class DatabaseRuntimeSettings implements RuntimeSettings { + private readonly envSettings: EnvRuntimeSettings; + + constructor( + private readonly prisma: PrismaClient, + private readonly secrets: LocalSecretEnvelope, + env: EnvSource = process.env, + private readonly leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY, + ) { + this.envSettings = new EnvRuntimeSettings(env); + } + + async provider(providerId: string, scope?: RuntimeScope): Promise { + const projectId = scope?.projectId?.trim(); + if (projectId === undefined || projectId === "") { + throw new Error("projectId is required to resolve provider credentials"); + } + + await this.prisma.$transaction(async (tx) => { + await loadActiveProviderSecret(tx, projectId, providerId); + }); + + return { + id: providerId, + openAgentLease: async ({ runId }) => this.prisma.$transaction(async (tx) => { + const resolved = await loadActiveProviderSecret(tx, projectId, providerId); + const secretVersion = resolved.connection.activeSecretVersion; + const credential = decryptStoredProviderCredential(this.secrets, { + organizationId: resolved.organizationId, + connectionId: resolved.connection.id, + providerId, + secretVersionId: secretVersion.id, + envelopeVersion: secretVersion.envelopeVersion, + keyId: secretVersion.keyId, + envelope: secretVersion.envelope, + }); + return this.leaseFactory( + { + baseUrl: credential.baseUrl, + authToken: credential.authToken, + anthropicApiKey: credential.anthropicApiKey, + }, + { providerId, projectId, runId }, + ); + }), + }; + } + + modelRegistry(scope?: RuntimeScope): Promise { + return this.envSettings.modelRegistry(scope); + } + + runPolicy(input: RunPolicyInput): Promise { + return this.envSettings.runPolicy(input); + } +} + +async function loadActiveProviderSecret( + tx: Prisma.TransactionClient, + projectId: string, + providerId: string, +): Promise<{ + readonly organizationId: string; + readonly connection: { + readonly id: string; + readonly activeSecretVersion: { + readonly id: string; + readonly connectionId: string; + readonly envelopeVersion: number; + readonly keyId: string; + readonly envelope: Prisma.JsonValue; + readonly retiredAt: Date | null; + }; + }; +}> { + const project = await tx.project.findUnique({ + where: { id: projectId }, + select: { organizationId: true, archivedAt: true }, + }); + if (project === null || project.archivedAt !== null) { + throw new Error(`active project not found: ${projectId}`); + } + await lockActiveOrganization(tx, project.organizationId); + + const connection = await tx.organizationProviderConnection.findUnique({ + where: { + organizationId_providerId: { + organizationId: project.organizationId, + providerId, + }, + }, + include: { activeSecretVersion: true }, + }); + const activeSecretVersion = connection?.activeSecretVersion; + if (connection === null || connection.status !== "ACTIVE" || + activeSecretVersion === null || activeSecretVersion === undefined || activeSecretVersion.retiredAt !== null || + activeSecretVersion.connectionId !== connection.id) { + throw new Error(`active provider connection not found: ${providerId} for project ${projectId}`); + } + return { + organizationId: project.organizationId, + connection: { id: connection.id, activeSecretVersion }, + }; +} + export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings { return new EnvRuntimeSettings(env); } +export function createDatabaseRuntimeSettings( + prisma: PrismaClient, + secrets: LocalSecretEnvelope, + env: EnvSource = process.env, + leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY, +): RuntimeSettings { + return new DatabaseRuntimeSettings(prisma, secrets, env, leaseFactory); +} + export function defaultSonnetModel(env: Env = process.env): string { return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL; } @@ -109,14 +223,6 @@ function readEnv(env: Env, name: string): string | undefined { return value === undefined || value === "" ? undefined : value; } -function requireSetting(env: Env, name: string): string { - const value = readEnv(env, name); - if (value === undefined) { - throw new Error(`missing required runtime setting: ${name}`); - } - return value; -} - function positiveIntegerEnv(env: Env, name: string, fallback: number): number { const raw = readEnv(env, name); if (raw === undefined) return fallback; diff --git a/hub/test/integration/admin-auth.test.ts b/hub/test/integration/admin-auth.test.ts index 45fca9e..125f514 100644 --- a/hub/test/integration/admin-auth.test.ts +++ b/hub/test/integration/admin-auth.test.ts @@ -9,7 +9,7 @@ import { sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js"; -import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; const SESSION_SECRET = "integration-test-session-secret"; const PUBLIC_BASE = "http://127.0.0.1:8788"; @@ -23,6 +23,7 @@ async function buildApp(fetchImpl?: typeof fetch) { feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: "/tmp/cph-test-workspaces", + secretEnvelope: testSecretEnvelope, cookieSecure: false, ...(fetchImpl !== undefined ? { fetchImpl } : {}), }); diff --git a/hub/test/integration/admin-explorer.test.ts b/hub/test/integration/admin-explorer.test.ts index bc7807e..1aa6ef0 100644 --- a/hub/test/integration/admin-explorer.test.ts +++ b/hub/test/integration/admin-explorer.test.ts @@ -18,7 +18,7 @@ import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; -import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; const SESSION_SECRET = "integration-test-session-secret"; const workspaceRoots: string[] = []; @@ -32,6 +32,7 @@ async function buildApp(workspaceRoot: string) { feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: workspaceRoot, + secretEnvelope: testSecretEnvelope, cookieSecure: false, }); await app.ready(); diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts index 98bba7e..34db461 100644 --- a/hub/test/integration/admin-members-teams.test.ts +++ b/hub/test/integration/admin-members-teams.test.ts @@ -8,7 +8,7 @@ import { mintSessionToken, sessionCookieHeader, } from "../../src/admin/routes/authRoutes.js"; -import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; import { addOrgMember } from "../../src/org/members.js"; import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js"; @@ -23,6 +23,7 @@ async function buildApp() { feishuAppId: "cli_test", feishuAppSecret: "secret_test", projectWorkspaceRoot: "/tmp/cph-test-ws", + secretEnvelope: testSecretEnvelope, cookieSecure: false, }); await app.ready(); diff --git a/hub/test/integration/admin-provider-connections.test.ts b/hub/test/integration/admin-provider-connections.test.ts new file mode 100644 index 0000000..19ce40d --- /dev/null +++ b/hub/test/integration/admin-provider-connections.test.ts @@ -0,0 +1,214 @@ +import Fastify from "fastify"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { registerAdminPlugin } from "../../src/admin/plugin.js"; +import { ProviderConnectionService } from "../../src/connections/providerConnections.js"; +import { mintSessionToken, sessionCookieHeader } from "../../src/admin/routes/authRoutes.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js"; + +const SESSION_SECRET = "integration-test-session-secret"; +const secrets = testSecretEnvelope; + +async function buildApp() { + const app = Fastify({ logger: false }); + await registerAdminPlugin(app, { + prisma, + sessionSecret: SESSION_SECRET, + publicBaseUrl: "http://127.0.0.1:8788", + feishuAppId: "cli_test", + feishuAppSecret: "secret_test", + projectWorkspaceRoot: "/tmp/cph-test-workspaces", + secretEnvelope: secrets, + providerReadinessProbe: async () => {}, + cookieSecure: false, + }); + await app.ready(); + return app; +} + +async function seedUser(role: "OWNER" | "ADMIN" | "MEMBER"): Promise { + const id = `u-${role.toLowerCase()}`; + const openId = `ou_${role.toLowerCase()}`; + await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: id, role }, + }); + return mintSessionToken({ userId: id, feishuOpenId: openId }, SESSION_SECRET); +} + +describe("Organization BYOK provider connections", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("stores write-only credentials in an encrypted immutable version", async () => { + const token = await seedUser("OWNER"); + const app = await buildApp(); + const credential = { + baseUrl: "https://provider.example/api", + authToken: "org-a-auth-token-never-return", + anthropicApiKey: "org-a-api-key-never-return", + }; + try { + const response = await app.inject({ + method: "PUT", + url: "/api/org/test-default/provider-connections/openrouter", + headers: { + cookie: sessionCookieHeader(token), + "content-type": "application/json", + }, + payload: credential, + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toMatchObject({ + providerId: "openrouter", + mode: "BYOK", + status: "ACTIVE", + activeVersion: 1, + keyId: "test-active", + }); + expect(response.body).not.toContain(credential.authToken); + expect(response.body).not.toContain(credential.anthropicApiKey); + expect(response.body).not.toContain(credential.baseUrl); + + const persisted = await prisma.$queryRaw>` + SELECT v."envelope", c."activeSecretVersionId" + FROM "ProviderCredentialVersion" v + JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId" + WHERE c."organizationId" = ${DEFAULT_ORG_ID} AND c."providerId" = 'openrouter' + `; + expect(persisted).toHaveLength(1); + const serialized = JSON.stringify(persisted); + expect(serialized).not.toContain(credential.authToken); + expect(serialized).not.toContain(credential.anthropicApiKey); + expect(serialized).not.toContain(credential.baseUrl); + expect(persisted[0]?.activeSecretVersionId).toBeTruthy(); + } finally { + await app.close(); + } + }); + + it("rotates by appending a version and never exposes old or new plaintext", async () => { + const token = await seedUser("ADMIN"); + const app = await buildApp(); + const cookie = sessionCookieHeader(token); + try { + const first = await app.inject({ + method: "PUT", + url: "/api/org/test-default/provider-connections/openrouter", + headers: { cookie, "content-type": "application/json" }, + payload: { baseUrl: "https://one.example", authToken: "first-secret" }, + }); + const second = await app.inject({ + method: "PUT", + url: "/api/org/test-default/provider-connections/openrouter", + headers: { cookie, "content-type": "application/json" }, + payload: { baseUrl: "https://two.example", authToken: "second-secret" }, + }); + + expect(first.statusCode).toBe(201); + expect(second.statusCode).toBe(200); + expect(second.json()).toMatchObject({ activeVersion: 2, status: "ACTIVE" }); + const versions = await prisma.$queryRaw>` + SELECT v."version", v."retiredAt" + FROM "ProviderCredentialVersion" v + JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId" + WHERE c."organizationId" = ${DEFAULT_ORG_ID} + ORDER BY v."version" + `; + expect(versions).toEqual([ + { version: 1, retiredAt: expect.any(Date) }, + { version: 2, retiredAt: null }, + ]); + + const read = await app.inject({ + method: "GET", + url: "/api/org/test-default/provider-connections", + headers: { cookie }, + }); + expect(read.statusCode).toBe(200); + expect(read.json()).toEqual({ connections: [expect.objectContaining({ activeVersion: 2 })] }); + expect(read.body).not.toContain("first-secret"); + expect(read.body).not.toContain("second-secret"); + } finally { + await app.close(); + } + }); + + it("rejects members and refuses to overwrite a platform-managed connection", async () => { + const memberToken = await seedUser("MEMBER"); + const app = await buildApp(); + try { + const denied = await app.inject({ + method: "PUT", + url: "/api/org/test-default/provider-connections/openrouter", + headers: { + cookie: sessionCookieHeader(memberToken), + "content-type": "application/json", + }, + payload: { baseUrl: "https://provider.example", authToken: "denied-secret" }, + }); + expect(denied.statusCode).toBe(403); + const service = new ProviderConnectionService(prisma, secrets, async () => {}); + await expect(service.rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "another-provider", + actorUserId: "u-member", + baseUrl: "https://provider.example", + authToken: "service-bypass-secret", + })).rejects.toThrow("requires an active Organization OWNER or ADMIN"); + await expect(prisma.organizationProviderConnection.count({ + where: { organizationId: DEFAULT_ORG_ID, providerId: "another-provider" }, + })).resolves.toBe(0); + + await prisma.$executeRaw` + INSERT INTO "OrganizationProviderConnection" + ("id", "organizationId", "providerId", "mode", "status", "createdAt", "updatedAt") + VALUES + ('platform-connection', ${DEFAULT_ORG_ID}, 'openrouter', 'PLATFORM_MANAGED', 'DRAFT', NOW(), NOW()) + `; + const ownerToken = await seedUser("OWNER"); + const refused = await app.inject({ + method: "PUT", + url: "/api/org/test-default/provider-connections/openrouter", + headers: { + cookie: sessionCookieHeader(ownerToken), + "content-type": "application/json", + }, + payload: { baseUrl: "https://provider.example", authToken: "takeover-secret" }, + }); + expect(refused.statusCode).toBe(403); + expect(refused.json()).toMatchObject({ + error: { code: "forbidden" }, + }); + } finally { + await app.close(); + } + }); + + it("does not activate or persist a credential that fails readiness", async () => { + await seedUser("ADMIN"); + const service = new ProviderConnectionService(prisma, secrets, async () => { + throw new Error("provider credential readiness check failed: status 401"); + }); + + await expect(service.rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "openrouter", + actorUserId: "u-admin", + baseUrl: "https://provider.example/api", + authToken: "rejected-secret", + })).rejects.toThrow("readiness check failed"); + await expect(prisma.organizationProviderConnection.count({ + where: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" }, + })).resolves.toBe(0); + await expect(prisma.providerCredentialVersion.count()).resolves.toBe(0); + }); +}); diff --git a/hub/test/integration/agent-sandbox-linux.test.ts b/hub/test/integration/agent-sandbox-linux.test.ts index 6312f9b..87aeec8 100644 --- a/hub/test/integration/agent-sandbox-linux.test.ts +++ b/hub/test/integration/agent-sandbox-linux.test.ts @@ -125,10 +125,10 @@ describe("real Claude SDK sandbox boundary", () => { workspaceDir: workspace, }, systemPrompt: "Use the Bash tool exactly once, then report completion.", - providerEnv: { + providerProxyEnv: { ANTHROPIC_BASE_URL: stub.baseUrl, - ANTHROPIC_AUTH_TOKEN: "provider-secret", - ANTHROPIC_API_KEY: "provider-api-secret", + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", + ANTHROPIC_API_KEY: "", }, tools: ["bash"], maxTurns: 3, diff --git a/hub/test/integration/deployment-preflight-cli.test.ts b/hub/test/integration/deployment-preflight-cli.test.ts index 5bd41e7..9816949 100644 --- a/hub/test/integration/deployment-preflight-cli.test.ts +++ b/hub/test/integration/deployment-preflight-cli.test.ts @@ -4,6 +4,15 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_ORG_ID, + prisma, + resetDb, + testSecretEnvelope, + TEST_SECRET_KEY, + TEST_SECRET_KEY_ID, +} from "./helpers.js"; +import { ProviderConnectionService } from "../../src/connections/providerConnections.js"; const execFileAsync = promisify(execFile); const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; @@ -16,6 +25,7 @@ describe("deployment preflight CLI", () => { let marker: string; beforeEach(async () => { + await resetDb(); root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-")); baseDir = join(root, "deploy"); hubDir = join(baseDir, "current", "hub"); @@ -93,6 +103,23 @@ describe("deployment preflight CLI", () => { expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL"); }); + it("rejects a structurally valid keyring that cannot authenticate stored envelopes", async () => { + await prismaActorForEnvelope(); + const workspaceRoot = join(persistentDir, "workspaces"); + const fixture = await createFixture({ + workspaceRoot, + marker, + keyringKey: Buffer.alloc(32, "wrong"), + }); + + const result = await runCli(fixture.args); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("stored provider envelope verification failed"); + expect(result.stderr).toContain("secret envelope authentication failed"); + expect(result.stderr).not.toContain("stored-envelope-secret"); + }); + it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => { const result = await execFileAsync( resolve("node_modules/.bin/tsx"), @@ -138,6 +165,7 @@ describe("deployment preflight CLI", () => { marker: string; pgIsReadyScript?: string; databaseUrl?: string; + keyringKey?: Buffer; }): Promise<{ args: string[] }> { const binDir = join(root, "bin"); const cphBin = join(binDir, "cph"); @@ -147,6 +175,7 @@ describe("deployment preflight CLI", () => { const runuserBin = join(binDir, "runuser"); const setprivBin = join(binDir, "setpriv"); const envFile = join(root, "platform.env"); + const keyringFile = join(root, "secret-keyring.json"); await Promise.all([ mkdir(binDir, { recursive: true }), mkdir(join(hubDir, "dist"), { recursive: true }), @@ -180,15 +209,17 @@ describe("deployment preflight CLI", () => { `const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`, ), writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"), + writeFile(keyringFile, JSON.stringify({ + version: 1, + activeKeyId: TEST_SECRET_KEY_ID, + keys: { [TEST_SECRET_KEY_ID]: (options.keyringKey ?? TEST_SECRET_KEY).toString("base64") }, + }), { mode: 0o600 }), ]); await writeFile( envFile, [ "NODE_ENV=production", `DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`, - "ANTHROPIC_BASE_URL=https://openrouter.ai/api", - "ANTHROPIC_AUTH_TOKEN=provider-token", - "ANTHROPIC_API_KEY=", "FEISHU_APP_ID=cli_app_id", "FEISHU_APP_SECRET=feishu-app-secret", "FEISHU_BOT_OPEN_ID=ou_bot", @@ -207,6 +238,8 @@ describe("deployment preflight CLI", () => { args: [ "--env-file", envFile, + "--keyring-file", + keyringFile, "--node-bin", process.execPath, "--runuser-bin", @@ -236,6 +269,20 @@ describe("deployment preflight CLI", () => { ], }; } + + async function prismaActorForEnvelope(): Promise { + await prisma.user.create({ data: { id: "preflight-admin", feishuOpenId: "ou_preflight", displayName: "Preflight" } }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "preflight-admin", role: "ADMIN" }, + }); + await new ProviderConnectionService(prisma, testSecretEnvelope, async () => {}).rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "openrouter", + actorUserId: "preflight-admin", + baseUrl: "https://provider.example/api", + authToken: "stored-envelope-secret", + }); + } }); async function executable(path: string, contents: string): Promise { diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 80a3cb7..63a48aa 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -17,9 +17,16 @@ import type { } from "@ai-sdk/provider"; import type { FeishuRuntime } from "../../src/feishu/client.js"; import type { ModelFactory } from "../../src/agent/runner.js"; +import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js"; export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; export const DEFAULT_ORG_ID = "org_test_default"; +export const TEST_SECRET_KEY_ID = "test-active"; +export const TEST_SECRET_KEY = Buffer.alloc(32, "k"); +export const testSecretEnvelope = new LocalSecretEnvelope({ + activeKeyId: TEST_SECRET_KEY_ID, + keys: new Map([[TEST_SECRET_KEY_ID, TEST_SECRET_KEY]]), +}); export const prisma = new PrismaClient({ datasources: { db: { url: TEST_DATABASE_URL } }, @@ -29,6 +36,8 @@ export const prisma = new PrismaClient({ export async function resetDb(): Promise { const tables = [ "FeishuEventReceipt", + "ProviderCredentialVersion", + "OrganizationProviderConnection", "AgentFileChange", "AgentMessage", "AuditEntry", diff --git a/hub/test/integration/local-kek-rotation.test.ts b/hub/test/integration/local-kek-rotation.test.ts new file mode 100644 index 0000000..95e3eed --- /dev/null +++ b/hub/test/integration/local-kek-rotation.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js"; +import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js"; +import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; + +const temporaryDirectories: string[] = []; + +describe("local KEK rotation", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await prisma.$disconnect(); + }); + + it("atomically retains the old KEK, rewraps DEKs, and authenticates every envelope", async () => { + const oldKey = Buffer.alloc(32, "o"); + const oldSecrets = new LocalSecretEnvelope({ + activeKeyId: "local-old", + keys: new Map([["local-old", oldKey]]), + }); + await prisma.user.create({ data: { id: "rotation-admin", feishuOpenId: "ou_rotation", displayName: "Rotation" } }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "rotation-admin", role: "OWNER" }, + }); + await new ProviderConnectionService(prisma, oldSecrets, async () => {}).rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "openrouter", + actorUserId: "rotation-admin", + baseUrl: "https://provider.example/api", + authToken: "credential-stays-encrypted", + }); + const before = await prisma.providerCredentialVersion.findFirstOrThrow({ + include: { connection: true }, + }); + const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1; + const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-")); + temporaryDirectories.push(directory); + const keyringFile = join(directory, "keyring.json"); + await writeFile(keyringFile, JSON.stringify({ + version: 1, + activeKeyId: "local-old", + keys: { "local-old": oldKey.toString("base64") }, + }), { mode: 0o600 }); + const expectedOwner = { + uid: process.getuid?.() ?? 0, + gid: process.getgid?.() ?? 0, + }; + if (expectedOwner.uid !== 0 || expectedOwner.gid !== 0) { + await expect(rotateLocalKek({ + keyringFile, + prisma, + generatedKey: Buffer.alloc(32, "x"), + })).rejects.toThrow("must be owned by 0:0"); + } + + let releaseLock!: () => void; + const releaseLockPromise = new Promise((resolve) => { + releaseLock = resolve; + }); + let lockAcquired!: () => void; + const lockAcquiredPromise = new Promise((resolve) => { + lockAcquired = resolve; + }); + const lockHolder = prisma.$transaction(async (tx) => { + await tx.$queryRaw>` + SELECT pg_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK})::text AS "locked" + `; + lockAcquired(); + await releaseLockPromise; + }, { timeout: 30_000 }); + await lockAcquiredPromise; + try { + await expect(rotateLocalKek({ + keyringFile, + prisma, + generatedKey: Buffer.alloc(32, "x"), + expectedOwner, + })).rejects.toThrow("rotation lock is already held"); + } finally { + releaseLock(); + await lockHolder; + } + + const result = await rotateLocalKek({ + keyringFile, + prisma, + now: new Date("2026-07-10T12:34:56.000Z"), + generatedKey: Buffer.alloc(32, "n"), + expectedOwner, + }); + + expect(result).toMatchObject({ + previousActiveKeyId: "local-old", + activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/), + rewrapped: 1, + verified: 1, + }); + const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } }); + const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1; + expect(after.keyId).toBe(result.activeKeyId); + expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload); + expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek); + const keyringSource = await readFile(keyringFile, "utf8"); + expect(keyringSource).toContain("local-old"); + expect(keyringSource).toContain(result.activeKeyId); + expect(keyringSource).not.toContain("credential-stays-encrypted"); + + const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile)); + await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1); + expect(() => oldSecrets.decryptJson({ + purpose: "provider-connection", + organizationId: before.connection.organizationId, + connectionId: before.connection.id, + secretVersionId: before.id, + }, afterEnvelope)).toThrow("secret envelope key is unavailable"); + await expect(prisma.auditEntry.count({ + where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID }, + })).resolves.toBe(1); + }); +}); diff --git a/hub/test/integration/provider-runtime-settings.test.ts b/hub/test/integration/provider-runtime-settings.test.ts new file mode 100644 index 0000000..359dac2 --- /dev/null +++ b/hub/test/integration/provider-runtime-settings.test.ts @@ -0,0 +1,170 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { ProviderConnectionService } from "../../src/connections/providerConnections.js"; +import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js"; +import type { ProviderUpstreamCredential } from "../../src/connections/providerProxy.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js"; + +const secrets = testSecretEnvelope; + +describe("project-scoped provider runtime settings", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("resolves two projects only through their own Organization connection", async () => { + await seedTestOrganization("org_other", "other"); + await Promise.all([ + prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } }), + prisma.user.create({ data: { id: "user-b", feishuOpenId: "ou_b", displayName: "B" } }), + prisma.project.create({ + data: { + id: "project-a", + organizationId: DEFAULT_ORG_ID, + name: "A", + workspaceDir: "/tmp/project-a", + }, + }), + prisma.project.create({ + data: { + id: "project-b", + organizationId: "org_other", + name: "B", + workspaceDir: "/tmp/project-b", + }, + }), + ]); + await Promise.all([ + prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" }, + }), + prisma.organizationMembership.create({ + data: { organizationId: "org_other", userId: "user-b", role: "OWNER" }, + }), + ]); + const service = new ProviderConnectionService(prisma, secrets, async () => {}); + await service.rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "openrouter", + actorUserId: "user-a", + baseUrl: "https://provider-a.example/api", + authToken: "provider-a-token", + anthropicApiKey: "provider-a-api-key", + }); + await service.rotateByok({ + organizationId: "org_other", + providerId: "openrouter", + actorUserId: "user-b", + baseUrl: "https://provider-b.example/api", + authToken: "provider-b-token", + }); + const leasedCredentials: ProviderUpstreamCredential[] = []; + const settings = new DatabaseRuntimeSettings(prisma, secrets, { + ANTHROPIC_AUTH_TOKEN: "forbidden-global-token", + ANTHROPIC_BASE_URL: "https://forbidden-global.example", + }, async (credential) => { + leasedCredentials.push(credential); + return { + sdkEnv: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", + ANTHROPIC_AUTH_TOKEN: `run-capability-${leasedCredentials.length}`, + ANTHROPIC_API_KEY: "", + }, + sensitiveValues: [`run-capability-${leasedCredentials.length}`], + async close() {}, + }; + }); + + const providerA = await settings.provider("openrouter", { projectId: "project-a" }); + const providerB = await settings.provider("openrouter", { projectId: "project-b" }); + expect(JSON.stringify([providerA, providerB])).not.toContain("provider-a-token"); + expect(JSON.stringify([providerA, providerB])).not.toContain("provider-b-token"); + + const leaseA = await providerA.openAgentLease({ runId: "run-a" }); + const leaseB = await providerB.openAgentLease({ runId: "run-b" }); + expect(leaseA.sdkEnv).toMatchObject({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", + ANTHROPIC_AUTH_TOKEN: "run-capability-1", + ANTHROPIC_API_KEY: "", + }); + expect(leaseB.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe("run-capability-2"); + expect(leasedCredentials).toEqual([ + { + baseUrl: "https://provider-a.example/api", + authToken: "provider-a-token", + anthropicApiKey: "provider-a-api-key", + }, + { + baseUrl: "https://provider-b.example/api", + authToken: "provider-b-token", + anthropicApiKey: "", + }, + ]); + }); + + it("fails closed without project scope or for missing, inactive, and corrupt connections", async () => { + await prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" }, + }); + await prisma.project.create({ + data: { + id: "project-a", + organizationId: DEFAULT_ORG_ID, + name: "A", + workspaceDir: "/tmp/project-a", + }, + }); + const settings = new DatabaseRuntimeSettings(prisma, secrets, { + ANTHROPIC_AUTH_TOKEN: "must-not-be-a-fallback", + }); + + await expect(settings.provider("openrouter")).rejects.toThrow("projectId is required"); + await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow( + "active provider connection not found", + ); + + const service = new ProviderConnectionService(prisma, secrets, async () => {}); + await service.rotateByok({ + organizationId: DEFAULT_ORG_ID, + providerId: "openrouter", + actorUserId: "user-a", + baseUrl: "https://provider-a.example/api", + authToken: "provider-a-token", + }); + await prisma.organization.update({ + where: { id: DEFAULT_ORG_ID }, + data: { status: "SUSPENDED" }, + }); + await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow( + `organization ${DEFAULT_ORG_ID} is SUSPENDED`, + ); + + await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ACTIVE" } }); + const connection = await prisma.organizationProviderConnection.findUniqueOrThrow({ + where: { + organizationId_providerId: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" }, + }, + }); + const version = await prisma.providerCredentialVersion.findUniqueOrThrow({ + where: { id: connection.activeSecretVersionId ?? "missing" }, + }); + const envelope = structuredClone(version.envelope) as { + payload: { ciphertext: string }; + }; + const bytes = Buffer.from(envelope.payload.ciphertext, "base64"); + bytes[0] = (bytes[0] ?? 0) ^ 1; + envelope.payload.ciphertext = bytes.toString("base64"); + await prisma.providerCredentialVersion.update({ + where: { id: version.id }, + data: { envelope }, + }); + const corruptProvider = await settings.provider("openrouter", { projectId: "project-a" }); + await expect(corruptProvider.openAgentLease({ runId: "run-corrupt" })).rejects.toThrow( + "secret envelope authentication failed", + ); + }); +}); diff --git a/hub/test/integration/server-start.test.ts b/hub/test/integration/server-start.test.ts index 0c77cad..54c1d32 100644 --- a/hub/test/integration/server-start.test.ts +++ b/hub/test/integration/server-start.test.ts @@ -1,8 +1,10 @@ import { createServer } from "node:net"; import { once } from "node:events"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { startHub } from "../../src/hub.js"; -import { TEST_DATABASE_URL } from "./helpers.js"; +import { resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID } from "./helpers.js"; const ENV_KEYS = [ "DATABASE_URL", @@ -15,6 +17,7 @@ const ENV_KEYS = [ "HUB_SESSION_SECRET", "HUB_PUBLIC_BASE_URL", "HUB_FEISHU_LISTENER_ENABLED", + "HUB_SECRET_KEYRING_FILE", "HOST", "PORT", ] as const; @@ -30,16 +33,23 @@ describe("Hub startup", () => { }); it("rejects startup before the Feishu listener when the HTTP bind fails", async () => { + await resetDb(); const blocker = createServer(); blocker.listen(0, "127.0.0.1"); await once(blocker, "listening"); const address = blocker.address(); if (address === null || typeof address === "string") throw new Error("expected an INET test listener"); + const keyringDirectory = await mkdtemp(join(tmpdir(), "cph-hub-start-keyring-")); + const keyringFile = join(keyringDirectory, "keyring.json"); + await writeFile(keyringFile, JSON.stringify({ + version: 1, + activeKeyId: TEST_SECRET_KEY_ID, + keys: { [TEST_SECRET_KEY_ID]: TEST_SECRET_KEY.toString("base64") }, + }), { mode: 0o600 }); + Object.assign(process.env, { DATABASE_URL: TEST_DATABASE_URL, - ANTHROPIC_AUTH_TOKEN: "test-provider-token", - ANTHROPIC_API_KEY: "", FEISHU_APP_ID: "test-app", FEISHU_APP_SECRET: "test-secret", FEISHU_BOT_OPEN_ID: "ou_test_bot", @@ -47,15 +57,18 @@ describe("Hub startup", () => { HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes", HUB_PUBLIC_BASE_URL: "https://hub.example.test", HUB_FEISHU_LISTENER_ENABLED: "false", + HUB_SECRET_KEYRING_FILE: keyringFile, HOST: "127.0.0.1", PORT: String(address.port), }); try { + const { startHub } = await import("../../src/hub.js"); await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" }); } finally { blocker.close(); await once(blocker, "close"); + await rm(keyringDirectory, { recursive: true, force: true }); } }); }); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 430ab60..9a506ee 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -30,13 +30,16 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { async provider(providerId) { return { id: providerId, - baseUrl: "https://openrouter.ai/api", - authToken: "test-token", - anthropicApiKey: "", - sdkEnv: { - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "test-token", - ANTHROPIC_API_KEY: "", + async openAgentLease() { + return { + sdkEnv: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", + ANTHROPIC_AUTH_TOKEN: "test-run-capability", + ANTHROPIC_API_KEY: "", + }, + sensitiveValues: ["test-run-capability"], + async close() {}, + }; }, }; }, @@ -153,9 +156,9 @@ describe("trigger full lifecycle (integration)", () => { }); expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true); expect(runAgentCalls).toHaveLength(1); - expect(runAgentCalls[0]?.providerEnv).toMatchObject({ - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "test-token", + expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", + ANTHROPIC_AUTH_TOKEN: "test-run-capability", ANTHROPIC_API_KEY: "", }); expect(runAgentCalls[0]?.maxTurns).toBe(7); diff --git a/hub/test/unit/agent-security.test.ts b/hub/test/unit/agent-security.test.ts index 9c49d7f..972069b 100644 --- a/hub/test/unit/agent-security.test.ts +++ b/hub/test/unit/agent-security.test.ts @@ -11,14 +11,14 @@ describe("agent subprocess security policy", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); - it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => { + it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const policy = await createAgentSecurityPolicy({ workspaceRoot, workspaceDir: workspace, - providerEnv: { - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "provider-secret", + providerProxyEnv: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, hostEnv: { @@ -37,8 +37,8 @@ describe("agent subprocess security policy", () => { PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", CPH_BIN: "/usr/local/bin/cph", - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "provider-secret", + ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }); expect(policy.env).not.toHaveProperty("DATABASE_URL"); @@ -73,8 +73,8 @@ describe("agent subprocess security policy", () => { await expect(createAgentSecurityPolicy({ workspaceRoot, workspaceDir: workspace, - providerEnv: { - ANTHROPIC_AUTH_TOKEN: "provider-secret", + providerProxyEnv: { + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", DATABASE_URL: "must-not-cross-boundary", }, hostEnv: { PATH: "/usr/bin:/bin" }, @@ -109,7 +109,7 @@ describe("agent subprocess security policy", () => { await expect(createAgentSecurityPolicy({ workspaceRoot, workspaceDir: linked, - providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" }, + providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("project workspace contains a symlink"); }); @@ -124,7 +124,7 @@ describe("agent subprocess security policy", () => { await expect(createAgentSecurityPolicy({ workspaceRoot, workspaceDir: linked, - providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" }, + providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("symlink"); }); diff --git a/hub/test/unit/deployment-preflight.test.ts b/hub/test/unit/deployment-preflight.test.ts index 4a0aef6..d477220 100644 --- a/hub/test/unit/deployment-preflight.test.ts +++ b/hub/test/unit/deployment-preflight.test.ts @@ -8,9 +8,6 @@ import { const VALID_ENV = { NODE_ENV: "production", DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub", - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "provider-token", - ANTHROPIC_API_KEY: "", FEISHU_APP_ID: "cli_app_id", FEISHU_APP_SECRET: "feishu-app-secret", FEISHU_BOT_OPEN_ID: "ou_bot", @@ -82,7 +79,6 @@ describe("validateDeploymentPreflight", () => { input({ env: { ...VALID_ENV, - ANTHROPIC_AUTH_TOKEN: "", FEISHU_APP_SECRET: "", HUB_PUBLIC_BASE_URL: "http://hub.example.com", HUB_SESSION_SECRET: "short", @@ -93,7 +89,6 @@ describe("validateDeploymentPreflight", () => { expect(error).toBeInstanceOf(DeploymentPreflightError); expect((error as Error).message).toMatchInlineSnapshot(` "deployment preflight failed: - - ANTHROPIC_AUTH_TOKEN is required - FEISHU_APP_SECRET is required - HUB_PUBLIC_BASE_URL must use https - HUB_SESSION_SECRET must contain at least 32 characters" @@ -107,11 +102,11 @@ describe("validateDeploymentPreflight", () => { ); }); - it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => { + it("rejects legacy process-global provider settings instead of silently ignoring them", () => { expect(() => validateDeploymentPreflight( - input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }), + input({ env: { ...VALID_ENV, ANTHROPIC_AUTH_TOKEN: "global-secret" } }), ), - ).toThrow("ANTHROPIC_API_KEY must be present and empty"); + ).toThrow("ANTHROPIC_AUTH_TOKEN must not be set"); }); }); diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index 9c2398e..e195d13 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -212,10 +212,13 @@ function mockSettings(): RuntimeSettings { async provider(providerId) { return { id: providerId, - baseUrl: "https://example.invalid", - authToken: "test-token", - anthropicApiKey: "", - sdkEnv: {}, + async openAgentLease() { + return { + sdkEnv: {}, + sensitiveValues: [], + async close() {}, + }; + }, }; }, async modelRegistry() { diff --git a/hub/test/unit/provider-proxy.test.ts b/hub/test/unit/provider-proxy.test.ts new file mode 100644 index 0000000..30875b3 --- /dev/null +++ b/hub/test/unit/provider-proxy.test.ts @@ -0,0 +1,156 @@ +import { createServer } from "node:http"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { openProviderProxyLease, type AgentProviderLease } from "../../src/connections/providerProxy.js"; + +const leases: AgentProviderLease[] = []; +const upstreamServers: ReturnType[] = []; + +afterEach(async () => { + await Promise.all(leases.splice(0).map((lease) => lease.close())); + await Promise.all(upstreamServers.splice(0).map(async (server) => { + server.closeAllConnections(); + if (server.listening) { + server.close(); + await once(server, "close"); + } + })); +}); + +describe("run-scoped provider proxy", () => { + it("keeps the Organization provider credential out of the Agent environment", async () => { + const observed: Array<{ authorization: string | undefined; apiKey: string | undefined; body: string }> = []; + const upstream = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + observed.push({ + authorization: request.headers.authorization, + apiKey: header(request.headers["x-api-key"]), + body: Buffer.concat(chunks).toString("utf8"), + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + }); + upstreamServers.push(upstream); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const address = upstream.address(); + if (address === null || typeof address === "string") throw new Error("expected upstream TCP address"); + + const lease = await openProviderProxyLease({ + baseUrl: `http://127.0.0.1:${address.port}/api`, + authToken: "customer-provider-auth-token", + anthropicApiKey: "customer-provider-api-key", + }); + leases.push(lease); + + const serializedAgentEnv = JSON.stringify(lease.sdkEnv); + expect(serializedAgentEnv).not.toContain("customer-provider-auth-token"); + expect(serializedAgentEnv).not.toContain("customer-provider-api-key"); + expect(serializedAgentEnv).not.toContain(String(address.port)); + expect(lease.sdkEnv).toMatchObject({ + ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/), + ANTHROPIC_AUTH_TOKEN: expect.any(String), + ANTHROPIC_API_KEY: "", + }); + + const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { + method: "POST", + headers: { + authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`, + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ model: "test" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(observed).toEqual([{ + authorization: "Bearer customer-provider-auth-token", + apiKey: "customer-provider-api-key", + body: JSON.stringify({ model: "test" }), + }]); + }); + + it("rejects requests without the run capability before contacting upstream", async () => { + let upstreamRequests = 0; + const diagnostics: unknown[] = []; + const upstream = createServer((_request, response) => { + upstreamRequests += 1; + response.end("unexpected"); + }); + upstreamServers.push(upstream); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const address = upstream.address(); + if (address === null || typeof address === "string") throw new Error("expected upstream TCP address"); + const lease = await openProviderProxyLease( + { + baseUrl: `http://127.0.0.1:${address.port}`, + authToken: "customer-secret", + anthropicApiKey: "", + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + leases.push(lease); + + const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { + method: "POST", + body: "{}", + }); + + expect(response.status).toBe(401); + expect(upstreamRequests).toBe(0); + expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]); + }); + + it("does not forward provider credentials across an upstream redirect", async () => { + let redirectedRequests = 0; + const diagnostics: unknown[] = []; + const redirectTarget = createServer((_request, response) => { + redirectedRequests += 1; + response.end("leaked"); + }); + upstreamServers.push(redirectTarget); + redirectTarget.listen(0, "127.0.0.1"); + await once(redirectTarget, "listening"); + const targetAddress = redirectTarget.address(); + if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected redirect target address"); + + const upstream = createServer((_request, response) => { + response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` }); + response.end(); + }); + upstreamServers.push(upstream); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamAddress = upstream.address(); + if (upstreamAddress === null || typeof upstreamAddress === "string") throw new Error("expected upstream address"); + const lease = await openProviderProxyLease( + { + baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, + authToken: "customer-secret", + anthropicApiKey: "", + }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, + ); + leases.push(lease); + + const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { + headers: { authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}` }, + redirect: "manual", + }); + + expect(response.status).toBe(502); + await expect(response.text()).resolves.toBe("provider redirect refused"); + expect(redirectedRequests).toBe(0); + expect(diagnostics).toEqual([{ code: "provider_proxy_redirect_refused", category: "redirect" }]); + }); +}); + +function header(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} diff --git a/hub/test/unit/provider-readiness.test.ts b/hub/test/unit/provider-readiness.test.ts new file mode 100644 index 0000000..1041028 --- /dev/null +++ b/hub/test/unit/provider-readiness.test.ts @@ -0,0 +1,128 @@ +import { createServer } from "node:http"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { ProviderReadinessError, probeOpenRouterCredential } from "../../src/connections/providerReadiness.js"; + +const servers: ReturnType[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(async (server) => { + server.closeAllConnections(); + if (server.listening) { + server.close(); + await once(server, "close"); + } + })); +}); + +describe("provider credential readiness", () => { + it("authenticates the OpenRouter key endpoint before activation", async () => { + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const server = createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"data":{"label":"test"}}'); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("expected TCP address"); + + await expect(probeOpenRouterCredential({ + providerId: "openrouter", + baseUrl: `http://127.0.0.1:${address.port}/api`, + authToken: "readiness-secret", + anthropicApiKey: "", + })).resolves.toBeUndefined(); + expect(requests).toEqual([{ + url: "/api/v1/key", + authorization: "Bearer readiness-secret", + }]); + }); + + it("fails closed on rejected credentials without exposing them in the error", async () => { + const server = createServer((_request, response) => { + response.writeHead(401, { "content-type": "application/json" }); + response.end('{"error":"token rejected"}'); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("expected TCP address"); + + let message = ""; + try { + await probeOpenRouterCredential({ + providerId: "openrouter", + baseUrl: `http://127.0.0.1:${address.port}`, + authToken: "never-return-readiness-secret", + anthropicApiKey: "", + }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toBe("provider credential readiness check failed: status 401"); + expect(message).not.toContain("never-return-readiness-secret"); + }); + + it("does not follow redirects with the provider credential", async () => { + let redirected = 0; + const target = createServer((_request, response) => { + redirected += 1; + response.end("leaked"); + }); + servers.push(target); + target.listen(0, "127.0.0.1"); + await once(target, "listening"); + const targetAddress = target.address(); + if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected target address"); + const provider = createServer((_request, response) => { + response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` }); + response.end(); + }); + servers.push(provider); + provider.listen(0, "127.0.0.1"); + await once(provider, "listening"); + const providerAddress = provider.address(); + if (providerAddress === null || typeof providerAddress === "string") throw new Error("expected provider address"); + + await expect(probeOpenRouterCredential({ + providerId: "openrouter", + baseUrl: `http://127.0.0.1:${providerAddress.port}`, + authToken: "redirect-secret", + anthropicApiKey: "", + })).rejects.toThrow("status 307"); + expect(redirected).toBe(0); + }); + + it("preserves a redacted network category for observability", async () => { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("expected provider address"); + server.close(); + await once(server, "close"); + + let failure: unknown; + try { + await probeOpenRouterCredential({ + providerId: "openrouter", + baseUrl: `http://127.0.0.1:${address.port}`, + authToken: "network-secret", + anthropicApiKey: "", + }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ProviderReadinessError); + expect(failure).toMatchObject({ + code: "provider_readiness_unreachable", + category: "connection", + message: "provider credential readiness check could not reach provider", + }); + expect(JSON.stringify(failure)).not.toContain("network-secret"); + }); +}); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 6c78426..059ae89 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -194,7 +194,7 @@ describe("runAgent", () => { expect(result.costUsd).toBe(0.0042); }); - it("passes only provider and safe runtime env without mutating process env", async () => { + it("passes only the loopback provider proxy capability and safe runtime env without mutating process env", async () => { process.env["DATABASE_URL"] = "postgresql://platform-secret"; process.env["FEISHU_APP_SECRET"] = "feishu-secret"; process.env["HUB_SESSION_SECRET"] = "session-secret"; @@ -205,9 +205,9 @@ describe("runAgent", () => { model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, - providerEnv: { - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "test-token", + providerProxyEnv: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, runId: "run-1", @@ -219,8 +219,8 @@ describe("runAgent", () => { expect(call).toMatchObject({ options: { env: { - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", + ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, sandbox: { diff --git a/hub/test/unit/runtime-settings.test.ts b/hub/test/unit/runtime-settings.test.ts index 0fa9438..f074b41 100644 --- a/hub/test/unit/runtime-settings.test.ts +++ b/hub/test/unit/runtime-settings.test.ts @@ -15,44 +15,16 @@ describe("runtime settings", () => { expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7"); }); - it("returns provider SDK env without requiring global process env mutation", async () => { + it("refuses process-global provider credentials even when they are present", async () => { const settings = createEnvRuntimeSettings({ ANTHROPIC_BASE_URL: "https://example.test/api", ANTHROPIC_AUTH_TOKEN: "openrouter-token", ANTHROPIC_API_KEY: "explicit-api-key", }); - await expect(settings.provider("openrouter")).resolves.toMatchObject({ - id: "openrouter", - baseUrl: "https://example.test/api", - authToken: "openrouter-token", - anthropicApiKey: "explicit-api-key", - sdkEnv: { - ANTHROPIC_BASE_URL: "https://example.test/api", - ANTHROPIC_AUTH_TOKEN: "openrouter-token", - ANTHROPIC_API_KEY: "explicit-api-key", - }, - }); - }); - - it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => { - const settings = createEnvRuntimeSettings({ - ANTHROPIC_AUTH_TOKEN: "openrouter-token", - }); - - await expect(settings.provider("openrouter")).resolves.toMatchObject({ - baseUrl: "https://openrouter.ai/api", - anthropicApiKey: "", - sdkEnv: { - ANTHROPIC_API_KEY: "", - }, - }); - }); - - it("requires an auth token for the OpenRouter provider", async () => { - const settings = createEnvRuntimeSettings({}); - - await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN"); + await expect(settings.provider("openrouter")).rejects.toThrow( + "process-global provider credentials are disabled", + ); }); it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => { diff --git a/hub/test/unit/secret-envelope.test.ts b/hub/test/unit/secret-envelope.test.ts new file mode 100644 index 0000000..58f3d33 --- /dev/null +++ b/hub/test/unit/secret-envelope.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + LocalSecretEnvelope, + loadLocalSecretKeyring, + type SecretBinding, +} from "../../src/security/secretEnvelope.js"; + +const createdDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(createdDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("local secret envelope", () => { + it("encrypts with a per-secret DEK and authenticates the complete binding", async () => { + const keyring = await keyringFile({ active: key("a"), previous: key("b") }, "active"); + const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: keyring, + })); + const binding = secretBinding(); + const plaintext = { + schemaVersion: 1, + baseUrl: "https://provider.example/api", + authToken: "org-a-secret-token", + anthropicApiKey: "org-a-api-key", + }; + + const envelope = secrets.encryptJson(binding, plaintext); + + expect(envelope).toMatchObject({ + version: 1, + algorithm: "AES-256-GCM", + keyId: "active", + }); + expect(JSON.stringify(envelope)).not.toContain(plaintext.authToken); + expect(envelope.wrappedDek.ciphertext).not.toBe(envelope.payload.ciphertext); + expect(secrets.decryptJson(binding, envelope)).toEqual(plaintext); + }); + + it.each([ + ["organization", { organizationId: "org-b" }], + ["connection", { connectionId: "connection-b" }], + ["version", { secretVersionId: "secret-version-b" }], + ["purpose", { purpose: "feishu-application" }], + ])("rejects ciphertext substituted across %s scope", async (_label, changed) => { + const keyring = await keyringFile({ active: key("a") }, "active"); + const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: keyring, + })); + const binding = secretBinding(); + const envelope = secrets.encryptJson(binding, { token: "never-log-this-token" }); + + expect(() => secrets.decryptJson({ ...binding, ...changed }, envelope)).toThrow( + "secret envelope authentication failed", + ); + }); + + it("fails closed for a missing KEK or corrupted ciphertext without including plaintext", async () => { + const encryptingKeyring = await keyringFile({ encrypting: key("a") }, "encrypting"); + const decryptingKeyring = await keyringFile({ other: key("b") }, "other"); + const encrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: encryptingKeyring, + })); + const decrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: decryptingKeyring, + })); + const binding = secretBinding(); + const envelope = encrypting.encryptJson(binding, { token: "never-log-this-token" }); + + expect(() => decrypting.decryptJson(binding, envelope)).toThrow("secret envelope key is unavailable"); + + const corrupted = structuredClone(envelope); + corrupted.payload.ciphertext = flipFirstByte(corrupted.payload.ciphertext); + let message = ""; + try { + encrypting.decryptJson(binding, corrupted); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toBe("secret envelope authentication failed"); + expect(message).not.toContain("never-log-this-token"); + }); + + it("rewraps only the DEK when the active KEK rotates", async () => { + const firstFile = await keyringFile({ old: key("a") }, "old"); + const rotatedFile = await keyringFile({ old: key("a"), current: key("c") }, "current"); + const first = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: firstFile, + })); + const rotated = new LocalSecretEnvelope(await loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: rotatedFile, + })); + const binding = secretBinding(); + const envelope = first.encryptJson(binding, { token: "rotatable" }); + + const rewrapped = rotated.rewrap(binding, envelope); + + expect(rewrapped.keyId).toBe("current"); + expect(rewrapped.payload).toEqual(envelope.payload); + expect(rewrapped.wrappedDek).not.toEqual(envelope.wrappedDek); + expect(rotated.decryptJson(binding, rewrapped)).toEqual({ token: "rotatable" }); + }); + + it("loads production keys only from the fixed systemd credential", async () => { + const credentialsDirectory = await temporaryDirectory(); + await writeKeyring(path.join(credentialsDirectory, "cph-secret-keyring"), { active: key("a") }, "active"); + + await expect(loadLocalSecretKeyring({ + NODE_ENV: "production", + CREDENTIALS_DIRECTORY: credentialsDirectory, + })).resolves.toMatchObject({ activeKeyId: "active" }); + + await expect(loadLocalSecretKeyring({ + NODE_ENV: "production", + CREDENTIALS_DIRECTORY: credentialsDirectory, + HUB_SECRET_KEYRING_FILE: "/tmp/untrusted-keyring.json", + })).rejects.toThrow("production secret keyring must use the systemd credential"); + }); + + it("rejects absent, malformed, or incomplete keyrings", async () => { + await expect(loadLocalSecretKeyring({ NODE_ENV: "test" })).rejects.toThrow( + "missing required secret keyring setting", + ); + + const directory = await temporaryDirectory(); + const malformed = path.join(directory, "malformed.json"); + await writeFile(malformed, "not json", "utf8"); + await expect(loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: malformed, + })).rejects.toThrow("invalid local secret keyring"); + + const missingActive = await keyringFile({ old: key("a") }, "current"); + await expect(loadLocalSecretKeyring({ + NODE_ENV: "test", + HUB_SECRET_KEYRING_FILE: missingActive, + })).rejects.toThrow("active key is unavailable"); + }); +}); + +function secretBinding(): SecretBinding { + return { + purpose: "provider-connection", + organizationId: "org-a", + connectionId: "connection-a", + secretVersionId: "secret-version-a", + }; +} + +function key(character: string): string { + return Buffer.alloc(32, character).toString("base64"); +} + +async function keyringFile(keys: Readonly>, activeKeyId: string): Promise { + const directory = await temporaryDirectory(); + const file = path.join(directory, "keyring.json"); + await writeKeyring(file, keys, activeKeyId); + return file; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-")); + createdDirectories.push(directory); + return directory; +} + +async function writeKeyring( + file: string, + keys: Readonly>, + activeKeyId: string, +): Promise { + await writeFile(file, JSON.stringify({ version: 1, activeKeyId, keys }), { encoding: "utf8", mode: 0o600 }); +} + +function flipFirstByte(value: string): string { + const bytes = Buffer.from(value, "base64"); + bytes[0] = (bytes[0] ?? 0) ^ 1; + return bytes.toString("base64"); +} diff --git a/spec/Spec/System.lean b/spec/Spec/System.lean index b725b7a..62341fd 100644 --- a/spec/Spec/System.lean +++ b/spec/Spec/System.lean @@ -18,7 +18,8 @@ import Spec.System.Audit **语义分歧点**: - `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。 -- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。 +- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org; + connection secret 使用本地主密钥信封与 fail-closed resolver(ADR-0024)。 - `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界 (ADR-0021)。 - `Capacity` —— platform ceiling 与 org policy 的分层限制、持久 admission request 状态和 @@ -36,5 +37,5 @@ import Spec.System.Audit - `Audit` —— customer Project/Run 审计有意从简(内容多为 plumbing,OPEN);Platform Audit 由 `PlatformAdministration` 独立钉死。 -标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0023。 +标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0024。 -/ diff --git a/spec/Spec/System/Organization.lean b/spec/Spec/System/Organization.lean index ba25c06..051e04f 100644 --- a/spec/Spec/System/Organization.lean +++ b/spec/Spec/System/Organization.lean @@ -1,7 +1,7 @@ import Spec.Prelude /-! -# Organization —— SaaS tenant root (ADR-0020) +# Organization —— SaaS tenant root (ADR-0020, ADR-0024) ADR-0020:Hub 长期按 SaaS 形态演进,`Organization` 是客户侧 tenant root。 `Project` 与 `Team` 都必须归属且只归属一个 organization;team 对 project 的授权 @@ -11,7 +11,8 @@ ADR-0020:Hub 长期按 SaaS 形态演进,`Organization` 是客户侧 tenant root 本模块只钉死会造成实现分歧的不变量:tenant root 存在、project/team 单归属、team grant 不得跨 org,以及 model provider connection 的凭据归属模式。用户身份、外部目录 provider 细节仍为 `OPEN`;平台控制面身份/角色/审计已由 ADR-0023 与 -`Spec.System.PlatformAdministration` 独立决定。 +`Spec.System.PlatformAdministration` 独立决定。连接凭据的本地主密钥信封、不可变版本、 +writer authority 与 fail-closed resolver 由 ADR-0024 固定。 -/ namespace Spec.System @@ -57,13 +58,50 @@ inductive ProviderCredentialMode where | byok | platformManaged -/-- Organization 的 model provider connection(`PINNED`, ADR-0021):connection 必须 -归属一个且仅一个 organization,并明确采用哪一种凭据模式。key/base URL 的加密表示、 -轮换与 resolver 机制由后续 secret-control-plane 决策规定。 -/ +/-- Organization 的 model provider connection(`PINNED`, ADR-0021, ADR-0024):connection +必须归属一个且仅一个 organization,并明确采用哪一种凭据模式。凭据存为由本地版本化 +master-key keyring 包裹的不可变信封版本;业务代码只能通过显式 organization/project +作用域的 fail-closed resolver 获得短生命周期明文,不得回退到 process-global key;Agent +执行环境只获得 run-scoped 本地 proxy capability,不得获得 org provider 明文。 -/ structure OrganizationProviderConnection where /-- connection 所属 organization(`PINNED`, ADR-0021)。 -/ organization : I.OrganizationId /-- connection 的凭据归属模式(`PINNED`, ADR-0021)。 -/ mode : ProviderCredentialMode +/-- Organization connection 的运行态(`PINNED`, ADR-0024):只有 `active` connection +可被 resolver 使用;`draft` 与 `disabled` 都必须 fail closed。 -/ +inductive OrganizationConnectionStatus where + | draft + | active + | disabled + +/-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须 +同时绑定 organization、connection、secret version 与 purpose,因此密文不能跨行、跨 org、 +跨 connection 或跨用途替换。各标识符的数据库表示属于 plumbing,这里保持 opaque。 -/ +structure OrganizationSecretBinding + (OrganizationId ConnectionId SecretVersionId Purpose : Type) where + /-- secret 所属 organization(`PINNED`, ADR-0024)。 -/ + organization : OrganizationId + /-- secret 所属稳定 connection(`PINNED`, ADR-0024)。 -/ + connection : ConnectionId + /-- 不可变 secret version(`PINNED`, ADR-0024)。 -/ + secretVersion : SecretVersionId + /-- payload 的连接类型/用途(`PINNED`, ADR-0024)。 -/ + purpose : Purpose + +/-- 生产 secret resolver 的使用条件(`PINNED`, ADR-0024):connection 与 active secret +必须属于请求解析出的同一 active organization,connection 必须 active,且信封必须能用其 +记录的本地 KEK 完整认证并解密。缺失/错误 key、损坏密文和任何 scope 不一致都返回失败; +不得尝试全局环境变量凭据;Agent child 也不得接收该明文。`authenticatedEnvelope` 是 +密码学实现提供的判定。 -/ +def OrganizationSecretResolvable + {OrganizationId ConnectionId SecretVersionId Purpose : Type} + [BEq OrganizationId] + (requestedOrganization : OrganizationId) + (binding : OrganizationSecretBinding OrganizationId ConnectionId SecretVersionId Purpose) + (organizationActive connectionActive authenticatedEnvelope : Bool) : Bool := + organizationActive && connectionActive && + (binding.organization == requestedOrganization) && authenticatedEnvelope + end Spec.System