@nylorun/runtime 0.23.0-beta → 0.24.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/README.md +79 -28
- package/dist/adapters/execution/restate.js +1 -1
- package/dist/adapters/replication/pgoutput.d.ts +2 -0
- package/dist/adapters/replication/pgoutput.js +6 -3
- package/dist/adapters/sandbox/local.d.ts +9 -1
- package/dist/adapters/sandbox/local.js +16 -4
- package/dist/adapters/sandbox/virtual.js +4 -1
- package/dist/api/a2a/endpoint.js +5 -1
- package/dist/api/a2a/routes.d.ts +2 -0
- package/dist/api/a2a/routes.js +2 -2
- package/dist/api/a2a/tasks.js +2 -1
- package/dist/api/ag-ui/endpoint.js +4 -2
- package/dist/api/ag-ui/history.js +4 -1
- package/dist/api/ag-ui/routes.d.ts +4 -1
- package/dist/api/ag-ui/routes.js +11 -10
- package/dist/api/ag-ui/translate.js +3 -2
- package/dist/api/components.d.ts +4953 -1204
- package/dist/api/components.js +25 -16
- package/dist/api/gate/keys.js +7 -1
- package/dist/api/gate/routes.d.ts +6 -0
- package/dist/api/gate/routes.js +39 -3
- package/dist/api/http/app.js +4 -4
- package/dist/api/http/define.d.ts +6 -0
- package/dist/api/http/define.js +13 -1
- package/dist/api/http/respond.js +1 -1
- package/dist/api/http/routes/artifacts.js +35 -8
- package/dist/api/http/routes/coverage.d.ts +9 -0
- package/dist/api/http/routes/coverage.js +34 -0
- package/dist/api/http/routes/environments.d.ts +11 -0
- package/dist/api/http/routes/environments.js +283 -0
- package/dist/api/http/routes/mcp.d.ts +1 -1
- package/dist/api/http/routes/mcp.js +2 -2
- package/dist/api/http/routes/me.js +1 -1
- package/dist/api/http/routes/reads.d.ts +45 -9
- package/dist/api/http/routes/reads.js +54 -5
- package/dist/api/http/routes/sessions.js +11 -6
- package/dist/api/http/routes/tenant.js +32 -10
- package/dist/api/openapi.js +31 -26
- package/dist/artifacts/disk.d.ts +59 -0
- package/dist/artifacts/disk.js +563 -0
- package/dist/artifacts/export.d.ts +1 -1
- package/dist/artifacts/export.js +2 -3
- package/dist/artifacts/folders.d.ts +6 -3
- package/dist/artifacts/folders.js +17 -12
- package/dist/artifacts/service.d.ts +2 -1
- package/dist/artifacts/service.js +6 -1
- package/dist/artifacts/workspace.d.ts +1 -1
- package/dist/artifacts/workspace.js +1 -1
- package/dist/core/flow-host.d.ts +2 -3
- package/dist/core/flow-host.js +3 -0
- package/dist/core/provider.d.ts +3 -2
- package/dist/core/provider.js +5 -37
- package/dist/core/sandbox-routes.d.ts +2 -21
- package/dist/core/sandbox-routes.js +10 -43
- package/dist/execution/types.d.ts +1 -1
- package/dist/gates/contract.d.ts +3 -24
- package/dist/gates/contract.js +1 -36
- package/dist/gates/egress-credentials.d.ts +9 -0
- package/dist/gates/egress-credentials.js +11 -4
- package/dist/gates/egress.d.ts +28 -1
- package/dist/gates/egress.js +115 -10
- package/dist/gates/fixture-model.d.ts +28 -0
- package/dist/gates/fixture-model.js +92 -0
- package/dist/gates/handler.js +4 -4
- package/dist/gates/http-tool.d.ts +6 -2
- package/dist/gates/http-tool.js +3 -5
- package/dist/gates/in-process.d.ts +5 -0
- package/dist/gates/in-process.js +12 -1
- package/dist/gates/journal.d.ts +30 -0
- package/dist/gates/journal.js +52 -0
- package/dist/gates/mcp-handler.d.ts +4 -0
- package/dist/gates/mcp-handler.js +5 -1
- package/dist/gates/meter.d.ts +10 -2
- package/dist/gates/meter.js +20 -5
- package/dist/gates/policy.d.ts +49 -0
- package/dist/gates/policy.js +134 -0
- package/dist/gates/tenant-vaults.d.ts +7 -7
- package/dist/gates/tenant-vaults.js +5 -5
- package/dist/gates/tool-calls.d.ts +10 -5
- package/dist/gates/tool-calls.js +28 -10
- package/dist/gates/tool-client.d.ts +8 -0
- package/dist/gates/tool-client.js +27 -11
- package/dist/gates/tool-contract.d.ts +10 -68
- package/dist/gates/tool-contract.js +1 -85
- package/dist/gates/tool-gate.d.ts +1 -1
- package/dist/harness/calls.d.ts +12 -5
- package/dist/harness/calls.js +21 -12
- package/dist/harness/executors.js +12 -9
- package/dist/harness/main.js +18 -5
- package/dist/harness/pod.d.ts +34 -0
- package/dist/harness/pod.js +41 -0
- package/dist/harness/service.d.ts +0 -1
- package/dist/harness/service.js +13 -5
- package/dist/harness/setup.d.ts +27 -0
- package/dist/harness/setup.js +150 -0
- package/dist/harness/ws-client.js +3 -2
- package/dist/harness/ws-port.d.ts +1 -5
- package/dist/harness/ws-port.js +1 -5
- package/dist/harness-api/in-process.js +3 -3
- package/dist/harness-api/record.d.ts +15 -1
- package/dist/harness-api/record.js +174 -44
- package/dist/harness-api/redact.d.ts +3 -0
- package/dist/harness-api/redact.js +45 -0
- package/dist/harness-api/server.d.ts +8 -0
- package/dist/harness-api/server.js +93 -13
- package/dist/harness-api/start.d.ts +10 -3
- package/dist/harness-api/start.js +40 -10
- package/dist/harness-api/tool-journal.d.ts +96 -0
- package/dist/harness-api/tool-journal.js +102 -0
- package/dist/harness-api/tool-status.d.ts +13 -0
- package/dist/harness-api/tool-status.js +26 -0
- package/dist/harness-api/workspace.d.ts +6 -3
- package/dist/harness-api/workspace.js +27 -11
- package/dist/harness-api/ws-server.d.ts +11 -3
- package/dist/harness-api/ws-server.js +94 -15
- package/dist/host/gates.d.ts +2 -1
- package/dist/host/gates.js +10 -2
- package/dist/host/main.js +54 -5
- package/dist/host/operate.js +67 -5
- package/dist/host/stack-config.d.ts +64 -0
- package/dist/host/stack-config.js +61 -1
- package/dist/keys/client.js +4 -1
- package/dist/keys/keys.d.ts +5 -5
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +999 -2211
- package/dist/mcp/connect.d.ts +25 -20
- package/dist/mcp/connect.js +29 -30
- package/dist/mcp/pool.d.ts +2 -2
- package/dist/mcp/pool.js +1 -1
- package/dist/mcp/preview.d.ts +3 -3
- package/dist/mcp/preview.js +10 -20
- package/dist/mcp/snapshot.d.ts +5 -11
- package/dist/mcp/snapshot.js +6 -7
- package/dist/model/pi-model.js +8 -0
- package/dist/openapi.json +6955 -2526
- package/dist/reads/types.d.ts +16 -3
- package/dist/record/append.d.ts +3 -1
- package/dist/record/append.js +1 -0
- package/dist/record/envelope.d.ts +5 -1
- package/dist/record/envelope.js +7 -3
- package/dist/record/environment.d.ts +25 -0
- package/dist/record/environment.js +44 -0
- package/dist/record/index.d.ts +1 -1
- package/dist/record/index.js +1 -1
- package/dist/sandbox/disk-fs.d.ts +95 -0
- package/dist/sandbox/disk-fs.js +206 -0
- package/dist/sandbox/join.d.ts +17 -18
- package/dist/sandbox/join.js +42 -15
- package/dist/sandbox/manager.d.ts +16 -8
- package/dist/sandbox/manager.js +33 -17
- package/dist/sandbox/placement.d.ts +13 -12
- package/dist/sandbox/placement.js +29 -12
- package/dist/sandbox/pods/client.d.ts +6 -1
- package/dist/sandbox/pods/lifecycle.d.ts +28 -27
- package/dist/sandbox/pods/lifecycle.js +14 -26
- package/dist/sandbox/pods/reconcile.js +18 -12
- package/dist/sandbox/pods/spec.d.ts +15 -13
- package/dist/sandbox/pods/spec.js +16 -20
- package/dist/sandbox/records.d.ts +2 -2
- package/dist/sandbox/records.js +3 -3
- package/dist/sandbox/resolve.d.ts +35 -27
- package/dist/sandbox/resolve.js +66 -41
- package/dist/sandbox/session-sandbox.js +1 -1
- package/dist/sandbox/setup.d.ts +61 -0
- package/dist/sandbox/setup.js +123 -0
- package/dist/sandbox/share.d.ts +11 -21
- package/dist/sandbox/share.js +12 -41
- package/dist/sandbox/tenant-config.d.ts +12 -14
- package/dist/sandbox/tenant-config.js +12 -17
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
- package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
- package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
- package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
- package/dist/store/postgres/reads.js +117 -17
- package/dist/store/postgres/record-writer.d.ts +4 -4
- package/dist/store/postgres/record-writer.js +9 -9
- package/dist/store/postgres/record.d.ts +7 -0
- package/dist/store/postgres/record.js +23 -2
- package/dist/store/postgres/schema.d.ts +934 -761
- package/dist/store/postgres/schema.js +102 -73
- package/dist/store/postgres/store.js +119 -66
- package/dist/store/types.d.ts +109 -48
- package/dist/tenant/advance.d.ts +7 -6
- package/dist/tenant/advance.js +115 -42
- package/dist/tenant/artifact-tool.js +3 -4
- package/dist/tenant/commands.d.ts +15 -2
- package/dist/tenant/commands.js +93 -12
- package/dist/tenant/context.d.ts +48 -22
- package/dist/tenant/context.js +4 -15
- package/dist/tenant/effects.d.ts +1 -1
- package/dist/tenant/effects.js +37 -20
- package/dist/tenant/environments.d.ts +167 -0
- package/dist/tenant/environments.js +823 -0
- package/dist/tenant/harness-tokens.d.ts +21 -0
- package/dist/tenant/harness-tokens.js +61 -0
- package/dist/tenant/host-token.d.ts +1 -1
- package/dist/tenant/host-token.js +2 -4
- package/dist/tenant/identity-file.d.ts +4 -4
- package/dist/tenant/identity-file.js +17 -8
- package/dist/tenant/issuers.js +3 -3
- package/dist/tenant/principals.js +1 -1
- package/dist/tenant/reset.js +3 -3
- package/dist/tenant/runtime.d.ts +6 -1
- package/dist/tenant/runtime.js +7 -1
- package/dist/tenant/session.d.ts +3 -3
- package/dist/tenant/session.js +0 -14
- package/dist/tenant/sessions.d.ts +13 -13
- package/dist/tenant/sessions.js +61 -138
- package/dist/tenant/signing-keys.d.ts +5 -1
- package/dist/tenant/status.js +3 -3
- package/dist/tenant/sweep.js +12 -6
- package/dist/tenant/transcript.d.ts +210 -20
- package/dist/tenant/transcript.js +62 -11
- package/dist/tenant/turn-timing.d.ts +43 -0
- package/dist/tenant/turn-timing.js +39 -0
- package/dist/tenant/types.d.ts +2 -0
- package/dist/traces/ids.d.ts +42 -0
- package/dist/traces/ids.js +85 -0
- package/dist/traces/key.d.ts +6 -0
- package/dist/traces/key.js +37 -0
- package/dist/traces/map.d.ts +113 -0
- package/dist/traces/map.js +317 -0
- package/dist/traces/options.d.ts +7 -0
- package/dist/traces/options.js +1 -0
- package/dist/traces/otlp.d.ts +42 -0
- package/dist/traces/otlp.js +84 -0
- package/dist/traces/projector.d.ts +58 -0
- package/dist/traces/projector.js +200 -0
- package/dist/traces/read.d.ts +23 -0
- package/dist/traces/read.js +16 -0
- package/dist/vault/coverage.d.ts +26 -0
- package/dist/vault/coverage.js +64 -0
- package/dist/vault/error.d.ts +11 -1
- package/dist/vault/error.js +5 -1
- package/dist/vault/service.d.ts +47 -48
- package/dist/vault/service.js +321 -382
- package/dist/vault/sources.d.ts +5 -16
- package/dist/vault/sources.js +6 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/package.json +13 -13
- package/dist/api/http/routes/sandboxes.d.ts +0 -9
- package/dist/api/http/routes/sandboxes.js +0 -153
- package/dist/api/http/routes/vaults.d.ts +0 -10
- package/dist/api/http/routes/vaults.js +0 -139
- package/dist/record/sandbox.d.ts +0 -25
- package/dist/record/sandbox.js +0 -43
- package/dist/tenant/sandboxes.d.ts +0 -123
- package/dist/tenant/sandboxes.js +0 -566
package/dist/vault/service.js
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Runtime vault (docs/design/runtime/vault.md,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* this module holds no SQL.
|
|
2
|
+
* The Runtime vault (docs/design/runtime/vault.md, D56): one vault per Environment, created and
|
|
3
|
+
* deleted with it, holding the Environment's secrets; and the host vault, which holds the model
|
|
4
|
+
* credential. Storage is the async Session Store (`store/types.ts`); this module holds no SQL.
|
|
6
5
|
*
|
|
7
6
|
* ## Public surface
|
|
8
7
|
*
|
|
9
8
|
* ```ts
|
|
10
9
|
* new VaultService({ store: SessionStore, kek: () => Buffer })
|
|
11
10
|
*
|
|
12
|
-
* //
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* listVaults(ownerUserId: string | undefined, options?: { installation?: boolean }): Promise<VaultInfo[]>
|
|
16
|
-
* getVault(id: string): Promise<VaultInfo>
|
|
17
|
-
* deleteVault(id: string): Promise<{ id: string }>
|
|
18
|
-
* createCredential(vaultId: string, body: CreateCredentialRequest): Promise<CredentialInfo>
|
|
19
|
-
* listCredentials(vaultId: string): Promise<CredentialInfo[]>
|
|
20
|
-
* getCredential(vaultId: string, id: string): Promise<CredentialInfo>
|
|
21
|
-
* rotateCredential(vaultId: string, id: string, body: RotateCredentialRequest): Promise<CredentialInfo>
|
|
22
|
-
* deleteCredential(vaultId: string, id: string): Promise<{ id: string }>
|
|
11
|
+
* // An Environment's vault, in the caller's transaction (`tenant/environments.ts`).
|
|
12
|
+
* createEnvironmentVault(t: Tx, environmentId: string, actor: string): Promise<void>
|
|
13
|
+
* deleteEnvironmentVault(t: Tx, environmentId: string, actor: string): Promise<void>
|
|
23
14
|
*
|
|
24
|
-
* //
|
|
25
|
-
*
|
|
26
|
-
*
|
|
15
|
+
* // Its secrets (`/v1/environments/{id}/secrets`): each opens its own transaction and writes its
|
|
16
|
+
* // audit rows in it. A put needs the vault key, so callers reach it through `Keys`.
|
|
17
|
+
* putEnvironmentSecret(environmentId, name, body: PutEnvironmentSecretRequest, actor): Promise<EnvironmentSecretView>
|
|
18
|
+
* listEnvironmentSecrets(environmentId): Promise<EnvironmentSecretView[]>
|
|
19
|
+
* getEnvironmentSecret(environmentId, name): Promise<EnvironmentSecretView>
|
|
20
|
+
* deleteEnvironmentSecret(environmentId, name, actor): Promise<{ id: string }>
|
|
21
|
+
*
|
|
22
|
+
* // The shell's variables of a session's sandbox, in the caller's transaction (R2c).
|
|
23
|
+
* environmentVariables(t: Tx, environmentId: string | undefined): Promise<Record<string, string>>
|
|
27
24
|
*
|
|
28
25
|
* // Use: opens its own transactions; never call it inside one. `sessionCredentials`
|
|
29
26
|
* // (`vault/sources.ts`) calls it for a session's MCP servers and HTTP tools; a tool preview
|
|
30
|
-
* // (R2b C12, `mcp/preview.ts`) for no session.
|
|
31
|
-
* authorize(input: { sessionId?;
|
|
27
|
+
* // (R2b C12, `mcp/preview.ts`) for no session; egress-gate releases shell secrets.
|
|
28
|
+
* authorize(input: { sessionId?; environmentId?; url }): Promise<AuthorizeResult>
|
|
29
|
+
* releaseEnvironmentSecret(input: { environmentId; host }): Promise<…>
|
|
30
|
+
*
|
|
31
|
+
* // Coverage: what `authorize` would decide for each URL a manifest names, in one transaction,
|
|
32
|
+
* // with no secret read and no audit row.
|
|
33
|
+
* coverage(manifest: AgentManifest | WorkflowManifest, request: CredentialCoverageRequest): Promise<CredentialCoverage>
|
|
32
34
|
*
|
|
33
35
|
* // Host model credential: each opens its own transaction.
|
|
34
36
|
* getHostModel(): Promise<HostModelView>
|
|
@@ -41,12 +43,12 @@
|
|
|
41
43
|
* (`vault/host-model.ts`), which only the Model Gate uses: this service seals the host model's
|
|
42
44
|
* credential but never reads it back.
|
|
43
45
|
*
|
|
44
|
-
* ##
|
|
46
|
+
* ## One credential per host and per URL
|
|
45
47
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* A secret write is refused (`409 credential_conflict`) when another `environment_secret` of the
|
|
49
|
+
* vault is bound to one of its hosts, or another `bearer` or `headers` credential to its URL, so
|
|
50
|
+
* a release or a tool call never has two to choose from. Names are unique per vault: for the
|
|
51
|
+
* shell kinds the name is the variable, so no variable is set twice.
|
|
50
52
|
*
|
|
51
53
|
* ## Transactions and I/O
|
|
52
54
|
*
|
|
@@ -54,30 +56,27 @@
|
|
|
54
56
|
* filesystem (it creates the key file on first use), so methods that
|
|
55
57
|
* encrypt or decrypt resolve the KEK before opening their transaction and
|
|
56
58
|
* do only in-memory crypto inside it.
|
|
57
|
-
* - `authorize` reads the
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* that write fails, `authorize` rejects and no header leaves the vault, so
|
|
64
|
-
* every approved use has an audit row. Unreadable ciphertext is audited in
|
|
65
|
-
* its own transaction before the refusal is returned.
|
|
59
|
+
* - `authorize` reads the Environment's vault and the credential bound to the URL (ciphertext
|
|
60
|
+
* only) in one transaction. Decryption happens after it commits, so the plaintext never
|
|
61
|
+
* enters a transaction. The `use`/`approved` audit row is written in a final transaction
|
|
62
|
+
* before the header is returned: if that write fails, `authorize` rejects and no header
|
|
63
|
+
* leaves the vault, so every approved use has an audit row. Unreadable ciphertext is audited
|
|
64
|
+
* in its own transaction before the refusal is returned.
|
|
66
65
|
*
|
|
67
66
|
* ## Credentials (R2b C2)
|
|
68
67
|
*
|
|
69
|
-
* A credential is bound to the URL a manifest names and sends a `bearer` token or a
|
|
70
|
-
* map, both sealed. Its `via` (where requests go, such as a gateway) and `identity`
|
|
71
|
-
* that names the session owner) are not secret: they live unsealed in the binding,
|
|
72
|
-
* URL and the header names
|
|
73
|
-
*
|
|
74
|
-
* credential is chosen.
|
|
68
|
+
* A tool credential is bound to the URL a manifest names and sends a `bearer` token or a
|
|
69
|
+
* `headers` map, both sealed. Its `via` (where requests go, such as a gateway) and `identity`
|
|
70
|
+
* (the header that names the session owner) are not secret: they live unsealed in the binding,
|
|
71
|
+
* beside the URL and the header names. The seal's AAD binds the vault, credential id, type and
|
|
72
|
+
* URL only, and matching is by URL only, so `via` never decides which credential is chosen.
|
|
75
73
|
*/
|
|
76
74
|
import { createHash, randomUUID } from "node:crypto";
|
|
77
|
-
import { ENVIRONMENT_SECRET_DEFAULT_INJECT, ENVIRONMENT_SECRET_SENTINEL,
|
|
75
|
+
import { ENVIRONMENT_SECRET_DEFAULT_INJECT, ENVIRONMENT_SECRET_SENTINEL, environmentSecretNameError, renderInjectFormat, } from "@nylorun/core/contracts";
|
|
78
76
|
import { canonical } from "../store/canonical.js";
|
|
79
77
|
import { decryptSecret, encryptSecret, VaultCryptoError } from "./crypto.js";
|
|
80
78
|
import { VaultError } from "./error.js";
|
|
79
|
+
import { credentialNeeds } from "./coverage.js";
|
|
81
80
|
import { checkCredentialHeaders } from "./headers.js";
|
|
82
81
|
import { hostModelCatalog } from "../model/catalog.js";
|
|
83
82
|
import { normalizeVaultUrl } from "./url.js";
|
|
@@ -90,242 +89,164 @@ export class VaultService {
|
|
|
90
89
|
this.store = options.store;
|
|
91
90
|
this.kek = options.kek;
|
|
92
91
|
}
|
|
93
|
-
// ---
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
createdAt: new Date().toISOString(),
|
|
106
|
-
scope: installation ? "installation" : "user",
|
|
107
|
-
};
|
|
108
|
-
await t.insertVault(row);
|
|
109
|
-
await this.audit(t, {
|
|
110
|
-
actor: "application",
|
|
111
|
-
action: "create",
|
|
112
|
-
vaultId: row.id,
|
|
113
|
-
outcome: "created",
|
|
114
|
-
});
|
|
115
|
-
return vaultInfoOf(row);
|
|
116
|
-
}));
|
|
92
|
+
// --- an Environment's vault (caller's transaction) ------------------------------
|
|
93
|
+
/** Creates Environment `environmentId`'s vault (D56), in the transaction that creates it. */
|
|
94
|
+
async createEnvironmentVault(t, environmentId, actor) {
|
|
95
|
+
const row = {
|
|
96
|
+
id: randomUUID(),
|
|
97
|
+
name: environmentId,
|
|
98
|
+
metadataJson: null,
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
environmentId,
|
|
101
|
+
};
|
|
102
|
+
await t.insertVault(row);
|
|
103
|
+
await this.audit(t, { actor, action: "create", vaultId: row.id, target: environmentId, outcome: "created" });
|
|
117
104
|
}
|
|
118
105
|
/**
|
|
119
|
-
*
|
|
120
|
-
* the
|
|
106
|
+
* Deletes Environment `environmentId`'s vault and its credentials, with their audit rows, in
|
|
107
|
+
* the transaction that deletes the Environment: no release or tool call finds them after it
|
|
108
|
+
* commits.
|
|
121
109
|
*/
|
|
122
|
-
async
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
async getVault(id) {
|
|
130
|
-
return this.store.tx((t) => this.vaultInfo(t, id));
|
|
131
|
-
}
|
|
132
|
-
async deleteVault(id) {
|
|
133
|
-
return this.store.tx(async (t) => {
|
|
134
|
-
await this.vaultInfo(t, id);
|
|
135
|
-
for (const credential of await t.credentialsForVault(id)) {
|
|
136
|
-
await this.audit(t, {
|
|
137
|
-
actor: "application",
|
|
138
|
-
action: "delete",
|
|
139
|
-
vaultId: id,
|
|
140
|
-
credentialId: credential.id,
|
|
141
|
-
outcome: "deleted",
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
await t.deleteVault(id);
|
|
110
|
+
async deleteEnvironmentVault(t, environmentId, actor) {
|
|
111
|
+
const vault = await t.vaultOfEnvironment(environmentId);
|
|
112
|
+
if (!vault)
|
|
113
|
+
return;
|
|
114
|
+
for (const credential of await t.credentialsForVault(vault.id))
|
|
145
115
|
await this.audit(t, {
|
|
146
|
-
actor
|
|
116
|
+
actor,
|
|
147
117
|
action: "delete",
|
|
148
|
-
vaultId: id,
|
|
118
|
+
vaultId: vault.id,
|
|
119
|
+
credentialId: credential.id,
|
|
120
|
+
target: environmentId,
|
|
149
121
|
outcome: "deleted",
|
|
150
122
|
});
|
|
151
|
-
|
|
152
|
-
});
|
|
123
|
+
await t.deleteVault(vault.id);
|
|
124
|
+
await this.audit(t, { actor, action: "delete", vaultId: vault.id, target: environmentId, outcome: "deleted" });
|
|
153
125
|
}
|
|
154
|
-
|
|
126
|
+
// --- an Environment's secrets ----------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* `PUT /v1/environments/{id}/secrets/{name}`: creates or replaces one credential. The same
|
|
129
|
+
* name and type keeps the credential's id and re-seals its value and binding; another type
|
|
130
|
+
* replaces it. Refused (`409 credential_conflict`) when another credential of the vault is
|
|
131
|
+
* bound to one of its hosts or to its URL. Runs under the Environment row's lock, which also
|
|
132
|
+
* orders it with session opens and deletes, and counts as the Environment's use.
|
|
133
|
+
*/
|
|
134
|
+
async putEnvironmentSecret(environmentId, name, body, actor) {
|
|
135
|
+
const invalid = environmentSecretNameError(body.type, name);
|
|
136
|
+
if (invalid)
|
|
137
|
+
throw new VaultError(400, invalid);
|
|
138
|
+
let payload;
|
|
139
|
+
let binding;
|
|
140
|
+
if (body.type === "environment_secret" || body.type === "environment_variable")
|
|
141
|
+
({ payload, binding } = environmentCredential(name, body));
|
|
142
|
+
else {
|
|
143
|
+
const url = normalizeVaultUrl(body.url);
|
|
144
|
+
payload = payloadOf(body);
|
|
145
|
+
binding = bindingOf(url, payload, body);
|
|
146
|
+
}
|
|
155
147
|
const kek = this.kek();
|
|
156
|
-
return this.store.tx(
|
|
157
|
-
await this.
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
148
|
+
return this.store.tx(async (t) => {
|
|
149
|
+
const vault = await this.environmentVault(t, environmentId, { lock: true });
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const others = (await t.credentialsForVault(vault.id))
|
|
152
|
+
.filter(isUserCredential)
|
|
153
|
+
.filter((row) => row.name !== name);
|
|
154
|
+
conflictOf(body.type, binding, others);
|
|
155
|
+
const existing = await t.credentialByName(vault.id, name);
|
|
156
|
+
let row;
|
|
157
|
+
if (existing && isUserCredential(existing) && existing.type === body.type) {
|
|
158
|
+
row = await this.writePayload(t, kek, existing, payload, binding, now);
|
|
159
|
+
await this.audit(t, { actor, action: "rotate", vaultId: vault.id, credentialId: row.id, outcome: "rotated" });
|
|
164
160
|
}
|
|
165
161
|
else {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
if (existing) {
|
|
163
|
+
await t.deleteCredential(vault.id, existing.id);
|
|
164
|
+
await this.audit(t, { actor, action: "delete", vaultId: vault.id, credentialId: existing.id, outcome: "deleted" });
|
|
165
|
+
}
|
|
166
|
+
const id = randomUUID();
|
|
167
|
+
const sealed = encryptSecret(kek, credentialAad(vault.id, id, body.type, binding), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
168
|
+
row = {
|
|
169
|
+
id,
|
|
170
|
+
vaultId: vault.id,
|
|
171
|
+
name,
|
|
172
|
+
type: body.type,
|
|
173
|
+
bindingJson: JSON.stringify(binding),
|
|
174
|
+
expiresAt: null,
|
|
175
|
+
createdAt: now,
|
|
176
|
+
rotatedAt: null,
|
|
177
|
+
...sealed,
|
|
178
|
+
};
|
|
179
|
+
await t.insertCredential(row);
|
|
180
|
+
await this.audit(t, { actor, action: "create", vaultId: vault.id, credentialId: id, outcome: "created" });
|
|
169
181
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
vaultId,
|
|
174
|
-
name: body.name,
|
|
175
|
-
type,
|
|
176
|
-
bindingJson: JSON.stringify(binding),
|
|
177
|
-
expiresAt: null,
|
|
178
|
-
createdAt: new Date().toISOString(),
|
|
179
|
-
rotatedAt: null,
|
|
180
|
-
...sealed,
|
|
181
|
-
};
|
|
182
|
-
await t.insertCredential(row);
|
|
183
|
-
await this.audit(t, {
|
|
184
|
-
actor: "application",
|
|
185
|
-
action: "create",
|
|
186
|
-
vaultId,
|
|
187
|
-
credentialId: id,
|
|
188
|
-
outcome: "created",
|
|
189
|
-
});
|
|
190
|
-
return credentialInfoOf(row);
|
|
191
|
-
}));
|
|
182
|
+
await t.updateEnvironment(environmentId, { lastUsedAt: now }, now);
|
|
183
|
+
return secretViewOf(row);
|
|
184
|
+
});
|
|
192
185
|
}
|
|
193
|
-
|
|
186
|
+
/** The Environment's secrets: names, types and bindings, never values. */
|
|
187
|
+
async listEnvironmentSecrets(environmentId) {
|
|
194
188
|
return this.store.tx(async (t) => {
|
|
195
|
-
await this.
|
|
196
|
-
return (await t.credentialsForVault(
|
|
189
|
+
const vault = await this.environmentVault(t, environmentId);
|
|
190
|
+
return (await t.credentialsForVault(vault.id))
|
|
197
191
|
.filter(isUserCredential)
|
|
198
|
-
.
|
|
192
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
193
|
+
.map(secretViewOf);
|
|
199
194
|
});
|
|
200
195
|
}
|
|
201
|
-
async
|
|
202
|
-
return this.store.tx(async (t) => credentialInfoOf(await this.credentialRow(t, vaultId, id)));
|
|
203
|
-
}
|
|
204
|
-
async rotateCredential(vaultId, id, body) {
|
|
205
|
-
const kek = this.kek();
|
|
206
|
-
return this.store.tx((t) => this.replay(t, `rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, async () => {
|
|
207
|
-
const row = await this.credentialRow(t, vaultId, id);
|
|
208
|
-
if (row.type !== body.auth.type)
|
|
209
|
-
throw new VaultError(409, "Credential type cannot change");
|
|
210
|
-
const current = credentialBinding(row);
|
|
211
|
-
let next;
|
|
212
|
-
let binding;
|
|
213
|
-
if (body.auth.type === "environment_secret") {
|
|
214
|
-
next = { value: body.auth.secretValue };
|
|
215
|
-
binding = current;
|
|
216
|
-
}
|
|
217
|
-
else if (body.auth.type === "environment_variable") {
|
|
218
|
-
next = {};
|
|
219
|
-
binding = { ...current, variableValue: body.auth.variableValue };
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
next = payloadOf(body.auth);
|
|
223
|
-
binding = bindingOf(current.url, next, {
|
|
224
|
-
via: body.auth.via === undefined ? current.via : body.auth.via,
|
|
225
|
-
identity: body.auth.identity === undefined ? current.identity : body.auth.identity,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
const updated = await this.writePayload(t, kek, row, next, binding, new Date().toISOString());
|
|
229
|
-
await this.audit(t, {
|
|
230
|
-
actor: "application",
|
|
231
|
-
action: "rotate",
|
|
232
|
-
vaultId,
|
|
233
|
-
credentialId: id,
|
|
234
|
-
outcome: "rotated",
|
|
235
|
-
});
|
|
236
|
-
return credentialInfoOf(updated);
|
|
237
|
-
}));
|
|
238
|
-
}
|
|
239
|
-
async deleteCredential(vaultId, id) {
|
|
196
|
+
async getEnvironmentSecret(environmentId, name) {
|
|
240
197
|
return this.store.tx(async (t) => {
|
|
241
|
-
await this.
|
|
242
|
-
await t.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
vaultId,
|
|
247
|
-
credentialId: id,
|
|
248
|
-
outcome: "deleted",
|
|
249
|
-
});
|
|
250
|
-
return { id };
|
|
198
|
+
const vault = await this.environmentVault(t, environmentId);
|
|
199
|
+
const row = await t.credentialByName(vault.id, name);
|
|
200
|
+
if (!row || !isUserCredential(row))
|
|
201
|
+
throw new VaultError(404, "Secret not found");
|
|
202
|
+
return secretViewOf(row);
|
|
251
203
|
});
|
|
252
204
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const vault = await t.getVault(id);
|
|
265
|
-
if (!vault)
|
|
266
|
-
throw new VaultError(404, "Vault not found");
|
|
267
|
-
if (vault.scope === "host")
|
|
268
|
-
throw new VaultError(400, "Host vault cannot be attached to a session");
|
|
269
|
-
// Any session may use the installation's own vaults (F9-D13).
|
|
270
|
-
if (vault.scope === "installation")
|
|
271
|
-
continue;
|
|
272
|
-
if (vault.ownerUserId !== ownerUserId)
|
|
273
|
-
throw options.opaque
|
|
274
|
-
? new VaultError(404, "Vault not found")
|
|
275
|
-
: new VaultError(403, "Vault belongs to another user");
|
|
276
|
-
}
|
|
277
|
-
for (const selection of selections) {
|
|
278
|
-
const row = await t.getCredential(selection.credentialId);
|
|
279
|
-
if (!row || !vaultIds.includes(row.vaultId))
|
|
280
|
-
throw new VaultError(400, "Credential is not in an attached vault");
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/** Writes the `attach` audit row. Run in the session PUT transaction. */
|
|
284
|
-
async recordAttachment(t, sessionId, vaultIds) {
|
|
285
|
-
await this.audit(t, {
|
|
286
|
-
actor: "application",
|
|
287
|
-
action: "attach",
|
|
288
|
-
sessionId,
|
|
289
|
-
target: vaultIds.join(","),
|
|
290
|
-
outcome: "attached",
|
|
205
|
+
async deleteEnvironmentSecret(environmentId, name, actor) {
|
|
206
|
+
return this.store.tx(async (t) => {
|
|
207
|
+
const vault = await this.environmentVault(t, environmentId, { lock: true });
|
|
208
|
+
const row = await t.credentialByName(vault.id, name);
|
|
209
|
+
if (!row || !isUserCredential(row))
|
|
210
|
+
throw new VaultError(404, "Secret not found");
|
|
211
|
+
await t.deleteCredential(vault.id, row.id);
|
|
212
|
+
await this.audit(t, { actor, action: "delete", vaultId: vault.id, credentialId: row.id, outcome: "deleted" });
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
await t.updateEnvironment(environmentId, { lastUsedAt: now }, now);
|
|
215
|
+
return { id: name };
|
|
291
216
|
});
|
|
292
217
|
}
|
|
293
218
|
/**
|
|
294
219
|
* The variables a session's sandbox commands get (R2c, D50): each `environment_secret`'s name
|
|
295
|
-
* set to the sentinel, each `environment_variable`'s value,
|
|
296
|
-
*
|
|
297
|
-
* secrets bound to one host. Reads rows only; no plaintext. Run in the caller's transaction.
|
|
220
|
+
* set to the sentinel, and each `environment_variable`'s value, from the Environment's vault.
|
|
221
|
+
* Reads rows only; no plaintext. Run in the caller's transaction.
|
|
298
222
|
*/
|
|
299
|
-
async
|
|
223
|
+
async environmentVariables(t, environmentId) {
|
|
224
|
+
if (environmentId === undefined)
|
|
225
|
+
return {};
|
|
226
|
+
const vault = await t.vaultOfEnvironment(environmentId);
|
|
227
|
+
if (!vault)
|
|
228
|
+
return {};
|
|
300
229
|
const environment = {};
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
environment[name] = row.type === "environment_secret" ? ENVIRONMENT_SECRET_SENTINEL : binding.variableValue;
|
|
312
|
-
for (const host of row.type === "environment_secret" ? binding.allowedHosts ?? [] : []) {
|
|
313
|
-
if (hosts.has(host) && hosts.get(host) !== row.id)
|
|
314
|
-
conflict ??= `Two attached environment_secret credentials are bound to ${host}`;
|
|
315
|
-
hosts.set(host, row.id);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return { environment, secretHosts: Object.fromEntries([...hosts].sort()), ...(conflict ? { conflict } : {}) };
|
|
230
|
+
for (const row of await t.credentialsForVault(vault.id)) {
|
|
231
|
+
if (row.type !== "environment_secret" && row.type !== "environment_variable")
|
|
232
|
+
continue;
|
|
233
|
+
const binding = JSON.parse(row.bindingJson);
|
|
234
|
+
if (row.type === "environment_secret")
|
|
235
|
+
environment[binding.secretName] = ENVIRONMENT_SECRET_SENTINEL;
|
|
236
|
+
else
|
|
237
|
+
environment[binding.variableName] = binding.variableValue;
|
|
238
|
+
}
|
|
239
|
+
return environment;
|
|
319
240
|
}
|
|
320
241
|
/**
|
|
321
|
-
* The header egress-gate sets on a request from
|
|
322
|
-
* `
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
242
|
+
* The header egress-gate sets on a request from Environment `environmentId`'s sandbox to
|
|
243
|
+
* `host` (R2c, D50): the `environment_secret` of its vault bound to `host`, its value unsealed
|
|
244
|
+
* and rendered into its format. `none` when no secret is bound to the host; `refused` when the
|
|
245
|
+
* sealed value cannot be read. One query and one unseal per call; nothing is cached (E3).
|
|
246
|
+
* Plaintext exists only in the returned header.
|
|
326
247
|
*/
|
|
327
248
|
async releaseEnvironmentSecret(input) {
|
|
328
|
-
const found = await this.store.tx((t) =>
|
|
249
|
+
const found = await this.store.tx((t) => environmentSecretsFor(t, input.environmentId, input.host));
|
|
329
250
|
if (found.length === 0)
|
|
330
251
|
return { status: "none" };
|
|
331
252
|
if (found.length > 1)
|
|
@@ -345,21 +266,23 @@ export class VaultService {
|
|
|
345
266
|
const inject = credentialBinding(row).inject ?? ENVIRONMENT_SECRET_DEFAULT_INJECT;
|
|
346
267
|
return { status: "released", header: inject.header, value: renderInjectFormat(inject.format, payload.value), credentialId: row.id };
|
|
347
268
|
}
|
|
348
|
-
/** Whether
|
|
349
|
-
async environmentSecretBound(
|
|
350
|
-
return (await this.store.tx((t) =>
|
|
269
|
+
/** Whether an `environment_secret` of Environment `environmentId` is bound to `host`. */
|
|
270
|
+
async environmentSecretBound(environmentId, host) {
|
|
271
|
+
return (await this.store.tx((t) => environmentSecretsFor(t, environmentId, host))).length > 0;
|
|
351
272
|
}
|
|
352
273
|
// --- use ---------------------------------------------------------------------
|
|
274
|
+
/**
|
|
275
|
+
* The headers a request to `url` carries: the credential of the Environment's vault bound to
|
|
276
|
+
* it, or none (`unauthenticated`) without an Environment or such a credential.
|
|
277
|
+
*/
|
|
353
278
|
async authorize(input) {
|
|
354
279
|
const url = normalizeVaultUrl(input.url);
|
|
280
|
+
const environmentId = input.environmentId;
|
|
281
|
+
if (environmentId === undefined)
|
|
282
|
+
return { status: "unauthenticated", url, headers: {} };
|
|
355
283
|
const decided = await this.store.tx(async (t) => {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
const found = await t.getVault(id);
|
|
359
|
-
if (found) {
|
|
360
|
-
scopes.set(id, found.scope);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
284
|
+
const vault = await t.vaultOfEnvironment(environmentId);
|
|
285
|
+
if (!vault) {
|
|
363
286
|
await this.audit(t, {
|
|
364
287
|
actor: "host",
|
|
365
288
|
action: "use",
|
|
@@ -367,48 +290,29 @@ export class VaultService {
|
|
|
367
290
|
target: url,
|
|
368
291
|
outcome: "refused",
|
|
369
292
|
});
|
|
370
|
-
return {
|
|
371
|
-
result: {
|
|
372
|
-
status: "refused",
|
|
373
|
-
url,
|
|
374
|
-
credentialIds: [],
|
|
375
|
-
reason: "vault_missing",
|
|
376
|
-
},
|
|
377
|
-
};
|
|
293
|
+
return { result: { status: "refused", url, credentialIds: [], reason: "vault_missing" } };
|
|
378
294
|
}
|
|
379
|
-
const matches =
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
matches.push(row);
|
|
384
|
-
matches.sort((a, b) => a.id.localeCompare(b.id));
|
|
295
|
+
const matches = (await t.credentialsForVault(vault.id))
|
|
296
|
+
.filter(isUserCredential)
|
|
297
|
+
.filter((row) => bindingUrl(row) === url)
|
|
298
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
385
299
|
if (matches.length === 0)
|
|
386
300
|
return { result: { status: "unauthenticated", url, headers: {} } };
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
: undefined;
|
|
390
|
-
const chosen = choose(matches, selection);
|
|
391
|
-
if (chosen.kind === "refused") {
|
|
301
|
+
if (matches.length > 1) {
|
|
302
|
+
// Refused at write (D56); kept as a guard.
|
|
392
303
|
await this.audit(t, {
|
|
393
304
|
actor: "host",
|
|
394
305
|
action: "use",
|
|
306
|
+
vaultId: vault.id,
|
|
395
307
|
sessionId: input.sessionId,
|
|
396
308
|
target: url,
|
|
397
309
|
outcome: "refused",
|
|
398
310
|
});
|
|
399
311
|
return {
|
|
400
|
-
result: {
|
|
401
|
-
status: "refused",
|
|
402
|
-
url,
|
|
403
|
-
credentialIds: chosen.credentialIds,
|
|
404
|
-
reason: chosen.reason,
|
|
405
|
-
},
|
|
312
|
+
result: { status: "refused", url, credentialIds: matches.map((row) => row.id), reason: "ambiguous" },
|
|
406
313
|
};
|
|
407
314
|
}
|
|
408
|
-
return {
|
|
409
|
-
row: chosen.row,
|
|
410
|
-
vault: scopes.get(chosen.row.vaultId) === "installation" ? "installation" : "user",
|
|
411
|
-
};
|
|
315
|
+
return { row: matches[0] };
|
|
412
316
|
});
|
|
413
317
|
if ("result" in decided)
|
|
414
318
|
return decided.result;
|
|
@@ -431,22 +335,11 @@ export class VaultService {
|
|
|
431
335
|
target: url,
|
|
432
336
|
outcome: "refused",
|
|
433
337
|
}));
|
|
434
|
-
return {
|
|
435
|
-
status: "refused",
|
|
436
|
-
url,
|
|
437
|
-
credentialIds: [row.id],
|
|
438
|
-
reason: "unreadable",
|
|
439
|
-
};
|
|
338
|
+
return { status: "refused", url, credentialIds: [row.id], reason: "unreadable" };
|
|
440
339
|
}
|
|
441
340
|
const headers = sendHeaders(row, payload);
|
|
442
|
-
if (!headers)
|
|
443
|
-
return {
|
|
444
|
-
status: "refused",
|
|
445
|
-
url,
|
|
446
|
-
credentialIds: [row.id],
|
|
447
|
-
reason: "unreadable",
|
|
448
|
-
};
|
|
449
|
-
}
|
|
341
|
+
if (!headers)
|
|
342
|
+
return { status: "refused", url, credentialIds: [row.id], reason: "unreadable" };
|
|
450
343
|
await this.store.tx((t) => this.audit(t, {
|
|
451
344
|
actor: "host",
|
|
452
345
|
action: "use",
|
|
@@ -463,9 +356,45 @@ export class VaultService {
|
|
|
463
356
|
headers,
|
|
464
357
|
...(binding.via === undefined ? {} : { via: binding.via }),
|
|
465
358
|
...(binding.identity === undefined ? {} : { identity: binding.identity }),
|
|
466
|
-
vault: decided.vault,
|
|
467
359
|
};
|
|
468
360
|
}
|
|
361
|
+
// --- coverage --------------------------------------------------------------------
|
|
362
|
+
/**
|
|
363
|
+
* What a session that names `request.environmentId` would send for each URL `manifest` names
|
|
364
|
+
* (`credentialNeeds`), decided as `authorize` decides a call: the credential of the
|
|
365
|
+
* Environment's vault bound to the URL. Reads no secret, writes no audit row.
|
|
366
|
+
*/
|
|
367
|
+
async coverage(manifest, request) {
|
|
368
|
+
const environmentId = request.environmentId;
|
|
369
|
+
return this.store.tx(async (t) => {
|
|
370
|
+
const credentials = [];
|
|
371
|
+
if (environmentId !== undefined) {
|
|
372
|
+
const vault = await this.environmentVault(t, environmentId);
|
|
373
|
+
for (const row of await t.credentialsForVault(vault.id))
|
|
374
|
+
if (isUserCredential(row))
|
|
375
|
+
credentials.push({ row, url: bindingUrl(row) });
|
|
376
|
+
}
|
|
377
|
+
const entries = credentialNeeds(manifest).map((need) => {
|
|
378
|
+
let url;
|
|
379
|
+
try {
|
|
380
|
+
url = normalizeVaultUrl(need.url);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (!(error instanceof VaultError))
|
|
384
|
+
throw error;
|
|
385
|
+
return coverageEntry(need, need.url, undefined, environmentId, error.message);
|
|
386
|
+
}
|
|
387
|
+
const bound = credentials.find((item) => item.url === url);
|
|
388
|
+
return coverageEntry(need, url, bound && coverageCredential(environmentId, bound.row), environmentId);
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
agentId: request.agentId,
|
|
392
|
+
environmentId: environmentId ?? null,
|
|
393
|
+
complete: entries.every((entry) => entry.status === "covered"),
|
|
394
|
+
entries,
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
}
|
|
469
398
|
// --- host model ----------------------------------------------------------------
|
|
470
399
|
async getHostModel() {
|
|
471
400
|
return this.store.tx((t) => this.hostModelView(t));
|
|
@@ -586,25 +515,28 @@ export class VaultService {
|
|
|
586
515
|
throw new VaultError(404, "Credential not found");
|
|
587
516
|
return { ...row, ...patch };
|
|
588
517
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
518
|
+
/**
|
|
519
|
+
* The vault of a live Environment (not being deleted), or the 404 of a missing one. With
|
|
520
|
+
* `lock`, the Environment's row is locked until the transaction ends.
|
|
521
|
+
*/
|
|
522
|
+
async environmentVault(t, environmentId, options = {}) {
|
|
523
|
+
const environment = await t.environment(environmentId, options);
|
|
524
|
+
const vault = environment && environment.pod?.desired !== "deleted"
|
|
525
|
+
? await t.vaultOfEnvironment(environmentId)
|
|
526
|
+
: undefined;
|
|
527
|
+
if (!vault)
|
|
528
|
+
throw new VaultError(404, "Environment not found");
|
|
529
|
+
return vault;
|
|
594
530
|
}
|
|
595
531
|
async ensureHostVault(t) {
|
|
596
|
-
|
|
597
|
-
if (existing?.scope === "host")
|
|
532
|
+
if (await t.getVault(HOST_VAULT_ID))
|
|
598
533
|
return;
|
|
599
|
-
if (existing)
|
|
600
|
-
throw new VaultError(409, "Host vault id is already used");
|
|
601
534
|
await t.insertVault({
|
|
602
535
|
id: HOST_VAULT_ID,
|
|
603
536
|
name: "Host",
|
|
604
|
-
ownerUserId: "host",
|
|
605
537
|
metadataJson: null,
|
|
606
538
|
createdAt: new Date().toISOString(),
|
|
607
|
-
|
|
539
|
+
environmentId: null,
|
|
608
540
|
});
|
|
609
541
|
}
|
|
610
542
|
validateHostModel(body) {
|
|
@@ -680,12 +612,6 @@ export class VaultService {
|
|
|
680
612
|
for (const id of new Set(ids))
|
|
681
613
|
await t.deleteCredential(HOST_VAULT_ID, id);
|
|
682
614
|
}
|
|
683
|
-
async credentialRow(t, vaultId, id) {
|
|
684
|
-
const row = await t.getCredential(id);
|
|
685
|
-
if (!row || row.vaultId !== vaultId || !isUserCredential(row))
|
|
686
|
-
throw new VaultError(404, "Credential not found");
|
|
687
|
-
return row;
|
|
688
|
-
}
|
|
689
615
|
async audit(t, entry) {
|
|
690
616
|
await t.insertVaultAudit({
|
|
691
617
|
id: randomUUID(),
|
|
@@ -706,35 +632,80 @@ function isUserCredential(row) {
|
|
|
706
632
|
function bindingUrl(row) {
|
|
707
633
|
return JSON.parse(row.bindingJson).url;
|
|
708
634
|
}
|
|
709
|
-
|
|
635
|
+
/** Where a credential is, for coverage: never its value. */
|
|
636
|
+
function coverageCredential(environmentId, row) {
|
|
637
|
+
const via = JSON.parse(row.bindingJson).via;
|
|
638
|
+
return { environmentId, name: row.name, ...(via === undefined ? {} : { via }) };
|
|
639
|
+
}
|
|
640
|
+
/** One coverage entry, with the sentence that says what the session would do. */
|
|
641
|
+
function coverageEntry(need, url, credential, environmentId, invalid) {
|
|
642
|
+
const required = need.kind === "http";
|
|
643
|
+
const where = environmentId === undefined
|
|
644
|
+
? "Without an Environment, no credential is sent"
|
|
645
|
+
: `Environment ${environmentId} holds no credential for this URL`;
|
|
646
|
+
const message = invalid !== undefined
|
|
647
|
+
? `No credential can be bound to this URL (${invalid}).`
|
|
648
|
+
: credential !== undefined
|
|
649
|
+
? `Sends '${credential.name}' from Environment ${credential.environmentId}${credential.via ? ` through ${credential.via}` : ""}.`
|
|
650
|
+
: required
|
|
651
|
+
? `${where}: each call fails with http.credential.`
|
|
652
|
+
: `${where}: the server is called without one, which only a public server accepts.`;
|
|
710
653
|
return {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
654
|
+
kind: need.kind,
|
|
655
|
+
...(need.agentId === undefined ? {} : { agentId: need.agentId }),
|
|
656
|
+
...(need.stage === undefined ? {} : { stage: need.stage }),
|
|
657
|
+
name: need.name,
|
|
658
|
+
serverName: need.serverName,
|
|
659
|
+
url,
|
|
660
|
+
required,
|
|
661
|
+
status: credential === undefined ? "missing" : "covered",
|
|
662
|
+
...(credential === undefined ? {} : { credential }),
|
|
663
|
+
message,
|
|
718
664
|
};
|
|
719
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Refuses a write that would bind a second credential to a host or a URL (D56): `others` are
|
|
668
|
+
* the vault's other credentials.
|
|
669
|
+
*/
|
|
670
|
+
function conflictOf(type, binding, others) {
|
|
671
|
+
if (type === "environment_secret") {
|
|
672
|
+
for (const row of others) {
|
|
673
|
+
if (row.type !== "environment_secret")
|
|
674
|
+
continue;
|
|
675
|
+
const hosts = credentialBinding(row).allowedHosts ?? [];
|
|
676
|
+
const host = (binding.allowedHosts ?? []).find((item) => hosts.includes(item));
|
|
677
|
+
if (host !== undefined)
|
|
678
|
+
throw new VaultError(409, `${row.name} is already bound to ${host}`, {
|
|
679
|
+
code: "credential_conflict",
|
|
680
|
+
details: { host, existing: row.name },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
else if (type === "bearer" || type === "headers") {
|
|
685
|
+
const existing = others.find((row) => (row.type === "bearer" || row.type === "headers") && credentialBinding(row).url === binding.url);
|
|
686
|
+
if (existing)
|
|
687
|
+
throw new VaultError(409, `${existing.name} is already bound to ${binding.url}`, {
|
|
688
|
+
code: "credential_conflict",
|
|
689
|
+
details: { url: binding.url, existing: existing.name },
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
720
693
|
function credentialBinding(row) {
|
|
721
694
|
return JSON.parse(row.bindingJson);
|
|
722
695
|
}
|
|
723
|
-
|
|
696
|
+
/** A secret as `GET .../secrets/{name}` shows it: never its value. */
|
|
697
|
+
function secretViewOf(row) {
|
|
724
698
|
const binding = credentialBinding(row);
|
|
725
699
|
return {
|
|
726
|
-
id: row.id,
|
|
727
|
-
vaultId: row.vaultId,
|
|
728
700
|
name: row.name,
|
|
729
701
|
type: row.type,
|
|
730
702
|
binding: row.type === "environment_secret"
|
|
731
703
|
? {
|
|
732
|
-
secretName: binding.secretName,
|
|
733
704
|
allowedHosts: binding.allowedHosts,
|
|
734
705
|
inject: binding.inject ?? { ...ENVIRONMENT_SECRET_DEFAULT_INJECT },
|
|
735
706
|
}
|
|
736
707
|
: row.type === "environment_variable"
|
|
737
|
-
? {
|
|
708
|
+
? {}
|
|
738
709
|
: { url: binding.url },
|
|
739
710
|
...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
|
|
740
711
|
...(binding.via === undefined ? {} : { via: binding.via }),
|
|
@@ -819,33 +790,10 @@ export function readHostPayload(kek, row, binding) {
|
|
|
819
790
|
plaintext.fill(0);
|
|
820
791
|
}
|
|
821
792
|
}
|
|
822
|
-
|
|
823
|
-
const ids = matches.map((row) => row.id);
|
|
824
|
-
if (matches.length === 1) {
|
|
825
|
-
const only = matches[0];
|
|
826
|
-
if (selection && selection.credentialId !== only.id)
|
|
827
|
-
return {
|
|
828
|
-
kind: "refused",
|
|
829
|
-
credentialIds: ids,
|
|
830
|
-
reason: "selection_mismatch",
|
|
831
|
-
};
|
|
832
|
-
return { kind: "use", row: only };
|
|
833
|
-
}
|
|
834
|
-
if (!selection)
|
|
835
|
-
return { kind: "refused", credentialIds: ids, reason: "ambiguous" };
|
|
836
|
-
const chosen = matches.find((row) => row.id === selection.credentialId);
|
|
837
|
-
if (!chosen)
|
|
838
|
-
return {
|
|
839
|
-
kind: "refused",
|
|
840
|
-
credentialIds: ids,
|
|
841
|
-
reason: "selection_mismatch",
|
|
842
|
-
};
|
|
843
|
-
return { kind: "use", row: chosen };
|
|
844
|
-
}
|
|
845
|
-
/** The sealed part of a create or rotate: a token, or a header map with lower-cased names. */
|
|
793
|
+
/** The sealed part of a tool credential: a token, or a header map with lower-cased names. */
|
|
846
794
|
function payloadOf(auth) {
|
|
847
795
|
if (auth.type === "bearer")
|
|
848
|
-
return { token: auth.
|
|
796
|
+
return { token: auth.value };
|
|
849
797
|
return {
|
|
850
798
|
headers: Object.fromEntries(Object.entries(auth.headers).map(([name, value]) => [name.toLowerCase(), value])),
|
|
851
799
|
};
|
|
@@ -907,18 +855,18 @@ function credentialAad(vaultId, credentialId, type, binding) {
|
|
|
907
855
|
}
|
|
908
856
|
: { vaultId, credentialId, type, url: binding.url }), "utf8");
|
|
909
857
|
}
|
|
910
|
-
/** The sealed payload and unsealed binding of
|
|
911
|
-
function environmentCredential(
|
|
912
|
-
if (
|
|
913
|
-
return { payload: {}, binding: { variableName:
|
|
914
|
-
if (
|
|
915
|
-
checkCredentialHeaders([
|
|
858
|
+
/** The sealed payload and unsealed binding of a shell credential named `name` (R2c). */
|
|
859
|
+
function environmentCredential(name, body) {
|
|
860
|
+
if (body.type === "environment_variable")
|
|
861
|
+
return { payload: {}, binding: { variableName: name, variableValue: body.value } };
|
|
862
|
+
if (body.inject)
|
|
863
|
+
checkCredentialHeaders([body.inject.header.toLowerCase()], undefined);
|
|
916
864
|
return {
|
|
917
|
-
payload: { value:
|
|
865
|
+
payload: { value: body.value },
|
|
918
866
|
binding: {
|
|
919
|
-
secretName:
|
|
920
|
-
allowedHosts:
|
|
921
|
-
...(
|
|
867
|
+
secretName: name,
|
|
868
|
+
allowedHosts: body.allowedHosts.map((host) => host.toLowerCase()),
|
|
869
|
+
...(body.inject ? { inject: { header: body.inject.header, format: body.inject.format } } : {}),
|
|
922
870
|
},
|
|
923
871
|
};
|
|
924
872
|
}
|
|
@@ -926,21 +874,12 @@ function requestHash(body) {
|
|
|
926
874
|
const { requestId: _requestId, ...rest } = body;
|
|
927
875
|
return canonical(rest);
|
|
928
876
|
}
|
|
929
|
-
/**
|
|
930
|
-
|
|
931
|
-
* sandbox (R2c). A session between turns counts: it can run again, and what it started may
|
|
932
|
-
* still run. Session open keeps them to one owner and one credential per host.
|
|
933
|
-
*/
|
|
934
|
-
async function sandboxSecretsFor(t, sandboxId, host) {
|
|
877
|
+
/** The `environment_secret` credentials of Environment `environmentId` bound to `host` (R2c). */
|
|
878
|
+
async function environmentSecretsFor(t, environmentId, host) {
|
|
935
879
|
const lower = host.toLowerCase();
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
for (const vaultId of vaultIds)
|
|
942
|
-
for (const row of await t.credentialsForVault(vaultId))
|
|
943
|
-
if (row.type === "environment_secret" && (credentialBinding(row).allowedHosts ?? []).includes(lower))
|
|
944
|
-
matches.push(row);
|
|
945
|
-
return matches;
|
|
880
|
+
const vault = await t.vaultOfEnvironment(environmentId);
|
|
881
|
+
if (!vault)
|
|
882
|
+
return [];
|
|
883
|
+
return (await t.credentialsForVault(vault.id)).filter((row) => row.type === "environment_secret" &&
|
|
884
|
+
(credentialBinding(row).allowedHosts ?? []).includes(lower));
|
|
946
885
|
}
|