forked from EduCraft/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
+12
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Type: task
|
||||
Status: open
|
||||
Blocked by: 19
|
||||
Blocked by: none
|
||||
|
||||
## Question
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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.
|
||||
+7
-6
@@ -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"
|
||||
|
||||
+43
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown>;
|
||||
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<T extends { readonly created: boolean }>(result: T): Omit<T, "created"> {
|
||||
const { created: _created, ...metadata } = result;
|
||||
return metadata;
|
||||
}
|
||||
@@ -56,7 +56,8 @@ export interface RunRequest {
|
||||
readonly model: string | undefined;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt: string | undefined;
|
||||
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||
/** Run-scoped loopback proxy capability; never an Organization provider credential. */
|
||||
readonly providerProxyEnv?: Record<string, string | undefined> | 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<RunResult> {
|
||||
const security = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
providerEnv: req.providerEnv,
|
||||
providerProxyEnv: req.providerProxyEnv,
|
||||
});
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
|
||||
@@ -29,7 +29,8 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
|
||||
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | 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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ProviderConnectionWriteResult> {
|
||||
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<readonly ProviderConnectionMetadata[]> {
|
||||
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<unknown>({
|
||||
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<number> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function lockConnection(tx: Prisma.TransactionClient, connectionId: string): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationProviderConnection" WHERE "id" = ${connectionId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
async function requireByokActor(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: Pick<RotateByokProviderInput, "organizationId" | "providerId" | "actorUserId">,
|
||||
): Promise<void> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
readonly sensitiveValues: readonly string[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
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<AgentProviderLease> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<Buffer> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateKeyringFile(path: string): Promise<LocalSecretEnvelope> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async function validatePostgresQuery(
|
||||
databaseUrl: string,
|
||||
secretEnvelope: LocalSecretEnvelope,
|
||||
): Promise<void> {
|
||||
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<Array<{ relation: string | null }>>`
|
||||
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<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
||||
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<void> {
|
||||
{ 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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
+66
-58
@@ -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) {
|
||||
|
||||
+24
-3
@@ -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<void> {
|
||||
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<void> {
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
projectWorkspaceRoot,
|
||||
secretEnvelope,
|
||||
});
|
||||
|
||||
const address = await app.listen(bind);
|
||||
|
||||
@@ -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<RotateLocalKekResult> {
|
||||
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<Array<{ readonly locked: boolean }>>`
|
||||
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<RotateLocalKekResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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")}`;
|
||||
}
|
||||
@@ -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<string, Buffer>;
|
||||
}
|
||||
|
||||
export type SecretKeyringEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
/**
|
||||
* 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<LocalSecretKeyring> {
|
||||
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<LocalSecretKeyring> {
|
||||
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<T = unknown>(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<string, Buffer>();
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim() !== "";
|
||||
}
|
||||
+4
-2
@@ -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";
|
||||
|
||||
+138
-32
@@ -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<Record<string, string | undefined>>;
|
||||
|
||||
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<string, string | undefined>;
|
||||
openAgentLease(input: { readonly runId: string }): Promise<AgentProviderLease>;
|
||||
}
|
||||
|
||||
export interface ProviderLeaseContext {
|
||||
readonly providerId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export type ProviderLeaseFactory = (
|
||||
credential: ProviderUpstreamCredential,
|
||||
context: ProviderLeaseContext,
|
||||
) => Promise<AgentProviderLease>;
|
||||
|
||||
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<ProviderRuntimeSettings> {
|
||||
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<ModelRegistry> {
|
||||
@@ -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<ProviderRuntimeSettings> {
|
||||
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<ModelRegistry> {
|
||||
return this.envSettings.modelRegistry(scope);
|
||||
}
|
||||
|
||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||
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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string> {
|
||||
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<Array<{
|
||||
envelope: unknown;
|
||||
activeSecretVersionId: string;
|
||||
}>>`
|
||||
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<Array<{ version: number; retiredAt: Date | null }>>`
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
const tables = [
|
||||
"FeishuEventReceipt",
|
||||
"ProviderCredentialVersion",
|
||||
"OrganizationProviderConnection",
|
||||
"AgentFileChange",
|
||||
"AgentMessage",
|
||||
"AuditEntry",
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
let lockAcquired!: () => void;
|
||||
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
||||
lockAcquired = resolve;
|
||||
});
|
||||
const lockHolder = prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw<Array<{ readonly locked: string }>>`
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<typeof createServer>[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<typeof createServer>[] = [];
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Record<string, string>>, activeKeyId: string): Promise<string> {
|
||||
const directory = await temporaryDirectory();
|
||||
const file = path.join(directory, "keyring.json");
|
||||
await writeKeyring(file, keys, activeKeyId);
|
||||
return file;
|
||||
}
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-"));
|
||||
createdDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function writeKeyring(
|
||||
file: string,
|
||||
keys: Readonly<Record<string, string>>,
|
||||
activeKeyId: string,
|
||||
): Promise<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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。
|
||||
-/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user