feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
@@ -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.
@@ -2,7 +2,7 @@
Type: task
Status: open
Blocked by: 19
Blocked by: none
## Question
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
+37 -5
View File
@@ -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
View File
@@ -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");
+65
View File
@@ -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])
}
+6
View File
@@ -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 };
}
+8
View File
@@ -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 }
: {}),
});
}
+13
View File
@@ -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;
}
+3 -2
View File
@@ -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"]>;
+3 -2
View File
@@ -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}`);
}
+39
View File
@@ -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;
}
+423
View File
@@ -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",
);
}
}
+225
View File
@@ -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";
}
}
+65
View File
@@ -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,
);
}
}
+61 -3
View File
@@ -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"),
+4 -20
View File
@@ -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);
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+204
View File
@@ -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")}`;
}
+268
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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 } : {}),
});
+2 -1
View File
@@ -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> {
+9
View File
@@ -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",
);
});
});
+17 -4
View File
@@ -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 });
}
});
});
+13 -10
View File
@@ -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);
+10 -10
View File
@@ -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");
});
+3 -8
View File
@@ -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");
});
});
+7 -4
View File
@@ -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() {
+156
View File
@@ -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;
}
+128
View File
@@ -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");
});
});
+6 -6
View File
@@ -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: {
+4 -32
View File
@@ -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 () => {
+188
View File
@@ -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");
}
+3 -2
View File
@@ -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
-/
+43 -5
View File
@@ -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