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