@pithy-sh/core 0.12.0 → 0.14.0
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/README.md +54 -0
- package/dist/audit/auditEvent.js +1 -1
- package/dist/audit/chain.d.ts +289 -0
- package/dist/audit/chain.d.ts.map +1 -0
- package/dist/audit/chain.js +400 -0
- package/dist/audit/citations.d.ts +52 -0
- package/dist/audit/citations.d.ts.map +1 -0
- package/dist/audit/citations.js +13 -0
- package/dist/audit/composed.d.ts +18 -0
- package/dist/audit/composed.d.ts.map +1 -0
- package/dist/audit/composed.js +28 -0
- package/dist/capability/bindings.d.ts +22 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +32 -2
- package/dist/capability/capability.d.ts +133 -15
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +17 -2
- package/dist/capability/erasure.d.ts +75 -0
- package/dist/capability/erasure.d.ts.map +1 -0
- package/dist/capability/erasure.js +2 -0
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/personColumns.d.ts +28 -0
- package/dist/capability/personColumns.d.ts.map +1 -0
- package/dist/capability/personColumns.js +66 -0
- package/dist/capability/personRows.d.ts +62 -0
- package/dist/capability/personRows.d.ts.map +1 -0
- package/dist/capability/personRows.js +59 -0
- package/dist/capability/purge.d.ts +113 -4
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +138 -5
- package/dist/capability/settings.d.ts +10 -0
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/vectorIndex.d.ts +36 -0
- package/dist/capability/vectorIndex.d.ts.map +1 -0
- package/dist/capability/vectorIndex.js +25 -0
- package/dist/cli/commandCatalog.generated.d.ts +434 -4
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +455 -2
- package/dist/cli/commandFacts.d.ts +8 -5
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +12 -3
- package/dist/consent/purpose.d.ts +70 -0
- package/dist/consent/purpose.d.ts.map +1 -0
- package/dist/consent/purpose.js +97 -0
- package/dist/consent/reader.d.ts +99 -0
- package/dist/consent/reader.d.ts.map +1 -0
- package/dist/consent/reader.js +33 -0
- package/dist/controlPlane/audit/actions.d.ts +15 -4
- package/dist/controlPlane/audit/actions.d.ts.map +1 -1
- package/dist/controlPlane/audit/actions.js +16 -5
- package/dist/controlPlane/capability.d.ts +10 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +56 -8
- package/dist/controlPlane/context.d.ts +4 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -2
- package/dist/controlPlane/data/connection.d.ts +21 -2
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +13 -4
- package/dist/controlPlane/data/connections.d.ts +94 -0
- package/dist/controlPlane/data/connections.d.ts.map +1 -0
- package/dist/controlPlane/data/connections.js +151 -0
- package/dist/controlPlane/data/hold.d.ts +46 -0
- package/dist/controlPlane/data/hold.d.ts.map +1 -0
- package/dist/controlPlane/data/hold.js +36 -0
- package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
- package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
- package/dist/controlPlane/data/oidcTrust.js +173 -0
- package/dist/controlPlane/data/tables.d.ts +4 -0
- package/dist/controlPlane/data/tables.d.ts.map +1 -1
- package/dist/controlPlane/data/tables.js +9 -4
- package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
- package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
- package/dist/controlPlane/data/trustFirstCall.js +25 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +32 -8
- package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
- package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
- package/dist/controlPlane/discovery/routeInput.js +80 -0
- package/dist/controlPlane/discovery/toolName.d.ts +21 -0
- package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
- package/dist/controlPlane/discovery/toolName.js +25 -0
- package/dist/controlPlane/erasure/attest.d.ts +78 -0
- package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
- package/dist/controlPlane/erasure/attest.js +164 -0
- package/dist/controlPlane/erasure/read.d.ts +39 -0
- package/dist/controlPlane/erasure/read.d.ts.map +1 -0
- package/dist/controlPlane/erasure/read.js +140 -0
- package/dist/controlPlane/error/errors.d.ts +61 -2
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +13 -0
- package/dist/controlPlane/evidence/derive.d.ts +124 -0
- package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
- package/dist/controlPlane/evidence/derive.js +343 -0
- package/dist/controlPlane/evidence/mapping.d.ts +85 -0
- package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
- package/dist/controlPlane/evidence/mapping.js +114 -0
- package/dist/controlPlane/evidence/pack.d.ts +46 -0
- package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
- package/dist/controlPlane/evidence/pack.js +124 -0
- package/dist/controlPlane/http/connections.d.ts +96 -0
- package/dist/controlPlane/http/connections.d.ts.map +1 -0
- package/dist/controlPlane/http/connections.js +109 -0
- package/dist/controlPlane/http/guard.d.ts +23 -2
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +103 -7
- package/dist/controlPlane/http/handlers.d.ts +212 -10
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +117 -26
- package/dist/controlPlane/http/holds.d.ts +57 -0
- package/dist/controlPlane/http/holds.d.ts.map +1 -0
- package/dist/controlPlane/http/holds.js +98 -0
- package/dist/controlPlane/http/presence.d.ts +27 -0
- package/dist/controlPlane/http/presence.d.ts.map +1 -0
- package/dist/controlPlane/http/presence.js +67 -0
- package/dist/controlPlane/http/processing.d.ts +73 -0
- package/dist/controlPlane/http/processing.d.ts.map +1 -0
- package/dist/controlPlane/http/processing.js +17 -0
- package/dist/controlPlane/http/responses.d.ts +927 -3
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +283 -7
- package/dist/controlPlane/http/routes.d.ts +12 -2
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +182 -5
- package/dist/controlPlane/http/schemas.d.ts +63 -0
- package/dist/controlPlane/http/schemas.d.ts.map +1 -1
- package/dist/controlPlane/http/schemas.js +39 -1
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +13 -1
- package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
- package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
- package/dist/controlPlane/http/verifyTrust.js +75 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0005_holds.js +26 -0
- package/dist/controlPlane/processing/derive.d.ts +77 -0
- package/dist/controlPlane/processing/derive.d.ts.map +1 -0
- package/dist/controlPlane/processing/derive.js +206 -0
- package/dist/controlPlane/processing/read.d.ts +10 -0
- package/dist/controlPlane/processing/read.d.ts.map +1 -0
- package/dist/controlPlane/processing/read.js +89 -0
- package/dist/controlPlane/scope/kind.d.ts +46 -0
- package/dist/controlPlane/scope/kind.d.ts.map +1 -0
- package/dist/controlPlane/scope/kind.js +52 -0
- package/dist/controlPlane/scope/scope.d.ts +58 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +59 -4
- package/dist/controlPlane/token/jws.js +1 -1
- package/dist/controlPlane/token/mint.js +1 -1
- package/dist/controlPlane/wire.d.ts +23 -0
- package/dist/controlPlane/wire.d.ts.map +1 -1
- package/dist/controlPlane/wire.js +24 -1
- package/dist/createBackend.d.ts +13 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +11 -3
- package/dist/createEntrypoint.d.ts +14 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +19 -5
- package/dist/error/http.d.ts +216 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +432 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +144 -0
- package/dist/error/pithyError.d.ts +22 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +31 -1
- package/dist/http/routeSpec.d.ts +46 -1
- package/dist/http/routeSpec.d.ts.map +1 -1
- package/dist/http/routeSpec.js +39 -5
- package/dist/http/validation.d.ts +11 -2
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +20 -3
- package/dist/http/verification.d.ts.map +1 -1
- package/dist/http/verification.js +1 -1
- package/dist/i18n/translator.d.ts +2 -1
- package/dist/i18n/translator.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/logger/record.d.ts +126 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/mcp/annotations.d.ts +20 -0
- package/dist/mcp/annotations.d.ts.map +1 -0
- package/dist/mcp/annotations.js +23 -0
- package/dist/mcp/tools.d.ts +75 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +118 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +8 -26
- package/dist/openapi/operations.d.ts +35 -0
- package/dist/openapi/operations.d.ts.map +1 -0
- package/dist/openapi/operations.js +37 -0
- package/dist/presence/channel.d.ts +11 -0
- package/dist/presence/channel.d.ts.map +1 -0
- package/dist/presence/channel.js +65 -0
- package/dist/presence/declaration.d.ts +154 -0
- package/dist/presence/declaration.d.ts.map +1 -0
- package/dist/presence/declaration.js +164 -0
- package/dist/presence/durableObject.d.ts +61 -0
- package/dist/presence/durableObject.d.ts.map +1 -0
- package/dist/presence/durableObject.js +257 -0
- package/dist/presence/frames.d.ts +166 -0
- package/dist/presence/frames.d.ts.map +1 -0
- package/dist/presence/frames.js +90 -0
- package/dist/presence/http/guard.d.ts +11 -0
- package/dist/presence/http/guard.d.ts.map +1 -0
- package/dist/presence/http/guard.js +20 -0
- package/dist/presence/http/responses.d.ts +31 -0
- package/dist/presence/http/responses.d.ts.map +1 -0
- package/dist/presence/http/responses.js +34 -0
- package/dist/presence/http/routes.d.ts +24 -0
- package/dist/presence/http/routes.d.ts.map +1 -0
- package/dist/presence/http/routes.js +127 -0
- package/dist/presence/http/schemas.d.ts +29 -0
- package/dist/presence/http/schemas.d.ts.map +1 -0
- package/dist/presence/http/schemas.js +28 -0
- package/dist/presence/names.d.ts +23 -0
- package/dist/presence/names.d.ts.map +1 -0
- package/dist/presence/names.js +22 -0
- package/dist/presence/room.d.ts +95 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +105 -0
- package/dist/presence/ticket.d.ts +55 -0
- package/dist/presence/ticket.d.ts.map +1 -0
- package/dist/presence/ticket.js +109 -0
- package/dist/privacy/classify.js +1 -1
- package/dist/privacy/encrypted.js +1 -1
- package/dist/processing/activity.d.ts +134 -0
- package/dist/processing/activity.d.ts.map +1 -0
- package/dist/processing/activity.js +161 -0
- package/dist/processing/composed.d.ts +18 -0
- package/dist/processing/composed.d.ts.map +1 -0
- package/dist/processing/composed.js +28 -0
- package/dist/processing/vocabulary.d.ts +26 -0
- package/dist/processing/vocabulary.d.ts.map +1 -0
- package/dist/processing/vocabulary.js +35 -0
- package/dist/queue/consumer.d.ts +59 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +46 -0
- package/dist/retention/composed.d.ts +37 -0
- package/dist/retention/composed.d.ts.map +1 -0
- package/dist/retention/composed.js +66 -0
- package/dist/retention/holds.d.ts +61 -0
- package/dist/retention/holds.d.ts.map +1 -0
- package/dist/retention/holds.js +107 -0
- package/dist/retention/holdsPurge.d.ts +22 -0
- package/dist/retention/holdsPurge.d.ts.map +1 -0
- package/dist/retention/holdsPurge.js +50 -0
- package/dist/retention/policy.d.ts +132 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +197 -0
- package/dist/session/rotated.d.ts +34 -0
- package/dist/session/rotated.d.ts.map +1 -0
- package/dist/session/rotated.js +25 -0
- package/dist/test-utils/connectionFixtures.d.ts +13 -0
- package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
- package/dist/test-utils/connectionFixtures.js +58 -0
- package/dist/test-utils/interleave.d.ts +12 -0
- package/dist/test-utils/interleave.d.ts.map +1 -0
- package/dist/test-utils/interleave.js +26 -0
- package/dist/testWorker.d.ts +8 -0
- package/dist/testWorker.d.ts.map +1 -0
- package/dist/testWorker.js +9 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/dispatchRoute.js +1 -1
- package/package.json +1 -1
- package/src/audit/auditEvent.ts +1 -1
- package/src/audit/chain.ts +551 -0
- package/src/audit/citations.ts +63 -0
- package/src/audit/composed.ts +30 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +166 -18
- package/src/capability/erasure.ts +82 -0
- package/src/capability/personColumns.ts +83 -0
- package/src/capability/personRows.ts +125 -0
- package/src/capability/purge.ts +208 -8
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +488 -2
- package/src/cli/commandFacts.ts +12 -3
- package/src/cloudflare-test.d.ts +3 -1
- package/src/consent/purpose.ts +160 -0
- package/src/consent/reader.ts +134 -0
- package/src/controlPlane/audit/actions.ts +15 -4
- package/src/controlPlane/capability.ts +99 -20
- package/src/controlPlane/context.ts +10 -2
- package/src/controlPlane/data/connection.ts +16 -2
- package/src/controlPlane/data/connections.ts +231 -0
- package/src/controlPlane/data/hold.ts +52 -0
- package/src/controlPlane/data/oidcTrust.ts +249 -0
- package/src/controlPlane/data/tables.ts +7 -0
- package/src/controlPlane/data/trustFirstCall.ts +37 -0
- package/src/controlPlane/discovery/adminRoute.ts +71 -9
- package/src/controlPlane/discovery/routeInput.ts +86 -0
- package/src/controlPlane/discovery/toolName.ts +23 -0
- package/src/controlPlane/erasure/attest.ts +205 -0
- package/src/controlPlane/erasure/read.ts +169 -0
- package/src/controlPlane/error/errors.ts +69 -2
- package/src/controlPlane/evidence/derive.ts +426 -0
- package/src/controlPlane/evidence/mapping.ts +118 -0
- package/src/controlPlane/evidence/pack.ts +190 -0
- package/src/controlPlane/http/connections.ts +129 -0
- package/src/controlPlane/http/guard.ts +178 -10
- package/src/controlPlane/http/handlers.ts +172 -40
- package/src/controlPlane/http/holds.ts +108 -0
- package/src/controlPlane/http/presence.ts +71 -0
- package/src/controlPlane/http/processing.ts +19 -0
- package/src/controlPlane/http/responses.ts +590 -7
- package/src/controlPlane/http/routes.ts +302 -3
- package/src/controlPlane/http/schemas.ts +126 -0
- package/src/controlPlane/http/verify.ts +19 -2
- package/src/controlPlane/http/verifyTrust.ts +176 -0
- package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
- package/src/controlPlane/migrations/0005_holds.ts +40 -0
- package/src/controlPlane/processing/derive.ts +359 -0
- package/src/controlPlane/processing/read.ts +102 -0
- package/src/controlPlane/scope/kind.ts +63 -0
- package/src/controlPlane/scope/scope.ts +65 -3
- package/src/controlPlane/wire.ts +26 -0
- package/src/createBackend.ts +31 -6
- package/src/createEntrypoint.ts +34 -2
- package/src/error/payload.ts +302 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/validation.ts +35 -3
- package/src/http/verification.ts +3 -1
- package/src/i18n/translator.ts +2 -1
- package/src/index.ts +2 -0
- package/src/mcp/annotations.ts +38 -0
- package/src/mcp/tools.ts +220 -0
- package/src/openapi/document.ts +8 -28
- package/src/openapi/operations.ts +62 -0
- package/src/presence/channel.ts +46 -0
- package/src/presence/declaration.ts +283 -0
- package/src/presence/durableObject.ts +287 -0
- package/src/presence/frames.ts +153 -0
- package/src/presence/http/guard.ts +23 -0
- package/src/presence/http/responses.ts +63 -0
- package/src/presence/http/routes.ts +165 -0
- package/src/presence/http/schemas.ts +39 -0
- package/src/presence/names.ts +45 -0
- package/src/presence/room.ts +145 -0
- package/src/presence/ticket.ts +145 -0
- package/src/processing/activity.ts +289 -0
- package/src/processing/composed.ts +33 -0
- package/src/processing/vocabulary.ts +43 -0
- package/src/queue/consumer.ts +104 -0
- package/src/retention/composed.ts +75 -0
- package/src/retention/holds.ts +175 -0
- package/src/retention/holdsPurge.ts +50 -0
- package/src/retention/policy.ts +305 -0
- package/src/session/rotated.ts +53 -0
- package/src/test-utils/connectionFixtures.ts +58 -0
- package/src/test-utils/interleave.ts +31 -0
- package/src/testWorker.ts +13 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import { noopEmit } from "../../audit/recorder";
|
|
6
|
+
import type { Capability, PithyHonoEnv } from "../../capability/capability";
|
|
7
|
+
import { causeMessage } from "../../error/cause";
|
|
8
|
+
import type { RouteGroup } from "../../openapi/document";
|
|
9
|
+
import { describedOperations } from "../../openapi/operations";
|
|
10
|
+
import { privacyAttestation } from "../../privacy/posture";
|
|
11
|
+
import type { PrivacyInventory } from "../../privacy/report";
|
|
12
|
+
import type { RetirementVerdict } from "../../privacy/retirement";
|
|
13
|
+
import { workerVersionMetadata } from "../../worker/identity";
|
|
14
|
+
import type { ControlPlaneContext } from "../context";
|
|
15
|
+
import { ControlPlaneConnection } from "../data/connection";
|
|
16
|
+
import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase } from "../data/tables";
|
|
17
|
+
import { type CapabilityHealthSource, readCapabilityHealth } from "../discovery/health";
|
|
18
|
+
import type { ControlPlaneEvidenceResponse } from "../http/responses";
|
|
19
|
+
import { type ComposedMigrationDatabase, readDatabaseMigrations } from "../migrationState/migrationState";
|
|
20
|
+
import { MIGRATIONS_READ_SCOPE } from "../scope/scope";
|
|
21
|
+
import {
|
|
22
|
+
type ConnectionRecord,
|
|
23
|
+
type Derived,
|
|
24
|
+
deriveAuditLogging,
|
|
25
|
+
deriveChangeHistory,
|
|
26
|
+
deriveCsrf,
|
|
27
|
+
deriveDataProtection,
|
|
28
|
+
deriveKeyRotation,
|
|
29
|
+
deriveManagementAccess,
|
|
30
|
+
deriveRouteVerification,
|
|
31
|
+
evidenceEntry,
|
|
32
|
+
type SecretsRotationInput,
|
|
33
|
+
} from "./derive";
|
|
34
|
+
import { EVIDENCE_CONTROL_IDS, EVIDENCE_MAPPING_VERSION, type EvidenceControlId, SECRETS_CAPABILITY } from "./mapping";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The reads behind the evidence pack (#821): what the Worker composes, records and declares, gathered once per
|
|
38
|
+
* call and handed to the pure derivations in `./derive.ts`.
|
|
39
|
+
*
|
|
40
|
+
* **Every read reports what it found or that it could not look, and never throws past here.** One unreadable
|
|
41
|
+
* table costs the controls that read it their answer — `not-derivable`, with the reason — and the pack still
|
|
42
|
+
* answers. A cause goes to the Worker's log, for the operator, and never into the pack: a D1 error can name a
|
|
43
|
+
* table or a query.
|
|
44
|
+
*
|
|
45
|
+
* **The pack never widens a scope.** Migration state is read only when the connection holds `migrations:read`,
|
|
46
|
+
* and a capability's health only for the keys this connection is granted, exactly as the manifest reads them.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** What the pack reads from the composed seam. Thunks, because the `compose` hook fills them after routes mount. */
|
|
50
|
+
export interface EvidenceSources {
|
|
51
|
+
/** Every capability this Worker composed. */
|
|
52
|
+
composition: () => readonly Capability[];
|
|
53
|
+
/** Every capability's health contribution, by name. */
|
|
54
|
+
composedHealth: () => ReadonlyMap<string, CapabilityHealthSource>;
|
|
55
|
+
/** Every database this Worker binds, with what this build declares for it. */
|
|
56
|
+
composedMigrations: () => readonly ComposedMigrationDatabase[];
|
|
57
|
+
/** The privacy inventory, walked once per isolate. */
|
|
58
|
+
privacyInventory: () => PrivacyInventory;
|
|
59
|
+
/** The field key's retirement guard. Never throws. */
|
|
60
|
+
retirement: (env: Record<string, unknown>) => Promise<RetirementVerdict>;
|
|
61
|
+
/** The route table `createBackend` recorded, or `undefined` for an app it did not assemble. */
|
|
62
|
+
routeGroups: () => readonly RouteGroup[] | undefined;
|
|
63
|
+
/** The app database for this request. */
|
|
64
|
+
database: (c: Context<PithyHonoEnv>) => ControlPlaneDatabase;
|
|
65
|
+
/** The clock. */
|
|
66
|
+
now: () => Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every connection registered for this environment, or null where the table could not be read. */
|
|
70
|
+
async function readConnections(
|
|
71
|
+
c: Context<PithyHonoEnv>,
|
|
72
|
+
sources: EvidenceSources,
|
|
73
|
+
environment: string,
|
|
74
|
+
): Promise<ConnectionRecord[] | null> {
|
|
75
|
+
try {
|
|
76
|
+
const rows = await sources
|
|
77
|
+
.database(c)
|
|
78
|
+
.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
79
|
+
.selectAll()
|
|
80
|
+
.where("environment", "=", environment)
|
|
81
|
+
.orderBy("createdAt")
|
|
82
|
+
.execute();
|
|
83
|
+
return rows.map((row) => {
|
|
84
|
+
// Only the fields the pack reports and the key windows it ages. The public halves stay in the row.
|
|
85
|
+
const connection = ControlPlaneConnection.parse(row);
|
|
86
|
+
return {
|
|
87
|
+
id: connection.id,
|
|
88
|
+
authorizedBy: connection.authorizedBy,
|
|
89
|
+
actorKind: connection.actorKind,
|
|
90
|
+
purpose: connection.purpose,
|
|
91
|
+
scopes: connection.scopes,
|
|
92
|
+
keys: connection.keys.map(({ validFrom, validUntil, revokedAt }) => ({ validFrom, validUntil, revokedAt })),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
c.var.log.warn("evidence: connections unreadable", { reason: causeMessage(cause) ?? String(cause) });
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The composed audit recorder and whether each binding it writes through is present, or null for none. */
|
|
102
|
+
function auditRecorder(capabilities: readonly Capability[], env: Record<string, unknown>) {
|
|
103
|
+
const recorder = capabilities.find((capability) => capability.auditRecorder !== undefined);
|
|
104
|
+
if (!recorder) return null;
|
|
105
|
+
const bindings = [...new Set(Object.values(recorder.databases ?? {}).map((database) => database.binding))];
|
|
106
|
+
return {
|
|
107
|
+
capability: recorder.name,
|
|
108
|
+
bindings: bindings.map((binding) => ({ binding, present: env[binding] != null })),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Secrets' rotation status for this caller, read exactly as the manifest reads it. */
|
|
113
|
+
async function secretsRotation(
|
|
114
|
+
c: Context<PithyHonoEnv>,
|
|
115
|
+
sources: EvidenceSources,
|
|
116
|
+
context: ControlPlaneContext,
|
|
117
|
+
): Promise<SecretsRotationInput> {
|
|
118
|
+
if (!sources.composition().some((capability) => capability.name === SECRETS_CAPABILITY)) {
|
|
119
|
+
return { state: "not-composed" };
|
|
120
|
+
}
|
|
121
|
+
const source = sources.composedHealth().get(SECRETS_CAPABILITY);
|
|
122
|
+
const report = await readCapabilityHealth(source, context.grantedScopes, (found) => found.read(c));
|
|
123
|
+
return { state: "composed", keys: source?.keys ?? [], report };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The privacy inventory, or null where the walk threw. */
|
|
127
|
+
function inventoryOf(c: Context<PithyHonoEnv>, sources: EvidenceSources): PrivacyInventory | null {
|
|
128
|
+
try {
|
|
129
|
+
return sources.privacyInventory();
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
c.var.log.warn("evidence: privacy inventory unreadable", { reason: causeMessage(cause) ?? String(cause) });
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read everything the pack derives from, and derive it. */
|
|
137
|
+
export async function readEvidencePack(
|
|
138
|
+
c: Context<PithyHonoEnv>,
|
|
139
|
+
sources: EvidenceSources,
|
|
140
|
+
context: ControlPlaneContext,
|
|
141
|
+
): Promise<ControlPlaneEvidenceResponse> {
|
|
142
|
+
const env = c.env as Record<string, unknown>;
|
|
143
|
+
const now = sources.now();
|
|
144
|
+
const groups = sources.routeGroups();
|
|
145
|
+
const operations = groups === undefined ? undefined : describedOperations(groups);
|
|
146
|
+
const migrationsGranted = context.grantedScopes.includes(MIGRATIONS_READ_SCOPE);
|
|
147
|
+
|
|
148
|
+
const [connections, secrets, retirement, databases] = await Promise.all([
|
|
149
|
+
readConnections(c, sources, context.environment),
|
|
150
|
+
secretsRotation(c, sources, context),
|
|
151
|
+
sources.retirement(env),
|
|
152
|
+
migrationsGranted
|
|
153
|
+
? Promise.all(
|
|
154
|
+
sources.composedMigrations().map((entry) =>
|
|
155
|
+
readDatabaseMigrations(env, entry, (failed, cause) =>
|
|
156
|
+
c.var.log.warn("evidence: migration ledger unreadable", {
|
|
157
|
+
database: failed.database,
|
|
158
|
+
binding: failed.binding,
|
|
159
|
+
reason: causeMessage(cause) ?? String(cause),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
: Promise.resolve([]),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const derived: Record<EvidenceControlId, Derived> = {
|
|
168
|
+
"audit-logging": deriveAuditLogging({
|
|
169
|
+
recorder: auditRecorder(sources.composition(), env),
|
|
170
|
+
emitting: c.var.emit !== noopEmit,
|
|
171
|
+
}),
|
|
172
|
+
"route-verification": deriveRouteVerification(operations),
|
|
173
|
+
"management-access": deriveManagementAccess(connections),
|
|
174
|
+
"key-rotation": deriveKeyRotation({ connections, now, secrets, retirement }),
|
|
175
|
+
"change-history": deriveChangeHistory({ granted: migrationsGranted, databases }),
|
|
176
|
+
"data-protection": deriveDataProtection({
|
|
177
|
+
inventory: inventoryOf(c, sources),
|
|
178
|
+
attestation: privacyAttestation(env),
|
|
179
|
+
}),
|
|
180
|
+
csrf: deriveCsrf({ operations, sameOriginPublished: c.var.sameOrigin !== null }),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
environment: context.environment,
|
|
185
|
+
generatedAt: now.toISOString(),
|
|
186
|
+
version: workerVersionMetadata(c.env).id,
|
|
187
|
+
mappingVersion: EVIDENCE_MAPPING_VERSION,
|
|
188
|
+
controls: EVIDENCE_CONTROL_IDS.map((control) => evidenceEntry(control, derived[control])),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
|
+
import { personDecision } from "../../cli/command";
|
|
7
|
+
import { ConflictError, NotFoundError } from "../../error/pithyError";
|
|
8
|
+
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
9
|
+
import type { ControlPlaneConnection } from "../data/connection";
|
|
10
|
+
import {
|
|
11
|
+
connectionCredentialKind,
|
|
12
|
+
connectionLive,
|
|
13
|
+
connectionRevokedAt,
|
|
14
|
+
listConnections,
|
|
15
|
+
revokeConnection,
|
|
16
|
+
} from "../data/connections";
|
|
17
|
+
import { type ControlPlaneHandlerDeps, caller } from "./handlers";
|
|
18
|
+
import type { ConnectionView, ControlPlaneConnectionsResponse, RevokeConnectionResponse } from "./responses";
|
|
19
|
+
import type { ConnectionListQuery, ConnectionParams, RevokeConnectionRequest } from "./schemas";
|
|
20
|
+
|
|
21
|
+
/** A connection as the listing shows it at `now`: who, what for, which credentials, never key material. */
|
|
22
|
+
export function connectionView(connection: ControlPlaneConnection, now: Date): ConnectionView {
|
|
23
|
+
return {
|
|
24
|
+
id: connection.id,
|
|
25
|
+
environment: connection.environment,
|
|
26
|
+
actorKind: connection.actorKind,
|
|
27
|
+
purpose: connection.purpose,
|
|
28
|
+
authorizedBy: connection.authorizedBy,
|
|
29
|
+
credentialKind: connectionCredentialKind(connection),
|
|
30
|
+
scopes: [...connection.scopes],
|
|
31
|
+
trusts: (connection.trusts ?? []).map((trust) => ({
|
|
32
|
+
trustId: trust.trustId,
|
|
33
|
+
issuer: trust.issuer,
|
|
34
|
+
claims: { ...trust.claims },
|
|
35
|
+
revokedAt: trust.revokedAt?.toISOString() ?? null,
|
|
36
|
+
})),
|
|
37
|
+
live: connectionLive(connection, now),
|
|
38
|
+
registeredAt: connection.createdAt.toISOString(),
|
|
39
|
+
revokedAt: connectionRevokedAt(connection, now)?.toISOString() ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `GET /control-plane/connections` — every connection bound to this Worker's environment, the caller's own among
|
|
45
|
+
* them. A database shared with another environment holds that environment's rows too, and they are never listed.
|
|
46
|
+
* Read-only.
|
|
47
|
+
*/
|
|
48
|
+
export function listConnectionsHandler(deps: ControlPlaneHandlerDeps) {
|
|
49
|
+
return async (c: Context<PithyHonoEnv>, query: ConnectionListQuery) => {
|
|
50
|
+
const context = caller(c);
|
|
51
|
+
const now = deps.now();
|
|
52
|
+
const listing = await listConnections(deps.database(c), {
|
|
53
|
+
principal: query.principal,
|
|
54
|
+
environment: context.environment,
|
|
55
|
+
});
|
|
56
|
+
return c.json({
|
|
57
|
+
connections: listing.connections.map((connection) => connectionView(connection, now)),
|
|
58
|
+
unattributed: listing.unattributed.map((connection) => connectionView(connection, now)),
|
|
59
|
+
} satisfies ControlPlaneConnectionsResponse);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The refusal of a connection asking to revoke itself. The action names the environment it is bound to, since
|
|
65
|
+
* `dashboard disconnect` would otherwise end the connection of the environment its `--env` defaults to.
|
|
66
|
+
*/
|
|
67
|
+
export function selfRevokeRefusal(connectionId: string, environment: string): ConflictError {
|
|
68
|
+
return new ConflictError({
|
|
69
|
+
message: "A connection cannot revoke itself here.",
|
|
70
|
+
action: `End this connection from the CLI instead. ${personDecision("dashboard disconnect", { flags: { "--env": environment } }, { runtime: true })}`,
|
|
71
|
+
detail: `connection ${connectionId} asked to revoke itself`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `POST /control-plane/connections/:connectionId/revoke` — revoke every key and trust another connection in this
|
|
77
|
+
* Worker's environment holds that is not yet revoked. A connection bound to another environment is answered as
|
|
78
|
+
* though it did not exist.
|
|
79
|
+
*
|
|
80
|
+
* Refused on the caller's own connection: a client ending its own access does it through `disconnect`, which also
|
|
81
|
+
* removes what it holds locally.
|
|
82
|
+
*/
|
|
83
|
+
export function revokeConnectionHandler(deps: ControlPlaneHandlerDeps) {
|
|
84
|
+
return async (c: Context<PithyHonoEnv>, params: ConnectionParams, body: RevokeConnectionRequest) => {
|
|
85
|
+
const context = caller(c);
|
|
86
|
+
if (params.connectionId === context.connectionId)
|
|
87
|
+
throw selfRevokeRefusal(context.connectionId, context.environment);
|
|
88
|
+
const db = deps.database(c);
|
|
89
|
+
const now = deps.now();
|
|
90
|
+
const revoked = await revokeConnection(db, params.connectionId, now, { environment: context.environment });
|
|
91
|
+
if (revoked === null) {
|
|
92
|
+
throw new NotFoundError({
|
|
93
|
+
message: "No connection in this environment has that id.",
|
|
94
|
+
detail: `connection ${params.connectionId} not found in ${context.environment}`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const target = revoked.connection;
|
|
98
|
+
await safeEmit(
|
|
99
|
+
c.var.emit,
|
|
100
|
+
{
|
|
101
|
+
action: ControlPlaneAuditActions.connectionRevoked,
|
|
102
|
+
outcome: "success",
|
|
103
|
+
severity: "warning",
|
|
104
|
+
actorType: "control-plane",
|
|
105
|
+
actorId: context.subject,
|
|
106
|
+
resourceType: "controlplane_connection",
|
|
107
|
+
resourceId: target.id,
|
|
108
|
+
requestId: c.req.header("cf-ray"),
|
|
109
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
110
|
+
userAgent: c.req.header("user-agent"),
|
|
111
|
+
metadata: {
|
|
112
|
+
callerConnectionId: context.connectionId,
|
|
113
|
+
connectionId: target.id,
|
|
114
|
+
connectionEnvironment: target.environment,
|
|
115
|
+
authorizedBy: target.authorizedBy,
|
|
116
|
+
reason: body.reason,
|
|
117
|
+
revokedKeyIds: [...revoked.revokedKeyIds],
|
|
118
|
+
revokedTrustIds: [...revoked.revokedTrustIds],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
c.var.log,
|
|
122
|
+
);
|
|
123
|
+
return c.json({
|
|
124
|
+
connection: connectionView(target, now),
|
|
125
|
+
revokedKeyIds: [...revoked.revokedKeyIds],
|
|
126
|
+
revokedTrustIds: [...revoked.revokedTrustIds],
|
|
127
|
+
} satisfies RevokeConnectionResponse);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import type { MiddlewareHandler } from "hono";
|
|
5
|
-
import type
|
|
5
|
+
import { type AuditEmit, noopEmit } from "../../audit/recorder";
|
|
6
6
|
import type { PithyHonoEnv } from "../../capability/capability";
|
|
7
7
|
import { commandName, remedy } from "../../cli/command";
|
|
8
8
|
import { type AmbientEnv, compositionEnvironment } from "../../env/ambient";
|
|
@@ -11,10 +11,22 @@ import { workerVersionMetadata } from "../../worker/identity";
|
|
|
11
11
|
import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
|
|
12
12
|
import type { ControlPlaneContext } from "../context";
|
|
13
13
|
import type { ControlPlaneActorKind } from "../data/connection";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ControlPlaneInsufficientScopeError,
|
|
16
|
+
ControlPlaneInvalidCredentialError,
|
|
17
|
+
ControlPlaneNotConnectedError,
|
|
18
|
+
} from "../error/errors";
|
|
15
19
|
import type { ControlPlaneRequirement } from "../scope/scope";
|
|
16
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CONTROL_PLANE_HEADER,
|
|
22
|
+
CONTROL_PLANE_OIDC_HEADER,
|
|
23
|
+
CONTROL_PLANE_SCOPE_HEADER,
|
|
24
|
+
CONTROL_PLANE_TRUST_HEADER,
|
|
25
|
+
CONTROL_PLANE_VERSION_CREATED_HEADER,
|
|
26
|
+
CONTROL_PLANE_VERSION_HEADER,
|
|
27
|
+
} from "../wire";
|
|
17
28
|
import { type ControlPlaneVerifyDeps, verifyControlPlaneCall } from "./verify";
|
|
29
|
+
import { type ControlPlaneTrustVerifyDeps, verifyControlPlaneTrustCall } from "./verifyTrust";
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* `requireControlPlane(scope)` — the gate every control-plane route wears, including the ones
|
|
@@ -57,8 +69,39 @@ export type ControlPlaneVerifier = (
|
|
|
57
69
|
* Build the verifier the seam's middleware publishes. Split out from the middleware so the wiring is
|
|
58
70
|
* testable without a Worker, and so `verify.ts` stays free of Hono.
|
|
59
71
|
*/
|
|
60
|
-
export function createControlPlaneVerifier(
|
|
72
|
+
export function createControlPlaneVerifier(
|
|
73
|
+
deps: ControlPlaneVerifyDeps,
|
|
74
|
+
/**
|
|
75
|
+
* The trust path's own dependencies (#722). Absent, a call presenting an OIDC token is refused like any other
|
|
76
|
+
* credential this Worker cannot verify — the seam fails closed rather than waving an unverifiable token through.
|
|
77
|
+
*/
|
|
78
|
+
trusts?: ControlPlaneTrustVerifyDeps,
|
|
79
|
+
): ControlPlaneVerifier {
|
|
61
80
|
return async (request, requirement) => {
|
|
81
|
+
// A call presents one credential. The OIDC header selects the trust path; both at once is refused rather
|
|
82
|
+
// than resolved, because "which of these did the caller mean" is not a question a verifier should answer.
|
|
83
|
+
const oidc = request.headers.get(CONTROL_PLANE_OIDC_HEADER);
|
|
84
|
+
if (oidc !== null) {
|
|
85
|
+
if (request.headers.get(CONTROL_PLANE_HEADER) !== null) {
|
|
86
|
+
throw new ControlPlaneInvalidCredentialError({
|
|
87
|
+
detail: "a call presented both a signed token and an OIDC token",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (!trusts) {
|
|
91
|
+
throw new ControlPlaneInvalidCredentialError({
|
|
92
|
+
detail: "an OIDC token was presented and this Worker verifies none",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return await verifyControlPlaneTrustCall(
|
|
96
|
+
{
|
|
97
|
+
token: oidc,
|
|
98
|
+
trustId: request.headers.get(CONTROL_PLANE_TRUST_HEADER) ?? undefined,
|
|
99
|
+
scope: request.headers.get(CONTROL_PLANE_SCOPE_HEADER) ?? undefined,
|
|
100
|
+
requirement,
|
|
101
|
+
},
|
|
102
|
+
trusts,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
62
105
|
// Clone before reading. The handler still has to parse this body through `validate`, and a stream
|
|
63
106
|
// consumed here would reach it empty — the digest check would pass and the request would then fail
|
|
64
107
|
// validation for no visible reason.
|
|
@@ -120,7 +163,26 @@ export function isControlPlaneGuard(handler: unknown): boolean {
|
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
export function requireControlPlane(requirement: ControlPlaneRequirement): MiddlewareHandler<PithyHonoEnv> {
|
|
166
|
+
return requireControlPlaneFor(async () => requirement);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The scope a call needs, resolved from the call itself. Never throws: whatever it cannot read resolves to
|
|
171
|
+
* a requirement, and `ANY_VERIFIED_CALLER` is the least it may resolve to — never no gate at all.
|
|
172
|
+
*/
|
|
173
|
+
export type ControlPlaneRequirementResolver = (
|
|
174
|
+
c: Parameters<MiddlewareHandler<PithyHonoEnv>>[0],
|
|
175
|
+
) => Promise<ControlPlaneRequirement>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* {@link requireControlPlane} for a route whose scope is named by the request, which is the presence ticket
|
|
179
|
+
* route's case (#815): the room kind in its body decides the scope. Resolving it **inside the gate, before
|
|
180
|
+
* verifying**, is what keeps a refused call to the one denial row every other route writes — a handler that
|
|
181
|
+
* checked the scope after the gate let the call through would leave an allowed row ahead of its denial.
|
|
182
|
+
*/
|
|
183
|
+
export function requireControlPlaneFor(resolve: ControlPlaneRequirementResolver): MiddlewareHandler<PithyHonoEnv> {
|
|
123
184
|
const guard: MiddlewareHandler<PithyHonoEnv> = async (c, next) => {
|
|
185
|
+
const requirement = await resolve(c);
|
|
124
186
|
// Every control-plane response carries the build that produced it, allowed or denied.
|
|
125
187
|
//
|
|
126
188
|
// **On the guard, not on the seam's own routes**, because the guard is the one thing every
|
|
@@ -180,13 +242,29 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
|
|
|
180
242
|
connectionEnvironment: context.environment,
|
|
181
243
|
scope: context.scope,
|
|
182
244
|
keyId: context.keyId,
|
|
245
|
+
// Said only for a trust, so a row written by a key reads exactly as it did before trusts existed.
|
|
246
|
+
...(context.credentialKind === "trust" ? { credentialKind: "trust" } : {}),
|
|
183
247
|
method: c.req.method,
|
|
184
248
|
path: c.req.path,
|
|
185
249
|
},
|
|
186
250
|
},
|
|
187
251
|
c.var.log,
|
|
188
252
|
);
|
|
189
|
-
|
|
253
|
+
// A handler can refuse a call the gate's requirement let through — an operator roster write that
|
|
254
|
+
// would move the owning role is refused `controlplane/insufficient_scope`, naming the ownership route,
|
|
255
|
+
// even on a connection granted `organization:ownership:transfer`, because a token exercises the one
|
|
256
|
+
// scope it was minted for — and it refuses it itself, past this gate. That refusal is still a blocked attempt, and the trail must
|
|
257
|
+
// say so: `call_allowed` alone would hide a credential probing for the grant it lacks.
|
|
258
|
+
//
|
|
259
|
+
// Both paths, as the organization routes' refusal recorder reads them: a throw that reaches here, and
|
|
260
|
+
// `c.error`, which is where Hono's `compose` leaves a downstream throw it rendered through `onError`.
|
|
261
|
+
try {
|
|
262
|
+
await next();
|
|
263
|
+
} catch (error) {
|
|
264
|
+
await auditHandlerDenial(c, requirement, context, error);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
await auditHandlerDenial(c, requirement, context, c.error);
|
|
190
268
|
};
|
|
191
269
|
(guard as unknown as Record<symbol, unknown>)[CONTROL_PLANE_GUARD] = true;
|
|
192
270
|
return guard;
|
|
@@ -199,9 +277,12 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
|
|
|
199
277
|
* Only `control-plane` events, because the kind describes a management client and nothing else — a
|
|
200
278
|
* handler recording what happened to a user is recording a user. An event that states a kind keeps it.
|
|
201
279
|
* A connection with no recorded kind adds nothing, so its rows read exactly as they did before.
|
|
280
|
+
*
|
|
281
|
+
* The no-op comes back as itself: there is nothing to stamp, and a wrapper around it would hide that nothing is
|
|
282
|
+
* recorded from every reader past this gate — the evidence pack's `controlPlaneCallsRecorded` among them (#821).
|
|
202
283
|
*/
|
|
203
284
|
export function stampActorKind(emit: AuditEmit, actorKind: ControlPlaneActorKind | null): AuditEmit {
|
|
204
|
-
if (actorKind === null) return emit;
|
|
285
|
+
if (actorKind === null || emit === noopEmit) return emit;
|
|
205
286
|
return (event) =>
|
|
206
287
|
emit(event.actorType === "control-plane" && event.actorKind == null ? { ...event, actorKind } : event);
|
|
207
288
|
}
|
|
@@ -217,6 +298,14 @@ export function stampActorKind(emit: AuditEmit, actorKind: ControlPlaneActorKind
|
|
|
217
298
|
* the exact verification step, and the audit trail is queryable and long-lived. The step belongs in the
|
|
218
299
|
* log, which is bounded and already carries the whole payload.
|
|
219
300
|
*
|
|
301
|
+
* **Attributed exactly as far as it is proven (#812).** A call that failed verification — no connection, a bad
|
|
302
|
+
* signature, an unknown or expired key — names nobody, because nothing about it is proven. A call whose credential
|
|
303
|
+
* verified and whose scope was not granted is a known connection asking for an operation it lacks, which is what
|
|
304
|
+
* scope probing looks like, so its row carries the subject as `actorId`, the connection as its resource, the key
|
|
305
|
+
* in `metadata.keyId` (and `credentialKind: "trust"` when that key is a trust), and the connection's `actorKind` — the same four the allowed row carries. Only the first presentation of a token is: the verifier
|
|
306
|
+
* spends the jti of a scope refusal and names no caller on a token already spent, because a signature proves who minted
|
|
307
|
+
* a token, not who presented it. Only the row changes; the error thrown is the same.
|
|
308
|
+
*
|
|
220
309
|
* Nothing here can turn a correct denial into a 500 — `safeEmit` swallows, and the caller rethrows the
|
|
221
310
|
* original error regardless.
|
|
222
311
|
*/
|
|
@@ -226,6 +315,24 @@ async function auditDenial(
|
|
|
226
315
|
cause: unknown,
|
|
227
316
|
): Promise<void> {
|
|
228
317
|
const code = cause instanceof PithyError ? cause.payload.code : "core/internal";
|
|
318
|
+
const proven = cause instanceof ControlPlaneInsufficientScopeError ? cause.caller : null;
|
|
319
|
+
// A claim mismatch on an OIDC token whose signature, issuer and audience verified (#722): the claims it carried
|
|
320
|
+
// are recorded, which is how an operator picks a matcher for a custom issuer. Nobody is named as the actor — the
|
|
321
|
+
// token is genuine, but not for any workload this Worker trusts.
|
|
322
|
+
const refusedTrust = cause instanceof ControlPlaneInvalidCredentialError ? cause.refusedTrust : null;
|
|
323
|
+
const metadata = {
|
|
324
|
+
reason: code,
|
|
325
|
+
required: String(requirement),
|
|
326
|
+
method: c.req.method,
|
|
327
|
+
path: c.req.path,
|
|
328
|
+
...(refusedTrust
|
|
329
|
+
? {
|
|
330
|
+
connectionId: refusedTrust.connectionId,
|
|
331
|
+
trustId: refusedTrust.trustId,
|
|
332
|
+
observedClaims: { ...refusedTrust.observedClaims },
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
335
|
+
};
|
|
229
336
|
await safeEmit(
|
|
230
337
|
c.var.emit,
|
|
231
338
|
{
|
|
@@ -233,13 +340,74 @@ async function auditDenial(
|
|
|
233
340
|
outcome: "denied",
|
|
234
341
|
severity: "warning",
|
|
235
342
|
actorType: "control-plane",
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
343
|
+
// The connection's own kind, as `stampActorKind` adds it to the allowed row, so a trail filtered on kind holds a
|
|
344
|
+
// client's refusals beside its calls. Absent when unproven, and absent for a connection with no recorded kind.
|
|
345
|
+
...(proven?.actorKind ? { actorKind: proven.actorKind } : {}),
|
|
346
|
+
// Null unless proven. The `sub` claim of a call that failed verification is unproven, and writing an
|
|
347
|
+
// unverified identity into the trail as the actor is how a forged token gets to name someone. A scope refusal
|
|
348
|
+
// came after the signature verified, so its subject is the token's real one.
|
|
349
|
+
actorId: proven?.subject ?? null,
|
|
350
|
+
...(proven ? { resourceType: "controlplane_connection", resourceId: proven.connectionId } : {}),
|
|
239
351
|
requestId: c.req.header("cf-ray"),
|
|
240
352
|
ip: c.req.header("cf-connecting-ip"),
|
|
241
353
|
userAgent: c.req.header("user-agent"),
|
|
242
|
-
metadata:
|
|
354
|
+
metadata: proven
|
|
355
|
+
? {
|
|
356
|
+
...metadata,
|
|
357
|
+
keyId: proven.keyId,
|
|
358
|
+
...(proven.credentialKind === "trust" ? { credentialKind: "trust" } : {}),
|
|
359
|
+
}
|
|
360
|
+
: metadata,
|
|
361
|
+
},
|
|
362
|
+
c.var.log,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Record a scope refusal a handler raised after the gate let the call in.
|
|
368
|
+
*
|
|
369
|
+
* Only `controlplane/insufficient_scope`: every other refusal past the gate is the handler's own to
|
|
370
|
+
* record, and a 404 or a validation error is not a credential reaching beyond its grant. The same row as
|
|
371
|
+
* {@link auditDenial}, with two differences that follow from the call having been verified: the actor is
|
|
372
|
+
* the proven subject and its connection, and `refusedBy` says the gate passed it — `required` is the
|
|
373
|
+
* gate's requirement, which this call met; what it lacked is in the log, with the rest of `detail`.
|
|
374
|
+
* `scope`, `keyId` and `credentialKind` name the refused credential exactly as the gate's `call_allowed` row does, so a
|
|
375
|
+
* read of one key's reach (#828) finds its refusals too, even on a request with no `cf-ray` to join by.
|
|
376
|
+
*/
|
|
377
|
+
async function auditHandlerDenial(
|
|
378
|
+
c: Parameters<MiddlewareHandler<PithyHonoEnv>>[0],
|
|
379
|
+
requirement: ControlPlaneRequirement,
|
|
380
|
+
context: ControlPlaneContext,
|
|
381
|
+
cause: unknown,
|
|
382
|
+
): Promise<void> {
|
|
383
|
+
// Read by its code, not `instanceof`: a capability that throws it may hold its own copy of this module,
|
|
384
|
+
// and a refusal that slipped past a class check would be exactly the unaudited attempt this exists for.
|
|
385
|
+
const code = (cause as { payload?: { code?: unknown } } | null)?.payload?.code;
|
|
386
|
+
if (code !== "controlplane/insufficient_scope") return;
|
|
387
|
+
await safeEmit(
|
|
388
|
+
c.var.emit,
|
|
389
|
+
{
|
|
390
|
+
action: ControlPlaneAuditActions.callDenied,
|
|
391
|
+
outcome: "denied",
|
|
392
|
+
severity: "warning",
|
|
393
|
+
actorType: "control-plane",
|
|
394
|
+
actorId: context.subject,
|
|
395
|
+
resourceType: "controlplane_connection",
|
|
396
|
+
resourceId: context.connectionId,
|
|
397
|
+
requestId: c.req.header("cf-ray"),
|
|
398
|
+
ip: c.req.header("cf-connecting-ip"),
|
|
399
|
+
userAgent: c.req.header("user-agent"),
|
|
400
|
+
metadata: {
|
|
401
|
+
reason: code,
|
|
402
|
+
required: String(requirement),
|
|
403
|
+
refusedBy: "handler",
|
|
404
|
+
connectionId: context.connectionId,
|
|
405
|
+
scope: context.scope,
|
|
406
|
+
keyId: context.keyId,
|
|
407
|
+
...(context.credentialKind === "trust" ? { credentialKind: "trust" } : {}),
|
|
408
|
+
method: c.req.method,
|
|
409
|
+
path: c.req.path,
|
|
410
|
+
},
|
|
243
411
|
},
|
|
244
412
|
c.var.log,
|
|
245
413
|
);
|