From 805a6483a1afd64f36fc950e0c131b556babb8ae Mon Sep 17 00:00:00 2001 From: Freshair129 <94353529+Freshair129@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:06:57 +0700 Subject: [PATCH] feat: governed callers - MSP becomes one implementation of the caller role ADR-GKS-GOVERNED-CALLERS was accepted by the owner on 2026-09-27, with all four open questions settled as proposed. A system that already has its own auth and memory can now write to GKS without deploying MSP. - Grants v2 adds a "governed" profile to GKS_CLIENT_GRANTS_PATH. Such a grant carries clientId, a hash-only credential, the legacy knowledge tools it may call, an exclusive list of portfolioIds, and a unique provenanceNamespace. v1 documents still parse as read grants. Startup fails on overlapping portfolios, a namespace claimed twice, the reserved "msp" namespace or msp-runtime id, and pipeline tools. - Governed calls arrive over HTTP with a Bearer credential and a _meta.gksCallerAuth envelope (version, callerId, scopeDigest). authorizeGovernedCallerRequest enforces the tool list, the portfolio boundary, the scope digest and the caller's own :proof/ refs. - Provenance checks are caller-aware. The tool schemas widen to ^[a-z][a-z0-9-]{0,30}:proof/, which still accepts every value accepted before. MSP stays msp:proof/, and its messages are unchanged. - MSP remains the built-in governed caller, and GKS_MSP_CALLER now lives in contracts. MSP is denied the portfolios a governed grant owns. Pipeline tools stay MSP-relay only. - Migration 0008 records the authenticated caller_id on promotions, stage_evidence, human_resolutions and artifact_links. Existing rows read as msp-runtime. The persistence port takes an optional callerId, and caller_id is not returned in responses. The C0.4 corpus replays unchanged except for TOOL-001, whose tools/list pattern widened. The baseline is re-locked for the tool registry hash and migration 0008. Docs amended: ADR-GKS-BOUNDARY, ADR-GKS-CLIENT-ACCESS, GKS-PORT-CONTRACT, README, the production runbook (provisioning a governed caller), and the call-direction rule in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 11 +- apps/gks-server/src/http-server.mjs | 18 +- apps/gks-server/src/server.mjs | 50 ++-- docs/ADR-GKS-BOUNDARY.md | 21 +- docs/ADR-GKS-CLIENT-ACCESS.md | 15 +- docs/ADR-GKS-GOVERNED-CALLERS.md | 232 ++++++++++++++++++ docs/GKS-PORT-CONTRACT.md | 28 ++- docs/GKS-PRODUCTION-RUNBOOK.md | 31 ++- migrations/0008_caller_attribution.sql | 11 + packages/gks-contracts/src/client-auth.mjs | 115 ++++++++- .../gks-contracts/src/tool-definitions.mjs | 4 +- packages/gks-contracts/src/validation.mjs | 17 +- packages/gks-core/src/index.mjs | 23 +- packages/gks-persistence/src/index.mjs | 35 ++- tests/contract/governed-callers.test.mjs | 220 +++++++++++++++++ tests/fixtures/baseline-lock.json | 8 +- .../expected/C0.4-TOOL-001.json | 4 +- tests/fixtures/c0-qualification/registry.json | 2 +- .../c0-qualification/result-manifest.json | 4 +- 20 files changed, 769 insertions(+), 86 deletions(-) create mode 100644 docs/ADR-GKS-GOVERNED-CALLERS.md create mode 100644 migrations/0008_caller_attribution.sql create mode 100644 tests/contract/governed-callers.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index b55ce4d..091b188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,10 @@ Remote: `origin` → https://github.com/Freshair129/Genesis-Knowledge-System (private). Local path in cross-repo references is `D:\gks`. **Call direction (do not invert):** `Zuri / GoVibe -> MSP -> GKS`. GKS never -calls outward to GenesisBlockDB, GoVibe, or MSP — MSP (`D:\msp`) is GKS's -sole caller. GenesisBlockDB is a separate graph/vector engine, not GKS's +calls outward to GenesisBlockDB, GoVibe, or MSP. MSP (`D:\msp`) is the +built-in governed caller. Another system may call GKS only as a provisioned +governed caller (`docs/ADR-GKS-GOVERNED-CALLERS.md`): its own portfolios, its +own provenance namespace, HTTP only, and no pipeline tools. GenesisBlockDB is a separate graph/vector engine, not GKS's assumed persistence backend. ## Toolchain diff --git a/README.md b/README.md index ace2e6b..f88b32d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ --- -version: "0.5.0b" +version: "0.6.0b" created_at: "2026-08-12T10:29:29+07:00,ATHER,working-tree" -last_update: "2026-09-24T10:06:32+07:00,RWANG" +last_update: "2026-09-27T22:00:00+07:00,Claude" status: "beta" superseded_by: null attributes: @@ -23,7 +23,11 @@ runtime path is: Zuri / GoVibe -> MSP -> GKS ``` -Zuri and GoVibe call MSP. MSP is the sole caller of GKS in this path. +Zuri and GoVibe call MSP, and MSP calls GKS. MSP is the built-in **governed +caller**. A system that already has its own auth and memory can call GKS +directly, without MSP, if it is provisioned as a governed caller for its own +portfolios under its own provenance namespace +(`docs/ADR-GKS-GOVERNED-CALLERS.md`). In every case GKS never calls outward. ## Knowledge Graph Service boundary @@ -168,6 +172,7 @@ verification, not production deployment or Zuri cutover evidence. | Version | Date | Status | Summary | Commit Hash | Agent | |---|---|---|---|---|---| +| 0.6.0b | 2026-09-27 | beta | Governed callers (ADR-GKS-GOVERNED-CALLERS): MSP is the built-in governed caller, and other systems can be provisioned for their own portfolios. | working-tree | Claude | | 0.5.0b | 2026-09-24 | beta | Implements optional hash-backed, scoped direct read-only clients over private HTTP; no default grants or production activation. | working-tree | RWANG | | 0.4.0b | 2026-09-24 | beta | Records the approved direct read-only client profile while keeping MSP as the governed write/pipeline/receipt path; direct auth is not yet implemented. | working-tree | RWANG | | 0.3.0b | 2026-09-24 | beta | Defines GKS as the Knowledge Graph Service while preserving MSP-only governed ingress and separating GKS from a physical graph database or broader Semantic Layer. | working-tree | RWANG | diff --git a/apps/gks-server/src/http-server.mjs b/apps/gks-server/src/http-server.mjs index 17051bd..40aa834 100644 --- a/apps/gks-server/src/http-server.mjs +++ b/apps/gks-server/src/http-server.mjs @@ -97,11 +97,11 @@ function sameSecret(left, right) { function withHttpAuthentication(request, httpRequest, runtime) { const toolName = request.params?.name; if (request.method !== "tools/call" || LIVENESS_TOOLS.has(toolName)) { - return { request, directClientGrant: null }; + return { request, clientGrant: null }; } const supplied = bearerCredential(httpRequest); if (runtime.mspRelayCredential && sameSecret(supplied, runtime.mspRelayCredential)) { - if (!request.params || typeof request.params !== "object") return { request, directClientGrant: null }; + if (!request.params || typeof request.params !== "object") return { request, clientGrant: null }; const metadata = request.params._meta && typeof request.params._meta === "object" ? request.params._meta : {}; const auth = metadata.gksMspAuth && typeof metadata.gksMspAuth === "object" ? metadata.gksMspAuth : {}; return { @@ -115,12 +115,12 @@ function withHttpAuthentication(request, httpRequest, runtime) { }, }, }, - directClientGrant: null, + clientGrant: null, }; } - const directClientGrant = findGksClientGrant(supplied, runtime.directClientGrants); - if (!directClientGrant) throw new HttpTransportError("gks_scope_denied", "Bearer authentication is invalid.", 401); - return { request, directClientGrant }; + const clientGrant = findGksClientGrant(supplied, runtime.clientGrants); + if (!clientGrant) throw new HttpTransportError("gks_scope_denied", "Bearer authentication is invalid.", 401); + return { request, clientGrant }; } async function handleMcpRequest(request, response, runtime, state) { @@ -156,18 +156,18 @@ async function handleMcpRequest(request, response, runtime, state) { return; } let authenticated; - let directClientGrant; + let clientGrant; try { const auth = withHttpAuthentication(parsed, request, runtime); authenticated = auth.request; - directClientGrant = auth.directClientGrant; + clientGrant = auth.clientGrant; } catch (error) { writeJson(response, error.statusCode ?? 401, createJsonRpcToolErrorResponse(parsed.id, error)); return; } state.inFlight += 1; try { - const result = await dispatchJsonRpcRequest(authenticated, { runtime, directClientGrant }); + const result = await dispatchJsonRpcRequest(authenticated, { runtime, clientGrant }); if (result) writeJson(response, 200, result); else writeNoContent(response); } finally { diff --git a/apps/gks-server/src/server.mjs b/apps/gks-server/src/server.mjs index fdcb9e0..fb9fe4c 100644 --- a/apps/gks-server/src/server.mjs +++ b/apps/gks-server/src/server.mjs @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import path from "node:path"; import { TextDecoder } from "node:util"; -import { GKS_TOOL_DEFINITIONS, authorizeGksClientRequest, authorizeLegacyMspRequest, automergeFloor, parseGksClientGrants, requiresLegacyMspAuth } from "@freshair129/gks-contracts"; +import { GKS_MSP_CALLER, GKS_TOOL_DEFINITIONS, GksScopeDeniedError, authorizeGksClientRequest, authorizeGovernedCallerRequest, authorizeLegacyMspRequest, automergeFloor, governedPortfolios, parseGksClientGrants, requiresLegacyMspAuth } from "@freshair129/gks-contracts"; import { createGksService } from "@freshair129/gks-core"; import { openSqlitePersistence } from "@freshair129/gks-persistence"; @@ -154,7 +154,9 @@ export function createRuntimeFromEnvironment(env = process.env) { const mspRelayCredential = env.GKS_MSP_RELAY_CREDENTIAL?.trim() || undefined; const clientGrantsPath = env.GKS_CLIENT_GRANTS_PATH?.trim(); if (clientGrantsPath && !path.isAbsolute(clientGrantsPath)) throw new Error("GKS_CLIENT_GRANTS_PATH must be an absolute path."); - let directClientGrants = []; + // Read grants and governed-caller grants (ADR-GKS-CLIENT-ACCESS, + // ADR-GKS-GOVERNED-CALLERS) share one file and one credential format. + let clientGrants = []; if (clientGrantsPath) { let contents; try { @@ -163,21 +165,21 @@ export function createRuntimeFromEnvironment(env = process.env) { throw new Error("GKS_CLIENT_GRANTS_PATH could not be read."); } try { - directClientGrants = parseGksClientGrants(contents); + clientGrants = parseGksClientGrants(contents); } catch { throw new Error("GKS_CLIENT_GRANTS_PATH contains invalid grants."); } } - if (directClientGrants.length > 0 && !requireMspAuth) { - throw new Error("GKS_MSP_AUTH_REQUIRED=1 is required when direct client grants are configured."); + if (clientGrants.length > 0 && !requireMspAuth) { + throw new Error("GKS_MSP_AUTH_REQUIRED=1 is required when client grants are configured."); } - if (requireMspAuth && !mspRelayCredential && directClientGrants.length === 0) { + if (requireMspAuth && !mspRelayCredential && clientGrants.length === 0) { throw new Error("GKS_MSP_RELAY_CREDENTIAL or a non-empty GKS_CLIENT_GRANTS_PATH is required when GKS_MSP_AUTH_REQUIRED=1."); } if (mspRelayCredential) { const mspCredentialHash = createHash("sha256").update(mspRelayCredential, "utf8").digest("hex"); - if (directClientGrants.some((grant) => grant.credentialSha256 === mspCredentialHash)) { - throw new Error("A credential cannot be registered for both MSP and direct-client access."); + if (clientGrants.some((grant) => grant.credentialSha256 === mspCredentialHash)) { + throw new Error("A credential cannot be registered for both MSP and a client grant."); } } const persistence = openSqlitePersistence({ dbPath }); @@ -186,7 +188,9 @@ export function createRuntimeFromEnvironment(env = process.env) { defaultPortfolioId: env.GKS_DEFAULT_PORTFOLIO_ID?.trim() || undefined, requireMspAuth, mspRelayCredential, - directClientGrants, + clientGrants, + // D7: portfolios a governed caller owns are denied to MSP. + governedPortfolios: governedPortfolios(clientGrants), // Decision 2: the auto-merge floor is deployment-set (GKS_AUTOMERGE_FLOOR) // and resolved HERE, at startup, from the same env the rest of the // runtime reads — an invalid value fails closed before the first promote. @@ -197,16 +201,18 @@ export function createRuntimeFromEnvironment(env = process.env) { }; } +// `caller` is the authenticated governed caller; the three governed writes use +// it for their provenance namespace and attribution. export function toolHandler(service, name) { const handlers = { gks_health: (args) => service.health(args), - gks_knowledge_promote: (args) => service.promoteCandidate(args), + gks_knowledge_promote: (args, caller) => service.promoteCandidate(args, caller), gks_search: (args) => service.search(args), gks_entity_get: (args) => service.getEntity(args), gks_relations_get: (args) => service.getRelations(args), - gks_artifact_link: (args) => service.linkArtifact(args), + gks_artifact_link: (args, caller) => service.linkArtifact(args, caller), gks_review_list: (args) => service.listUnresolvedMentions(args), - gks_review_apply: (args) => service.applyHumanResolution(args), + gks_review_apply: (args, caller) => service.applyHumanResolution(args, caller), gks_stage_evidence_export: (args) => service.exportStageEvidence(args), gks_pipeline_submit: (args) => service.pipelineSubmit(args), gks_pipeline_claim: (args) => service.pipelineClaim(args), @@ -237,7 +243,7 @@ export function createJsonRpcToolErrorResponse(id, error) { }; } -export async function dispatchJsonRpcRequest(request, { runtime, directClientGrant } = {}) { +export async function dispatchJsonRpcRequest(request, { runtime, clientGrant } = {}) { if (!runtime) throw new TypeError("runtime is required."); if (request.method === "notifications/initialized" || request.id === undefined) return null; if (request.method === "initialize") { @@ -255,17 +261,25 @@ export async function dispatchJsonRpcRequest(request, { runtime, directClientGra return createJsonRpcToolErrorResponse(request.id, { code: "gks_invalid_request", message: "Unknown GKS tool." }); } try { - if (directClientGrant) { - authorizeGksClientRequest(directClientGrant, { toolName, args: request.params?.arguments ?? {} }); + const args = request.params?.arguments ?? {}; + let caller = GKS_MSP_CALLER; + if (clientGrant?.profile === "governed") { + // ADR-GKS-GOVERNED-CALLERS: tools, portfolio boundary and provenance + // namespace all come from the server-side grant. + caller = authorizeGovernedCallerRequest(clientGrant, request.params?._meta, { toolName, args, defaultPortfolioId: runtime.defaultPortfolioId }); + } else if (clientGrant) { + authorizeGksClientRequest(clientGrant, { toolName, args }); } else if (runtime.requireMspAuth && requiresLegacyMspAuth(toolName)) { - authorizeLegacyMspRequest(request.params?._meta, { + const scope = authorizeLegacyMspRequest(request.params?._meta, { toolName, - args: request.params?.arguments ?? {}, + args, defaultPortfolioId: runtime.defaultPortfolioId, relayCredential: runtime.mspRelayCredential, }); + // D7: a portfolio owned by a governed caller is not MSP's. + if (runtime.governedPortfolios?.has(scope.portfolioId)) throw new GksScopeDeniedError("This portfolio is governed by another caller."); } - const structuredContent = await handler(request.params?.arguments ?? {}); + const structuredContent = await handler(args, caller); return { jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(structuredContent) }], structuredContent } }; } catch (error) { return createJsonRpcToolErrorResponse(request.id, error); diff --git a/docs/ADR-GKS-BOUNDARY.md b/docs/ADR-GKS-BOUNDARY.md index 51b4751..d70bc45 100644 --- a/docs/ADR-GKS-BOUNDARY.md +++ b/docs/ADR-GKS-BOUNDARY.md @@ -1,7 +1,7 @@ --- -version: "0.6.1b" +version: "0.7.0b" created_at: "2026-08-12T10:05:34+07:00,ATHER,working-tree" -last_update: "2026-09-27T09:30:00+07:00,Claude" +last_update: "2026-09-27T22:00:00+07:00,Claude" status: "beta" approval_owner: "Boss (บอส)" approval_recorded_at: "2026-08-12T10:16:19+07:00" @@ -57,7 +57,12 @@ physical graph/vector database engine. GenesisBlockDB remains a separate product, and a physical projection or worker receipt does not transfer canonical authority away from GKS. -For Phase 1, the governed runtime ingress remains MSP-only. The existing +Governed runtime ingress is limited to **governed callers** ([ADR-GKS-GOVERNED-CALLERS](ADR-GKS-GOVERNED-CALLERS.md), accepted +2026-09-27). MSP is the built-in governed caller. Another system that owns its +own auth and memory can be provisioned as a governed caller for the portfolios +it owns, with its own provenance namespace, and it does not need MSP. The text +below was written when ingress was MSP-only, and it still describes the MSP +path. The existing registered GKS tools and scope/authentication contracts are preserved; this decision does not grant direct credentials or access to Zuri, GoVibe, or other consumers. Personal memory, session-context assembly, MSP policy/approval, and @@ -107,8 +112,10 @@ its right. It does not mean source code must be deleted from the caller's repo. - The configured GKS root owns GKS service runtime, public contracts, canonicalization logic, backend ports, and service-level tests. -- The configured MSP root keeps its GKS provider/client boundary and remains the sole - governed caller for promotion, review, pipeline and receipt operations. Any +- The configured MSP root keeps its GKS provider/client boundary. MSP is the built-in + governed caller, and the only caller of pipeline and receipt operations. + A provisioned governed caller ([ADR-GKS-GOVERNED-CALLERS](ADR-GKS-GOVERNED-CALLERS.md)) may call promotion, artifact linking + and review, but only for its own portfolios. Any direct read-only caller must satisfy the distinct grant profile in `ADR-GKS-CLIENT-ACCESS.md`. - The configured GoVibe root keeps MSP/GKS names, contracts, disabled direct-GKS shim, @@ -156,7 +163,8 @@ API-010 must remain wire compatible during extraction. ## Acceptance criteria -- MSP is the only governed caller of GKS in the Zuri/GoVibe path. +- MSP is the governed caller of GKS in the Zuri/GoVibe path. Other governed callers + are provisioned per [ADR-GKS-GOVERNED-CALLERS](ADR-GKS-GOVERNED-CALLERS.md), and each one owns its portfolios exclusively. - Direct clients are limited to explicitly granted scoped reads and have no promotion, review, pipeline or MSP receipt authority. - GKS owns canonical identity and relations without owning MSP memory/context. @@ -206,6 +214,7 @@ the repository or adding another exemption is a boundary change recorded here. | Version | Date | Status | Summary | Commit Hash | Agent | |---|---|---|---|---|---| +| 0.7.0b | 2026-09-27 | beta | Amended by ADR-GKS-GOVERNED-CALLERS (accepted 2026-09-27). MSP is no longer the only governed caller; it is the built-in one, and a provisioned governed caller owns its portfolios and provenance namespace. Pipeline and receipt operations stay MSP's. | working-tree | Claude | | 0.6.1b | 2026-09-27 | beta | Recorded `apps/wiki-desktop` as the one named non-service exemption from the outward-reference boundary check, which is now case-insensitive. | working-tree | Claude | | 0.6.0b | 2026-09-24 | beta | Records the implemented, opt-in direct read-only HTTP profile; MSP remains the sole governed caller and production access stays disabled by default. | working-tree | RWANG | | 0.5.0b | 2026-09-24 | beta | Records the approved direct-client read-only exception through a server-side identity/scope grant; MSP remains sole governed caller for writes, pipeline and receipts. | working-tree | RWANG | diff --git a/docs/ADR-GKS-CLIENT-ACCESS.md b/docs/ADR-GKS-CLIENT-ACCESS.md index bcc2092..f6add04 100644 --- a/docs/ADR-GKS-CLIENT-ACCESS.md +++ b/docs/ADR-GKS-CLIENT-ACCESS.md @@ -1,7 +1,7 @@ --- -version: "0.2.0b" +version: "0.3.0b" created_at: "2026-09-24T07:21:56+07:00,RWANG,working-tree" -last_update: "2026-09-24T10:06:32+07:00,RWANG" +last_update: "2026-09-27T22:00:00+07:00,Claude" status: "beta" approval_owner: "Boss (บอส)" approval_recorded_at: "2026-09-24T07:21:56+07:00" @@ -138,13 +138,18 @@ System client -> trusted GKS authentication/grant adapter -> GKS read tools This decision does not authorize direct writes, general ingestion, promotion, review, MSP feature replacement, public/anonymous access, a browser-held service -secret, production deployment or canary traffic. Direct write authority needs -a separate decision covering provenance, approval, revocation, idempotency and -non-MSP receipt semantics. +secret, production deployment or canary traffic. + +Write authority for systems other than MSP is decided by ADR-GKS-GOVERNED-CALLERS +(accepted 2026-09-27). That ADR adds a `governed` profile to the same grants file +(`gks-client-grants/v2`, where v1 documents keep parsing as read grants). It +covers provenance, revocation, idempotency and receipt semantics. The read +profile defined here is unchanged. ## CHANGELOG | Version | Date | Status | Summary | Commit Hash | Agent | |---|---|---|---|---|---| +| 0.3.0b | 2026-09-27 | beta | Out-of-scope note: direct writes are now decided by ADR-GKS-GOVERNED-CALLERS through a `governed` profile in grants v2. The read profile and v1 documents are unchanged. | working-tree | Claude | | 0.2.0b | 2026-09-24 | beta | Implements per-client 256-bit bearer keys, hash-only server grants, private HTTP read-only authorization and restart-based revoke/rotation; OIDC/mTLS remain out of scope. | working-tree | RWANG | | 0.1.0b | 2026-09-24 | beta | Owner-approved access profiles: preserve MSP-governed operations and define explicitly granted direct read-only clients; identity verifier and activation remain open. | working-tree | RWANG | diff --git a/docs/ADR-GKS-GOVERNED-CALLERS.md b/docs/ADR-GKS-GOVERNED-CALLERS.md new file mode 100644 index 0000000..fbdad5e --- /dev/null +++ b/docs/ADR-GKS-GOVERNED-CALLERS.md @@ -0,0 +1,232 @@ +--- +version: "0.3.0" +created_at: "2026-09-27T21:00:00+07:00,Claude,working-tree" +last_update: "2026-09-27T22:00:00+07:00,Claude" +status: "accepted" +approval_owner: "Boss (บอส)" +approval_recorded_at: "2026-09-27T21:30:00+07:00" +superseded_by: null +attributes: + domain: "gks-service-access" + doc_type: "architecture-decision" + scope: "Governed callers: MSP becomes one implementation of a caller role that any system with its own auth and memory can fill" +--- + +# ADR: Governed callers + +## Decision status + +**Accepted by the owner on 2026-09-27, as proposed.** The owner approved all +four open questions as proposed; see "Owner decisions" below. This ADR amends +three documents: + +- ADR-GKS-BOUNDARY: "MSP is the only governed caller"; +- ADR-GKS-CLIENT-ACCESS: its "Out of scope: direct writes" section; +- the matching hard rule in `CLAUDE.md`. + +Those documents are updated together with the implementation. + +## Context + +Today a system can write to GKS only through MSP: + +- promotion, artifact linking and human review require the + `gks-msp-auth/v1` envelope with `principalId: "msp-runtime"`; +- every provenance reference must start with `msp:proof/`; +- direct clients (ADR-GKS-CLIENT-ACCESS) may only read. + +What GKS actually needs from its writer is a **role**, not the MSP product. The +role has three parts: + +1. **Authenticate** the end user or agent. GKS trusts the caller that did so. +2. **Decide the scope** (portfolio, tenant, business, workspace, project, + sharing) of each call. +3. **Supply provenance**: a reference to the approval that authorized the + write. + +A customer that already runs its own auth and memory can already fill that +role. Requiring it to deploy MSP only to reach GKS adds a hop that contributes +nothing. ADR-GKS-CLIENT-ACCESS left direct writes open, pending a decision on +provenance, approval, revocation, idempotency and non-MSP receipts. This ADR is +that decision. + +## Decision + +### D1 — "Governed caller" is a role; MSP is its first implementation + +A governed caller is a system that GKS trusts to authenticate its own users, +choose scopes within its boundary, and attest provenance. MSP keeps working +exactly as today, as the built-in governed caller `msp-runtime`. Other +governed callers are provisioned explicitly by the GKS operator. The call +direction is unchanged: a caller calls GKS, and GKS never calls out. + +### D2 — Provisioned by the existing grants file + +`GKS_CLIENT_GRANTS_PATH` gains `schemaVersion: "gks-client-grants/v2"`, and +v1 documents keep parsing unchanged. Each entry declares a `profile`: + +```json +{ + "schemaVersion": "gks-client-grants/v2", + "clients": [ + { "profile": "read", "clientId": "reporting", "credentialSha256": "…", + "allowedTools": ["gks_search"], "scopes": [{ "portfolioId": "p1", "tenantId": "t1", "…": "…" }] }, + { "profile": "governed", "clientId": "acme-backend", "credentialSha256": "…", + "provenanceNamespace": "acme", + "portfolioIds": ["acme-portfolio"], + "allowedTools": ["gks_knowledge_promote", "gks_search", "gks_entity_get", "gks_relations_get", + "gks_artifact_link", "gks_review_list", "gks_review_apply", "gks_stage_evidence_export"] } + ] +} +``` + +- `read` is the existing direct-client profile, unchanged. +- `governed` may list any of the eight legacy knowledge tools. Pipeline tools + are out of scope (see D8). +- Credentials use the existing format (`gksc_` + 32 random bytes) and are + stored hash-only, with the same restart-based rotation and revocation. +- Startup fails on any invalid entry. That includes a namespace or portfolio + claimed twice (D3, D5), a governed entry whose `clientId` is + `msp-runtime`, and the namespace `msp`. + +### D3 — Exclusive portfolio boundary + +- A governed caller owns the portfolios in `portfolioIds`. **Each portfolio + has at most one governed caller.** Within its portfolios the caller is + trusted to choose tenant, business, workspace, project and sharing, just as + MSP is today. +- GKS still enforces the exact tenant wall on every request + (ADR-GKS-C0-QUALIFICATION D1). A caller's trust never crosses a tenant + inside its own portfolio. +- A request for a portfolio outside the caller's boundary is + `gks_scope_denied` before any persistence read. +- Exclusivity is also what keeps callers apart in storage: + - idempotency keys, canonical refs and evidence cursors are all keyed by + `scope_key`; + - two callers can therefore never collide on an idempotency key, and cannot + observe each other's rows. + +### D4 — Authentication envelope + +Governed callers use the private HTTP transport: + +- the credential goes in `Authorization: Bearer`; +- each tool call carries this envelope: + +```json +"_meta": { "gksCallerAuth": { "version": "gks-caller-auth/v1", "callerId": "acme-backend", "scopeDigest": "" } } +``` + +- `callerId` must equal the grant's `clientId`. +- `scopeDigest` is computed exactly like MSP's (`mspScopeDigest`). It binds + the metadata to the arguments, so a proxy that rewrites the scope is refused. + +Stdio stays MSP-only. Whoever spawns a stdio child already holds the store and +its environment, which is operator trust, not caller trust. + +### D5 — Provenance namespaces + +- **The public tool schemas widen their pattern.** Today they require + `^msp:proof/`. The new pattern is `^[a-z][a-z0-9-]{0,30}:proof/`. It + applies to `provenance_ref` (promote), `provenanceRef` (review apply) and + `evidenceRef` (artifact link). Widening a pattern accepts everything + accepted before, so API-010 payloads are unaffected. +- **The authorization layer then requires the caller's own namespace**: + `acme:proof/…` for `acme-backend`, and `msp:proof/…` for MSP only. A caller + cannot attest under another caller's namespace. Namespaces are unique across + grants, and `msp` is reserved. +- **GKS stores the reference as given.** It never mints proofs or receipts + for any caller. That is the "non-MSP receipt semantics" question from + ADR-GKS-CLIENT-ACCESS: there are none, exactly as for MSP today. +- Verifying proofs (GKS-IDN-006) becomes a per-caller question, for example + proofs signed with a key in the grant. That stays a separate decision. + +### D6 — Attribution + +Each write records the authenticated `caller_id` next to the caller-supplied +provenance. That covers promotions, human resolutions, artifact links and +stage evidence, via additive migration 0008. Existing rows are backfilled as +`msp-runtime`. "Who wrote this" must not rest on a string the writer chose. + +### D7 — MSP compatibility + +- `GKS_MSP_RELAY_CREDENTIAL` and the `gks-msp-auth/v1` envelope work unchanged + over stdio and HTTP. API-010 payloads, response shapes and stored + `msp:proof/` references are untouched. +- MSP's boundary is every portfolio **not** claimed by a governed grant. A + portfolio moved to another governed caller becomes `gks_scope_denied` to + MSP. With no governed grants configured, behaviour is identical to today. + +### D8 — Out of scope + +- Pipeline (GenesisRAG17) tools. They keep their relay/worker credentials, and + per-caller pipeline sources would be a follow-up. +- OIDC/JWT/mTLS verification. +- Runtime grant administration or hot reload. +- Portfolios shared between callers. +- Public or anonymous access. +- GKS minting receipts. +- Verifying proofs against the caller (GKS-IDN-006). + +## Consequences + +- A customer with its own auth and memory reaches GKS directly. Its backend + becomes a governed caller, so there is no MSP deployment. +- There is still one authorization model: grants plus the built-in MSP + profile. No second, competing model appears. +- The blast radius of a leaked governed credential is that caller's + portfolios, not the whole store. It is revoked by editing the grants file + and restarting. +- Two things are now true of every write rather than assumed: + - "MSP owns policy" becomes "the portfolio's governed caller owns policy"; + - the stored `caller_id` records who wrote each row. + +## Changes on acceptance (implemented 2026-09-27) + +| Area | Change | +|---|---| +| `gks-contracts/client-auth.mjs` | parse grants v2 (the `governed` profile); enforce namespace and portfolio uniqueness | +| `gks-contracts/tool-definitions.mjs` | widen the three provenance patterns. The tool registry hash changes, so the baseline is re-locked. | +| `gks-contracts/validation.mjs`, `gks-core` | move the `msp:proof/` checks into caller-aware authorization | +| `apps/gks-server/http-server.mjs`, `server.mjs` | resolve a bearer credential to a governed grant; verify `gksCallerAuth`; enforce the boundary and namespace; deny MSP on claimed portfolios | +| `migrations/0008_caller_attribution.sql` | additive `caller_id` columns, with backfill | +| docs | amend ADR-GKS-BOUNDARY, ADR-GKS-CLIENT-ACCESS, GKS-PORT-CONTRACT, `CLAUDE.md`; runbook section on provisioning a governed caller | + +## Acceptance criteria + +- A governed caller can promote, link, review and export within its + portfolios, and every one of those calls is denied outside them. +- Denials that fail closed, with no persistence read: + - a wrong credential; + - a `callerId`/grant mismatch; + - a scope-digest mismatch; + - another caller's namespace, including `msp`; + - a portfolio owned by another caller; + - a tool not in `allowedTools`; + - any pipeline tool. +- Invalid grant documents fail startup, including overlapping portfolios, + duplicate namespaces, and a governed `msp-runtime` or `msp`. +- MSP behaviour, the API-010 fixtures and the C0.4 golden corpus replay + unchanged when no governed grant is configured. New golden cases cover a + governed caller and the denials above. +- Every write stores its authenticated `caller_id`, and existing rows read + back as `msp-runtime`. +- No raw credential appears in the grants file, logs, responses or the store. + +## Owner decisions (2026-09-27) + +The owner accepted each of the following as proposed. + + +1. **MSP's boundary** is every portfolio not claimed by a governed grant. +2. **Portfolios are exclusive:** one governed caller per portfolio. +3. **The attribution migration (D6)** ships in the same rollout. +4. **Per-caller pipeline sources** are left for a later, separate decision. + +## Change log + +| Version | Date | Status | Summary | Commit Hash | Agent | +|---|---|---|---|---|---| +| 0.1.0b | 2026-09-27 | proposed | Draft: governed-caller role with MSP as its first implementation. Grants v2 `governed` profile with exclusive portfolios, the `gks-caller-auth/v1` envelope, per-caller provenance namespaces, caller attribution, MSP compatibility; pipeline out of scope. | working-tree | Claude | +| 0.2.0 | 2026-09-27 | accepted | The owner accepted the ADR and all four open questions as proposed. | working-tree | Claude | +| 0.3.0 | 2026-09-27 | accepted | Implemented: grants v2 governed profile, `authorizeGovernedCallerRequest`, per-caller provenance namespaces (MSP messages unchanged), MSP denied on governed portfolios, migration 0008 `caller_id`, and the amended BOUNDARY, CLIENT-ACCESS, PORT-CONTRACT, README, runbook and CLAUDE.md. A golden corpus case over HTTP follows in the next PR. | working-tree | Claude | diff --git a/docs/GKS-PORT-CONTRACT.md b/docs/GKS-PORT-CONTRACT.md index 6702fe7..35b8a29 100644 --- a/docs/GKS-PORT-CONTRACT.md +++ b/docs/GKS-PORT-CONTRACT.md @@ -1,7 +1,7 @@ --- -version: "0.10.4b" +version: "0.11.0b" created_at: "2026-08-12T10:05:34+07:00,ATHER,working-tree" -last_update: "2026-09-27T20:00:00+07:00,Claude" +last_update: "2026-09-27T22:00:00+07:00,Claude" status: "beta" approval_owner: "Boss (บอส)" approval_recorded_at: "2026-08-12T10:16:19+07:00" @@ -88,6 +88,29 @@ and is specified in [`ADR-GKS-CLIENT-ACCESS.md`](ADR-GKS-CLIENT-ACCESS.md): - The HTTP adapter resolves per-client bearer-key hashes to server-provisioned grants; client-supplied metadata cannot create identity or authority. +**Governed callers** ([`ADR-GKS-GOVERNED-CALLERS.md`](ADR-GKS-GOVERNED-CALLERS.md)). +A `governed` grant (`gks-client-grants/v2`) authorizes a system other than MSP +to call the legacy knowledge tools listed in its `allowedTools`, including +promotion, artifact linking and review, over HTTP. It holds for the +portfolios the grant owns and nowhere else. The rules: + +- Every call carries `_meta.gksCallerAuth`: `{ version: "gks-caller-auth/v1", + callerId, scopeDigest }`. `callerId` must be the grant's `clientId`, and + `scopeDigest` is the same digest MSP sends. +- `provenance_ref`, `provenanceRef` and `evidenceRef` must be in the caller's + namespace (`:proof/…`). The tool schemas accept + `^[a-z][a-z0-9-]{0,30}:proof/`, and the caller's grant narrows that to its + own namespace. MSP keeps `msp:proof/`, with its messages unchanged. +- MSP is denied the portfolios a governed grant owns (`gks_scope_denied`: `This + portfolio is governed by another caller.`). +- Pipeline tools stay MSP-relay only. +- Each write records the authenticated `caller_id` (migration 0008), which is + not returned in responses. Rows written before the migration read as + `msp-runtime`. +- Internal port: `transactPromotion`, `transactHumanResolution` and + `transactArtifactLink` accept an optional `callerId`. It defaults to + `msp-runtime` for callers that predate governed callers. + ### MVP tool mapping | Port method | MCP tool | Phase | @@ -568,6 +591,7 @@ implementation package name appears in the client. | 0.10.2b | 2026-09-27 | beta | GKS-PIP-001 (owner decision): `gks_pipeline_submit` requires stage identities in catalog order 9 through 17. Receipts stay order-insensitive against the stored decision. | working-tree | Claude | | 0.10.3b | 2026-09-27 | beta | GKS-PIP-008 (owner decision): `gks_stage_evidence_export` refuses a `since_cursor` ahead of the store-wide cursor with `gks_invalid_request`, instead of returning an empty page, matching `gks_pipeline_evidence`. | working-tree | Claude | | 0.10.4b | 2026-09-27 | beta | GKS-ING-004 (owner decision): `gks_pipeline_submit` refuses source content or chunk text that is not well-formed Unicode. A lone surrogate would otherwise hash as U+FFFD and share a hash with different text. | working-tree | Claude | +| 0.11.0b | 2026-09-27 | beta | Governed callers (ADR-GKS-GOVERNED-CALLERS). Adds the grants v2 `governed` profile, the `gksCallerAuth` envelope and per-caller provenance namespaces, with the tool schema patterns widened. MSP is denied portfolios another caller owns. Writes record `caller_id` (migration 0008), and the port accepts an optional `callerId`. | working-tree | Claude | | 0.10.0b | 2026-09-24 | beta | Implements optional per-client hash-backed direct HTTP read grants while preserving the MSP profile; grants are not enabled by default and production rollout remains separate. | working-tree | RWANG | | 0.9.0b | 2026-09-24 | beta | Adds the approved direct-client read-only grant profile while preserving MSP auth for governed writes; concrete identity verification and activation remain unimplemented. | working-tree | RWANG | | 0.8.1b | 2026-09-22 | beta | Removed the stale port-version-1 statement that contradicted the selected GKS-owned SQLite production profile; deployment evidence remains separately gated. | working-tree | RWANG | diff --git a/docs/GKS-PRODUCTION-RUNBOOK.md b/docs/GKS-PRODUCTION-RUNBOOK.md index 6b92375..800a50f 100644 --- a/docs/GKS-PRODUCTION-RUNBOOK.md +++ b/docs/GKS-PRODUCTION-RUNBOOK.md @@ -1,7 +1,7 @@ --- -version: "0.2.1b" +version: "0.3.0b" created_at: "2026-09-22T10:54:22+07:00,RWANG,working-tree" -last_update: "2026-09-27T09:30:00+07:00,Claude" +last_update: "2026-09-27T22:00:00+07:00,Claude" status: "beta" superseded_by: null attributes: @@ -60,6 +60,32 @@ GKS_HTTP_PORT= The server must fail closed when the database path, required credential, or network bind configuration is missing. +## Provisioning a governed caller + +A system with its own auth and memory can call GKS without MSP +(ADR-GKS-GOVERNED-CALLERS). To provision one: + +1. Generate its credential: `gksc_` followed by 32 random bytes in base64url. + Hand the credential to the caller's operator only. +2. Add an entry to the grants file (`GKS_CLIENT_GRANTS_PATH`, with + `schemaVersion: "gks-client-grants/v2"`): + - `profile: "governed"`; + - `clientId`; + - `credentialSha256`: the lowercase SHA-256 of the credential; + - `provenanceNamespace`: unique, and not `msp`; + - `portfolioIds`: portfolios no other governed caller owns; + - `allowedTools`: legacy knowledge tools only. +3. Restart GKS. An invalid or overlapping grant stops startup. +4. **Before moving a portfolio that MSP already writes to**, stop MSP writes + to it. From the restart onward MSP gets `gks_scope_denied` there. Existing + rows keep their `caller_id` of `msp-runtime`. +5. To revoke, remove the entry and restart. + +The caller sends its credential as `Authorization: Bearer` over the private +HTTP transport. Each tool call carries `_meta.gksCallerAuth` (`version: +"gks-caller-auth/v1"`, its `callerId`, and the `scopeDigest` of the request +scope). Provenance references use `:proof/…`. + ## Canary sequence 1. Verify the artifact SHA and configuration names without printing values. @@ -124,6 +150,7 @@ Every deployment attempt records: | Version | Date | Status | Summary | Commit Hash | Agent | |---|---|---|---|---|---| +| 0.3.0b | 2026-09-27 | beta | Added provisioning and revocation of a governed caller, including moving a portfolio away from MSP. | working-tree | Claude | | 0.2.1b | 2026-09-27 | beta | Documented the schema-ahead refusal during rollback and the optional pipeline worker credential. | working-tree | Claude | | 0.2.0b | 2026-09-22 | beta | Added the Docker Compose reference target and separated package validation from actual host canary and production cutover evidence. | working-tree | RWANG | | 0.1.0b | 2026-09-22 | beta | Added private runtime prerequisites, canary, cutover, rollback, and evidence requirements. | working-tree | RWANG | diff --git a/migrations/0008_caller_attribution.sql b/migrations/0008_caller_attribution.sql new file mode 100644 index 0000000..a2d8cbe --- /dev/null +++ b/migrations/0008_caller_attribution.sql @@ -0,0 +1,11 @@ +-- ADR-GKS-GOVERNED-CALLERS D6: every governed write records the authenticated +-- caller next to the provenance reference the caller supplied, so "who wrote +-- this" never rests on a string the writer chose. +-- +-- Additive. Every row written before this migration came through MSP -- the +-- only governed caller that existed -- so the DEFAULT backfills them as +-- msp-runtime. New rows are written with an explicit caller_id by the service. +ALTER TABLE promotions ADD COLUMN caller_id TEXT NOT NULL DEFAULT 'msp-runtime'; +ALTER TABLE stage_evidence ADD COLUMN caller_id TEXT NOT NULL DEFAULT 'msp-runtime'; +ALTER TABLE human_resolutions ADD COLUMN caller_id TEXT NOT NULL DEFAULT 'msp-runtime'; +ALTER TABLE artifact_links ADD COLUMN caller_id TEXT NOT NULL DEFAULT 'msp-runtime'; diff --git a/packages/gks-contracts/src/client-auth.mjs b/packages/gks-contracts/src/client-auth.mjs index d0925c1..4c4bd57 100644 --- a/packages/gks-contracts/src/client-auth.mjs +++ b/packages/gks-contracts/src/client-auth.mjs @@ -1,15 +1,31 @@ import crypto from "node:crypto"; import { GksScopeDeniedError } from "./errors.mjs"; +import { GKS_MSP_PRINCIPAL_ID, LEGACY_MSP_AUTH_TOOLS, mspScopeDigest, normalizedMspScope } from "./msp-auth.mjs"; import { validateScope } from "./validation.mjs"; export const GKS_CLIENT_GRANTS_VERSION = "gks-client-grants/v1"; +// v2 adds the governed profile (ADR-GKS-GOVERNED-CALLERS D2); v1 documents +// keep parsing, every entry being a read grant. +export const GKS_CLIENT_GRANTS_VERSION_V2 = "gks-client-grants/v2"; +export const GKS_CALLER_AUTH_VERSION = "gks-caller-auth/v1"; export const GKS_DIRECT_READ_TOOLS = Object.freeze([ "gks_search", "gks_entity_get", "gks_relations_get", ]); +// A governed caller may be granted any of the legacy knowledge tools MSP uses; +// pipeline tools are out of scope (ADR-GKS-GOVERNED-CALLERS D8). +export const GKS_GOVERNED_TOOLS = LEGACY_MSP_AUTH_TOOLS; +// The built-in governed caller's provenance namespace; reserved. +export const GKS_MSP_PROVENANCE_NAMESPACE = "msp"; +// The built-in governed caller (ADR-GKS-GOVERNED-CALLERS D1): every call that +// did not come from a provisioned governed grant is MSP's. +export const GKS_MSP_CALLER = Object.freeze({ callerId: GKS_MSP_PRINCIPAL_ID, provenanceNamespace: GKS_MSP_PROVENANCE_NAMESPACE }); const DIRECT_READ_TOOL_SET = new Set(GKS_DIRECT_READ_TOOLS); +const GOVERNED_TOOL_SET = new Set(GKS_GOVERNED_TOOLS); +const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]{0,30}$/; +const CLIENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const CLIENT_CREDENTIAL_PATTERN = /^gksc_[A-Za-z0-9_-]{43}$/; const SCOPE_FIELDS = Object.freeze([ "portfolioId", @@ -57,28 +73,66 @@ export function parseGksClientGrants(raw) { if (!isRecord(document) || !hasOnlyKeys(document, new Set(["schemaVersion", "clients"]))) { throw new TypeError("GKS client grants document is invalid."); } - if (document.schemaVersion !== GKS_CLIENT_GRANTS_VERSION || !Array.isArray(document.clients)) { + const version = document.schemaVersion; + if ((version !== GKS_CLIENT_GRANTS_VERSION && version !== GKS_CLIENT_GRANTS_VERSION_V2) || !Array.isArray(document.clients)) { throw new TypeError("GKS client grants document is invalid."); } const seenCredentialHashes = new Set(); + const seenClientIds = new Set(); + const seenNamespaces = new Set(); + const claimedPortfolios = new Set(); const grants = document.clients.map((entry) => { - const entryFields = new Set(["clientId", "credentialSha256", "allowedTools", "scopes"]); - if (!isRecord(entry) || !hasOnlyKeys(entry, entryFields)) throw new TypeError("GKS client grant entry is invalid."); - if (typeof entry.clientId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(entry.clientId) || entry.clientId === "msp-runtime") { + if (!isRecord(entry)) throw new TypeError("GKS client grant entry is invalid."); + // v1 entries carry no profile and are read grants. + const profile = version === GKS_CLIENT_GRANTS_VERSION ? "read" : entry.profile; + if (profile !== "read" && profile !== "governed") throw new TypeError("GKS client grant entry is invalid."); + const entryFields = profile === "read" + ? new Set([...(version === GKS_CLIENT_GRANTS_VERSION ? [] : ["profile"]), "clientId", "credentialSha256", "allowedTools", "scopes"]) + : new Set(["profile", "clientId", "credentialSha256", "allowedTools", "provenanceNamespace", "portfolioIds"]); + if (!hasOnlyKeys(entry, entryFields)) throw new TypeError("GKS client grant entry is invalid."); + if (typeof entry.clientId !== "string" || !CLIENT_ID_PATTERN.test(entry.clientId) || entry.clientId === GKS_MSP_PRINCIPAL_ID || seenClientIds.has(entry.clientId)) { throw new TypeError("GKS client grant entry is invalid."); } + seenClientIds.add(entry.clientId); if (typeof entry.credentialSha256 !== "string" || !/^[0-9a-f]{64}$/.test(entry.credentialSha256) || seenCredentialHashes.has(entry.credentialSha256)) { throw new TypeError("GKS client grant entry is invalid."); } seenCredentialHashes.add(entry.credentialSha256); + const toolSet = profile === "read" ? DIRECT_READ_TOOL_SET : GOVERNED_TOOL_SET; if (!Array.isArray(entry.allowedTools) || entry.allowedTools.length === 0 - || entry.allowedTools.some((tool) => typeof tool !== "string" || !DIRECT_READ_TOOL_SET.has(tool)) + || entry.allowedTools.some((tool) => typeof tool !== "string" || !toolSet.has(tool)) || new Set(entry.allowedTools).size !== entry.allowedTools.length) { throw new TypeError("GKS client grant entry is invalid."); } - if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) throw new TypeError("GKS client grant entry is invalid."); + const allowedTools = Object.freeze([...entry.allowedTools]); + + if (profile === "governed") { + // ADR-GKS-GOVERNED-CALLERS D3/D5: a namespace and a portfolio each + // belong to exactly one caller, and `msp` is reserved for MSP. + const namespace = entry.provenanceNamespace; + if (typeof namespace !== "string" || !NAMESPACE_PATTERN.test(namespace) || namespace === GKS_MSP_PROVENANCE_NAMESPACE || seenNamespaces.has(namespace)) { + throw new TypeError("GKS client grant entry is invalid."); + } + seenNamespaces.add(namespace); + if (!Array.isArray(entry.portfolioIds) || entry.portfolioIds.length === 0) throw new TypeError("GKS client grant entry is invalid."); + for (const portfolioId of entry.portfolioIds) { + if (typeof portfolioId !== "string" || !portfolioId || portfolioId !== portfolioId.trim() || portfolioId.length > 256 || claimedPortfolios.has(portfolioId)) { + throw new TypeError("GKS client grant entry is invalid."); + } + claimedPortfolios.add(portfolioId); + } + return Object.freeze({ + profile, + clientId: entry.clientId, + credentialSha256: entry.credentialSha256, + allowedTools, + provenanceNamespace: namespace, + portfolioIds: Object.freeze([...entry.portfolioIds]), + }); + } + if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) throw new TypeError("GKS client grant entry is invalid."); const scopes = entry.scopes.map((input) => { if (!isRecord(input) || !hasOnlyKeys(input, SCOPE_INPUT_FIELDS)) throw new TypeError("GKS client grant scope is invalid."); return Object.freeze(validateScope(input)); @@ -87,9 +141,10 @@ export function parseGksClientGrants(raw) { if (new Set(scopeSignatures).size !== scopeSignatures.length) throw new TypeError("GKS client grant entry is invalid."); return Object.freeze({ + profile, clientId: entry.clientId, credentialSha256: entry.credentialSha256, - allowedTools: Object.freeze([...entry.allowedTools]), + allowedTools, scopes: Object.freeze(scopes), scopeSignatures: Object.freeze(scopeSignatures), }); @@ -97,6 +152,11 @@ export function parseGksClientGrants(raw) { return Object.freeze(grants); } +/** Portfolios owned by a governed caller; MSP is denied them (D7). */ +export function governedPortfolios(grants = []) { + return new Set(grants.filter((grant) => grant.profile === "governed").flatMap((grant) => grant.portfolioIds)); +} + export function findGksClientGrant(credential, grants = []) { if (!isGksClientCredential(credential) || !Array.isArray(grants)) return null; const digest = Buffer.from(hashGksClientCredential(credential), "hex"); @@ -108,8 +168,47 @@ export function findGksClientGrant(credential, grants = []) { return match; } +// The provenance fields a governed write carries, per tool. +const PROVENANCE_FIELDS = Object.freeze({ + gks_knowledge_promote: "provenance_ref", + gks_review_apply: "provenanceRef", + gks_artifact_link: "evidenceRef", +}); + +/** + * ADR-GKS-GOVERNED-CALLERS D3-D5: authorizes one call from a governed caller. + * The grant (resolved server-side from the bearer credential) decides the + * tools, the portfolio boundary and the provenance namespace; the + * `gksCallerAuth` envelope must name the same caller and bind the scope. + * Returns the caller context the service records (D6). + */ +export function authorizeGovernedCallerRequest(grant, meta, { toolName, args, defaultPortfolioId } = {}) { + if (!grant || grant.profile !== "governed" || !grant.allowedTools.includes(toolName)) { + throw new GksScopeDeniedError("Governed caller action is not authorized."); + } + const auth = meta?.gksCallerAuth; + if (!isRecord(auth) || auth.version !== GKS_CALLER_AUTH_VERSION || auth.callerId !== grant.clientId) { + throw new GksScopeDeniedError("Governed caller authentication is invalid."); + } + let scope; + try { + scope = normalizedMspScope(toolName, args ?? {}, defaultPortfolioId); + } catch { + throw new GksScopeDeniedError("Governed caller scope is invalid."); + } + if (!grant.portfolioIds.includes(scope.portfolioId)) throw new GksScopeDeniedError("Governed caller scope is not authorized."); + if (typeof auth.scopeDigest !== "string" || auth.scopeDigest !== mspScopeDigest(scope)) { + throw new GksScopeDeniedError("Governed caller scope does not match the request."); + } + const field = PROVENANCE_FIELDS[toolName]; + if (field && !(typeof args?.[field] === "string" && args[field].startsWith(`${grant.provenanceNamespace}:proof/`))) { + throw new GksScopeDeniedError(`${field} must be a ${grant.provenanceNamespace}:proof reference for this caller.`); + } + return Object.freeze({ callerId: grant.clientId, provenanceNamespace: grant.provenanceNamespace, scope }); +} + export function authorizeGksClientRequest(grant, { toolName, args } = {}) { - if (!grant || !grant.allowedTools.includes(toolName)) { + if (!grant || grant.profile === "governed" || !grant.allowedTools.includes(toolName)) { throw new GksScopeDeniedError("Direct GKS client action is not authorized."); } let scope; diff --git a/packages/gks-contracts/src/tool-definitions.mjs b/packages/gks-contracts/src/tool-definitions.mjs index 6f223b0..da41126 100644 --- a/packages/gks-contracts/src/tool-definitions.mjs +++ b/packages/gks-contracts/src/tool-definitions.mjs @@ -13,7 +13,7 @@ export const GKS_TOOL_DEFINITIONS = Object.freeze([ run_id: { type: "string", minLength: 1 }, stage: { type: "integer", minimum: 1, maximum: 12 }, source_snapshot_hash: { type: "string", pattern: "^[a-f0-9]{64}$" }, - provenance_ref: { type: "string", pattern: "^msp:proof/" }, + provenance_ref: { type: "string", pattern: "^[a-z][a-z0-9-]{0,30}:proof/" }, candidate: { type: "object" }, scope: { type: "object" }, pipeline_stage_id: { type: "string", pattern: "^DPS-KI-[A-Z0-9]+(-[A-Z0-9]+)*$" }, @@ -39,7 +39,7 @@ export const GKS_TOOL_DEFINITIONS = Object.freeze([ type: "object", properties: { action: { type: "string", enum: ["BIND", "MERGE"] }, - provenanceRef: { type: "string", pattern: "^msp:proof/" }, + provenanceRef: { type: "string", pattern: "^[a-z][a-z0-9-]{0,30}:proof/" }, scope: { type: "object" }, mentionId: { type: "string", pattern: "^gks:mention/[a-f0-9]{32}$" }, canonicalRef: { type: "string", pattern: "^gks:entity/[a-z0-9-]+-[a-f0-9]{32}$" }, diff --git a/packages/gks-contracts/src/validation.mjs b/packages/gks-contracts/src/validation.mjs index 0f3834c..d4b5dd8 100644 --- a/packages/gks-contracts/src/validation.mjs +++ b/packages/gks-contracts/src/validation.mjs @@ -117,7 +117,15 @@ function rejectCanonicalAssignments(value, path = "candidate") { } } -export function validatePromotionRequest(input, { defaultPortfolioId } = {}) { +// ADR-GKS-GOVERNED-CALLERS D5: every write carries a proof reference in its +// caller's own namespace. MSP's is `msp`, the default, so an MSP call is +// validated -- and refused -- exactly as before. +function requireProofRef(value, label, namespace = "msp") { + if (!value.startsWith(`${namespace}:proof/`)) throw new GksInvalidRequestError(`${label} must be an ${namespace}:proof reference.`); + return value; +} + +export function validatePromotionRequest(input, { defaultPortfolioId, provenanceNamespace } = {}) { if (!input || typeof input !== "object" || Array.isArray(input)) throw new GksInvalidRequestError("Knowledge candidate is required."); if (input.schema_version !== "govibe-knowledge-candidate/v1") throw new GksInvalidRequestError("Invalid knowledge candidate schema version."); const normalized = { @@ -135,7 +143,7 @@ export function validatePromotionRequest(input, { defaultPortfolioId } = {}) { throw new GksInvalidRequestError("pipeline_stage_id must be a DPS-KI-* pipeline stage id string."); } if (typeof input.source_snapshot_hash !== "string" || !HASH.test(input.source_snapshot_hash)) throw new GksInvalidRequestError("source_snapshot_hash must be 64 lower-case hexadecimal characters."); - if (!normalized.provenance_ref.startsWith("msp:proof/")) throw new GksInvalidRequestError("provenance_ref must be an msp:proof reference."); + requireProofRef(normalized.provenance_ref, "provenance_ref", provenanceNamespace); if (!input.candidate || typeof input.candidate !== "object" || Array.isArray(input.candidate)) throw new GksInvalidRequestError("candidate must be an object."); rejectCanonicalAssignments(input.candidate); return normalized; @@ -196,13 +204,12 @@ export function validateRelationType(value, label = "relationType") { // design (a human names what to bind or merge); they are still claims the // adapter verifies against stored rows inside the transaction, never trusted // shapes-only. -export function validateHumanResolutionRequest(input) { +export function validateHumanResolutionRequest(input, { provenanceNamespace } = {}) { if (!input || typeof input !== "object" || Array.isArray(input)) throw new GksInvalidRequestError("Human resolution request is required."); const action = requireString(input.action, "action").toUpperCase(); if (!HUMAN_RESOLUTION_ACTIONS.includes(action)) throw new GksInvalidRequestError(`action must be one of ${HUMAN_RESOLUTION_ACTIONS.join(", ")}.`); const scope = validateScope(input.scope); - const provenanceRef = requireString(input.provenanceRef, "provenanceRef"); - if (!provenanceRef.startsWith("msp:proof/")) throw new GksInvalidRequestError("provenanceRef must be an msp:proof reference."); + const provenanceRef = requireProofRef(requireString(input.provenanceRef, "provenanceRef"), "provenanceRef", provenanceNamespace); if (action === "BIND") { const mentionId = requireString(input.mentionId, "mentionId"); if (!MENTION_REF_PATTERN.test(mentionId)) throw new GksInvalidRequestError("mentionId must be a mention reference matching gks:mention/<32 hex>."); diff --git a/packages/gks-core/src/index.mjs b/packages/gks-core/src/index.mjs index 4f7fd22..fbb9739 100644 --- a/packages/gks-core/src/index.mjs +++ b/packages/gks-core/src/index.mjs @@ -11,6 +11,7 @@ export * from "./pipeline.mjs"; export * from "./temporal.mjs"; import { ENTITY_RESOLVE_STAGE_ID, + GKS_MSP_CALLER, GksConflictError, GksInvalidRequestError, GksInvalidBackendResponseError, @@ -73,6 +74,7 @@ function visible(recordScope, requestScope) { // a genuinely concurrent writer. The count is bounded because an unbounded // loop would spin forever on any bug that made the conflict deterministic. const NORM_KEY_CONFLICT_RETRIES = 3; +const MSP_CALLER = GKS_MSP_CALLER; export function createGksService({ persistence, defaultPortfolioId, automergeFloor: floorOption, pipelineRelayCredential, pipelineWorkerCredential } = {}) { assertGksPersistencePort(persistence); @@ -152,11 +154,14 @@ export function createGksService({ persistence, defaultPortfolioId, automergeFlo return { service: "gks", ...persistence.health() }; }, - async promoteCandidate(rawInput) { + // ADR-GKS-GOVERNED-CALLERS: `caller` is the authenticated governed caller + // (MSP unless the transport resolved another). It fixes the provenance + // namespace a write must use and is recorded with the write (D5, D6). + async promoteCandidate(rawInput, caller = MSP_CALLER) { // Ledger ADR D4: processing_time_ms is measured from here, the moment // the stage started executing, not from the moment its row is read. const startedAt = Date.now(); - const input = validatePromotionRequest(rawInput, { defaultPortfolioId }); + const input = validatePromotionRequest(rawInput, { defaultPortfolioId, provenanceNamespace: caller.provenanceNamespace }); const normalizedScope = input.scope; const normalizedScopeKey = scopeKey(normalizedScope); const seen = new Map(); @@ -253,6 +258,7 @@ export function createGksService({ persistence, defaultPortfolioId, automergeFlo knowledgeRef, sourceHash: input.source_snapshot_hash, provenanceRef: input.provenance_ref, + callerId: caller.callerId, candidate: input.candidate, entities, relations, @@ -323,9 +329,9 @@ export function createGksService({ persistence, defaultPortfolioId, automergeFlo // provenance ref. The resolver has no path here: resolveEntity is pure // and promoteCandidate reaches only transactPromotion, which itself // refuses to record strategy HUMAN. - async applyHumanResolution(input = {}) { - const request = validateHumanResolutionRequest(input); - return persistence.transactHumanResolution({ ...request, scopeKey: scopeKey(request.scope) }); + async applyHumanResolution(input = {}, caller = MSP_CALLER) { + const request = validateHumanResolutionRequest(input, { provenanceNamespace: caller.provenanceNamespace }); + return persistence.transactHumanResolution({ ...request, scopeKey: scopeKey(request.scope), callerId: caller.callerId }); }, // ADR-GKS-LEDGER-REPORTING D2 (Option B): the read-only cursor pull @@ -516,11 +522,12 @@ export function createGksService({ persistence, defaultPortfolioId, automergeFlo return { schemaVersion: PIPELINE_SCHEMA_VERSION, scope: request.scope, rows: page.rows, nextCursor: page.nextCursor }; }, - async linkArtifact(input = {}) { + async linkArtifact(input = {}, caller = MSP_CALLER) { const knowledgeRef = requireString(input.knowledgeRef, "knowledgeRef"); const artifactRef = requireString(input.artifactRef, "artifactRef"); const evidenceRef = requireString(input.evidenceRef, "evidenceRef"); - if (!evidenceRef.startsWith("msp:proof/")) throw new GksInvalidRequestError("evidenceRef must be an msp:proof reference."); + const namespace = caller.provenanceNamespace; + if (!evidenceRef.startsWith(`${namespace}:proof/`)) throw new GksInvalidRequestError(`evidenceRef must be an ${namespace}:proof reference.`); const relationType = validateRelationType(input.relationType); const normalizedScope = validateScope(input.scope); const entity = persistence.getEntity(knowledgeRef); @@ -528,7 +535,7 @@ export function createGksService({ persistence, defaultPortfolioId, automergeFlo if (!visible(entity.scope, normalizedScope)) throw new GksScopeDeniedError(); const normalizedScopeKey = scopeKey(normalizedScope); const canonicalRef = `gks:artifact-link/${digest(`${normalizedScopeKey}${SEP}${knowledgeRef}${SEP}${artifactRef}${SEP}${relationType}`)}`; - const row = persistence.transactArtifactLink({ canonicalRef, scopeKey: normalizedScopeKey, scope: normalizedScope, knowledgeRef, artifactRef, relationType, evidenceRef }); + const row = persistence.transactArtifactLink({ canonicalRef, scopeKey: normalizedScopeKey, scope: normalizedScope, knowledgeRef, artifactRef, relationType, evidenceRef, callerId: caller.callerId }); return { canonicalRef: row.canonical_ref, knowledgeRef: row.knowledge_ref, diff --git a/packages/gks-persistence/src/index.mjs b/packages/gks-persistence/src/index.mjs index 3be2c91..8059d91 100644 --- a/packages/gks-persistence/src/index.mjs +++ b/packages/gks-persistence/src/index.mjs @@ -301,6 +301,15 @@ function writeTransaction(db, fn) { return (...args) => transaction.immediate(...args); } +// ADR-GKS-GOVERNED-CALLERS D6: the authenticated caller recorded with a +// write. The service always passes it; a port-v3 caller that predates governed +// callers passes none, and every such write was MSP's. +function callerIdOf(callerId) { + if (callerId === undefined || callerId === null) return "msp-runtime"; + if (typeof callerId !== "string" || !callerId) throw new GksInvalidRequestError("callerId must be a non-empty string."); + return callerId; +} + function rowScope(row) { return { portfolioId: row.portfolio_id, @@ -450,8 +459,8 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO VALUES (@pending_id, @scope_key, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @from_candidate_ref, @relation_type, @to_candidate_ref, @from_mention_id, @to_mention_id, @confidence, @metadata_json, @provenance_ref, @promotion_idempotency_key, 'PENDING', @created_at) `); const insertPromotion = db.prepare(` - INSERT INTO promotions (scope_key, idempotency_key, knowledge_ref, source_hash, provenance_ref, candidate_json, canonical_mappings_json, graph_version, created_at) - VALUES (@scope_key, @idempotency_key, @knowledge_ref, @source_hash, @provenance_ref, @candidate_json, @canonical_mappings_json, @graph_version, @created_at) + INSERT INTO promotions (scope_key, idempotency_key, knowledge_ref, source_hash, provenance_ref, caller_id, candidate_json, canonical_mappings_json, graph_version, created_at) + VALUES (@scope_key, @idempotency_key, @knowledge_ref, @source_hash, @provenance_ref, @caller_id, @candidate_json, @canonical_mappings_json, @graph_version, @created_at) `); // ------------------------------------------------------------------------- @@ -465,11 +474,11 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO const nextEvidenceCursor = db.prepare("UPDATE graph_state SET evidence_cursor = evidence_cursor + 1 WHERE singleton = 1 RETURNING evidence_cursor"); const currentEvidenceCursor = db.prepare("SELECT evidence_cursor FROM graph_state WHERE singleton = 1"); const insertStageEvidence = db.prepare(` - INSERT INTO stage_evidence (evidence_id, cursor, scope_key, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, pipeline_stage_id, pipeline_definition_id, execution_contract_id, run_id, provenance_ref, evidence_json, metrics_json, records_json, produced_at) - VALUES (@evidence_id, @cursor, @scope_key, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @pipeline_stage_id, @pipeline_definition_id, @execution_contract_id, @run_id, @provenance_ref, @evidence_json, @metrics_json, @records_json, @produced_at) + INSERT INTO stage_evidence (evidence_id, cursor, scope_key, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, pipeline_stage_id, pipeline_definition_id, execution_contract_id, run_id, provenance_ref, caller_id, evidence_json, metrics_json, records_json, produced_at) + VALUES (@evidence_id, @cursor, @scope_key, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @pipeline_stage_id, @pipeline_definition_id, @execution_contract_id, @run_id, @provenance_ref, @caller_id, @evidence_json, @metrics_json, @records_json, @produced_at) `); - function recordStageEvidence({ evidenceId, scope, scopeKey: scopeKeyValue, pipelineStageId, runId, provenanceRef, evidence, metrics, records, producedAt }) { + function recordStageEvidence({ evidenceId, scope, scopeKey: scopeKeyValue, pipelineStageId, runId, provenanceRef, callerId, evidence, metrics, records, producedAt }) { const cursor = nextEvidenceCursor.get().evidence_cursor; insertStageEvidence.run({ evidence_id: evidenceId, @@ -486,6 +495,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO execution_contract_id: KNOWLEDGE_INGESTION_CONTRACT_ID, run_id: runId ?? null, provenance_ref: provenanceRef, + caller_id: callerIdOf(callerId), // Always an object and always an array: one representation for // "nothing here", so a puller branches on one shape (ledger ADR D2). evidence_json: JSON.stringify(evidence ?? {}), @@ -739,6 +749,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO knowledge_ref: input.knowledgeRef, source_hash: input.sourceHash, provenance_ref: input.provenanceRef, + caller_id: callerIdOf(input.callerId), candidate_json: JSON.stringify(input.candidate), canonical_mappings_json: JSON.stringify(input.canonicalMappings), graph_version: graphVersion, @@ -761,6 +772,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO pipelineStageId: input.stageEvidence.pipelineStageId ?? ENTITY_RESOLVE_STAGE_ID, runId: input.stageEvidence.runId ?? null, provenanceRef: input.provenanceRef, + callerId: input.callerId, evidence, metrics: { ...metrics, @@ -851,8 +863,8 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO const selectPendingByMention = db.prepare("SELECT * FROM pending_relations WHERE status = 'PENDING' AND (from_mention_id = ? OR to_mention_id = ?) ORDER BY pending_id"); const markPendingMaterialized = db.prepare("UPDATE pending_relations SET status = 'MATERIALIZED', materialized_ref = @materialized_ref WHERE pending_id = @pending_id"); const insertHumanResolution = db.prepare(` - INSERT INTO human_resolutions (decision_id, action, scope_key, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, mention_id, canonical_ref, superseded_ref, provenance_ref, graph_version, created_at) - VALUES (@decision_id, @action, @scope_key, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @mention_id, @canonical_ref, @superseded_ref, @provenance_ref, @graph_version, @created_at) + INSERT INTO human_resolutions (decision_id, action, scope_key, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, mention_id, canonical_ref, superseded_ref, provenance_ref, caller_id, graph_version, created_at) + VALUES (@decision_id, @action, @scope_key, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @mention_id, @canonical_ref, @superseded_ref, @provenance_ref, @caller_id, @graph_version, @created_at) `); // The review-listing predicate as a row check: the write may act on @@ -995,6 +1007,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO pipelineStageId: ENTITY_RESOLVE_STAGE_ID, runId: null, provenanceRef: input.provenanceRef, + callerId: input.callerId, evidence: { action: "BIND", strategy: "HUMAN", outcome: "MATCHED", mention_id: mention.mention_id, canonical_ref: target.canonical_ref, decision_id: bindDecisionId, graph_version: graphVersion, materialized_relations: materializedRelations.length }, metrics: { records_in: 1, records_out: 1 }, records: [], @@ -1014,6 +1027,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO canonical_ref: target.canonical_ref, superseded_ref: null, provenance_ref: input.provenanceRef, + caller_id: callerIdOf(input.callerId), graph_version: graphVersion, created_at: now, }); @@ -1096,6 +1110,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO pipelineStageId: ENTITY_RESOLVE_STAGE_ID, runId: null, provenanceRef: input.provenanceRef, + callerId: input.callerId, evidence: { action: "MERGE", strategy: "HUMAN", outcome: "MATCHED", canonical_ref: survivor.canonical_ref, superseded_ref: loser.canonical_ref, decision_id: mergeDecisionId, graph_version: graphVersion, repointed_relations: repointedRelations.length, removed_duplicate_relations: removedDuplicateRelations.length }, metrics: { records_in: 2, records_out: 1 }, records: [], @@ -1115,6 +1130,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO canonical_ref: survivor.canonical_ref, superseded_ref: loser.canonical_ref, provenance_ref: input.provenanceRef, + caller_id: callerIdOf(input.callerId), graph_version: graphVersion, created_at: now, }); @@ -1129,8 +1145,8 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO }); const insertArtifactLink = db.prepare(` - INSERT INTO artifact_links (canonical_ref, scope_key, knowledge_ref, artifact_ref, relation_type, evidence_ref, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, graph_version, created_at) - VALUES (@canonical_ref, @scope_key, @knowledge_ref, @artifact_ref, @relation_type, @evidence_ref, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @graph_version, @created_at) + INSERT INTO artifact_links (canonical_ref, scope_key, knowledge_ref, artifact_ref, relation_type, evidence_ref, caller_id, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, graph_version, created_at) + VALUES (@canonical_ref, @scope_key, @knowledge_ref, @artifact_ref, @relation_type, @evidence_ref, @caller_id, @portfolio_id, @tenant_id, @business_id, @workspace_id, @project_id, @sharing, @graph_version, @created_at) ON CONFLICT(scope_key, knowledge_ref, artifact_ref, relation_type) DO NOTHING `); const selectArtifactLink = db.prepare("SELECT * FROM artifact_links WHERE scope_key = ? AND knowledge_ref = ? AND artifact_ref = ? AND relation_type = ?"); @@ -1146,6 +1162,7 @@ export function openSqlitePersistence({ dbPath, migrationsDir = DEFAULT_MIGRATIO artifact_ref: input.artifactRef, relation_type: input.relationType, evidence_ref: input.evidenceRef, + caller_id: callerIdOf(input.callerId), portfolio_id: input.scope.portfolioId, tenant_id: input.scope.tenantId, business_id: input.scope.businessId, diff --git a/tests/contract/governed-callers.test.mjs b/tests/contract/governed-callers.test.mjs new file mode 100644 index 0000000..e48772d --- /dev/null +++ b/tests/contract/governed-callers.test.mjs @@ -0,0 +1,220 @@ +// @req ADR-GKS-GOVERNED-CALLERS — a system with its own auth and memory is a +// governed caller: it writes to the portfolios it owns, under its own +// provenance namespace, over the private HTTP transport, without MSP. MSP +// stays the built-in governed caller and loses only the portfolios another +// caller owns. +import { afterEach, describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + GKS_CALLER_AUTH_VERSION, + GKS_MSP_AUTH_VERSION, + authorizeGovernedCallerRequest, + hashGksClientCredential, + mspScopeDigest, + parseGksClientGrants, + scopeKey, +} from "@freshair129/gks-contracts"; +import { createGksService } from "@freshair129/gks-core"; +import { openSqlitePersistence } from "@freshair129/gks-persistence"; +import { promotion, scope } from "../fixtures/candidates.mjs"; +import { runHttpServer } from "../../apps/gks-server/src/http-server.mjs"; + +const cleanups = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()(); +}); + +const credential = (fill) => `gksc_${Buffer.alloc(32, fill).toString("base64url")}`; +const ACME = credential(41); +const OTHER = credential(42); +const MSP_SECRET = "governed-msp-secret"; +const acmeScope = scope({ portfolioId: "acme-portfolio", tenantId: "acme-tenant" }); +const mspScope = scope({ portfolioId: "portfolio-zuri" }); +const LEGACY_TOOLS = ["gks_knowledge_promote", "gks_search", "gks_entity_get", "gks_relations_get", "gks_artifact_link", "gks_review_list", "gks_review_apply", "gks_stage_evidence_export"]; + +function governed(overrides = {}) { + return { profile: "governed", clientId: "acme-backend", credentialSha256: hashGksClientCredential(ACME), provenanceNamespace: "acme", portfolioIds: ["acme-portfolio"], allowedTools: LEGACY_TOOLS, ...overrides }; +} + +function grants(...clients) { + return { schemaVersion: "gks-client-grants/v2", clients }; +} + +describe("grants v2: the governed profile", () => { + it("parses a governed entry alongside a read entry, and v1 documents unchanged", () => { + const parsed = parseGksClientGrants(grants( + governed(), + { profile: "read", clientId: "reporting", credentialSha256: hashGksClientCredential(OTHER), allowedTools: ["gks_search"], scopes: [mspScope] }, + )); + expect(parsed.map((grant) => [grant.profile, grant.clientId])).toEqual([["governed", "acme-backend"], ["read", "reporting"]]); + expect(parsed[0]).toMatchObject({ provenanceNamespace: "acme", portfolioIds: ["acme-portfolio"] }); + const v1 = parseGksClientGrants({ schemaVersion: "gks-client-grants/v1", clients: [{ clientId: "reporting", credentialSha256: hashGksClientCredential(OTHER), allowedTools: ["gks_search"], scopes: [mspScope] }] }); + expect(v1[0]).toMatchObject({ profile: "read", clientId: "reporting" }); + }); + + it.each([ + ["the reserved msp namespace", [governed({ provenanceNamespace: "msp" })]], + ["the msp-runtime client id", [governed({ clientId: "msp-runtime" })]], + ["a namespace claimed twice", [governed(), governed({ clientId: "other", credentialSha256: hashGksClientCredential(OTHER), portfolioIds: ["other-portfolio"] })]], + ["a portfolio claimed twice", [governed(), governed({ clientId: "other", credentialSha256: hashGksClientCredential(OTHER), provenanceNamespace: "other" })]], + ["a pipeline tool", [governed({ allowedTools: ["gks_pipeline_submit"] })]], + ["no portfolios", [governed({ portfolioIds: [] })]], + ["a malformed namespace", [governed({ provenanceNamespace: "Acme Corp" })]], + ["read-profile scopes on a governed entry", [{ ...governed(), scopes: [acmeScope] }]], + ["an unknown profile", [governed({ profile: "admin" })]], + ])("fails startup on %s", (_label, clients) => { + expect(() => parseGksClientGrants(grants(...clients))).toThrow(TypeError); + }); +}); + +describe("authorizeGovernedCallerRequest", () => { + const [grant] = parseGksClientGrants(grants(governed())); + const meta = (overrides = {}) => ({ gksCallerAuth: { version: GKS_CALLER_AUTH_VERSION, callerId: "acme-backend", scopeDigest: mspScopeDigest(acmeScope), ...overrides } }); + const promote = promotion({ scope: acmeScope, provenance_ref: "acme:proof/p-1" }); + + it("returns the caller context for a call inside its boundary and namespace", () => { + expect(authorizeGovernedCallerRequest(grant, meta(), { toolName: "gks_knowledge_promote", args: promote })).toMatchObject({ callerId: "acme-backend", provenanceNamespace: "acme" }); + }); + + it.each([ + ["a tool it was not granted", () => authorizeGovernedCallerRequest(parseGksClientGrants(grants(governed({ allowedTools: ["gks_search"] })))[0], meta(), { toolName: "gks_knowledge_promote", args: promote })], + ["a pipeline tool", () => authorizeGovernedCallerRequest(grant, meta(), { toolName: "gks_pipeline_submit", args: promote })], + ["a missing envelope", () => authorizeGovernedCallerRequest(grant, {}, { toolName: "gks_knowledge_promote", args: promote })], + ["a stale envelope version", () => authorizeGovernedCallerRequest(grant, meta({ version: GKS_MSP_AUTH_VERSION }), { toolName: "gks_knowledge_promote", args: promote })], + ["another caller's id", () => authorizeGovernedCallerRequest(grant, meta({ callerId: "msp-runtime" }), { toolName: "gks_knowledge_promote", args: promote })], + ["a portfolio it does not own", () => authorizeGovernedCallerRequest(grant, meta({ scopeDigest: mspScopeDigest(mspScope) }), { toolName: "gks_search", args: { query: "x", scope: mspScope } })], + ["a scope digest for another scope", () => authorizeGovernedCallerRequest(grant, meta({ scopeDigest: mspScopeDigest({ ...acmeScope, tenantId: "t2" }) }), { toolName: "gks_knowledge_promote", args: promote })], + ["MSP's provenance namespace", () => authorizeGovernedCallerRequest(grant, meta(), { toolName: "gks_knowledge_promote", args: { ...promote, provenance_ref: "msp:proof/p-1" } })], + ["another namespace on a review", () => authorizeGovernedCallerRequest(grant, meta(), { toolName: "gks_review_apply", args: { action: "BIND", scope: acmeScope, provenanceRef: "other:proof/h-1" } })], + ["another namespace on an artifact link", () => authorizeGovernedCallerRequest(grant, meta(), { toolName: "gks_artifact_link", args: { scope: acmeScope, evidenceRef: "msp:proof/a-1" } })], + ])("denies %s", (_label, call) => { + expect(call).toThrow(expect.objectContaining({ code: "gks_scope_denied" })); + }); +}); + +async function startServer(clientGrants) { + const dir = mkdtempSync(path.join(tmpdir(), "gks-governed-")); + const dbPath = path.join(dir, "gks.sqlite"); + const grantsPath = path.join(dir, "client-grants.json"); + writeFileSync(grantsPath, JSON.stringify(clientGrants), "utf8"); + const app = runHttpServer({ + env: { GKS_DB_PATH: dbPath, GKS_MSP_AUTH_REQUIRED: "1", GKS_MSP_RELAY_CREDENTIAL: MSP_SECRET, GKS_CLIENT_GRANTS_PATH: grantsPath, GKS_PIPELINE_RELAY_CREDENTIAL: "governed-pipeline-secret" }, + host: "127.0.0.1", + port: 0, + }); + await app.ready; + cleanups.push(async () => { + await app.close(); + rmSync(dir, { recursive: true, force: true }); + }); + return { base: `http://127.0.0.1:${app.server.address().port}`, dbPath }; +} + +let nextId = 1; +async function call(base, bearer, name, args, meta) { + const response = await fetch(`${base}/mcp`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args, ...(meta ? { _meta: meta } : {}) } }), + }); + return (await response.json()).result; +} + +const asAcme = (requestScope = acmeScope) => ({ gksCallerAuth: { version: GKS_CALLER_AUTH_VERSION, callerId: "acme-backend", scopeDigest: mspScopeDigest(requestScope) } }); +const asMsp = (requestScope) => ({ gksMspAuth: { version: GKS_MSP_AUTH_VERSION, principalId: "msp-runtime", role: "msp", scopeDigest: mspScopeDigest(requestScope) } }); + +function rows(dbPath, sql, ...params) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare(sql).all(...params); + } finally { + db.close(); + } +} + +describe("a governed caller over HTTP", () => { + it("writes and reads inside its own portfolio, attributed to itself, without MSP", async () => { + const { base, dbPath } = await startServer(grants(governed())); + const promoted = await call(base, ACME, "gks_knowledge_promote", promotion({ idempotency_key: "acme-1", scope: acmeScope, provenance_ref: "acme:proof/acme-1" }), asAcme()); + expect(promoted.isError).toBeUndefined(); + const ref = promoted.structuredContent.canonical_mappings[0].canonicalRef; + const found = await call(base, ACME, "gks_search", { query: "LINE", scope: acmeScope }, asAcme()); + expect(found.structuredContent.map((entity) => entity.canonicalRef)).toContain(ref); + const linked = await call(base, ACME, "gks_artifact_link", { knowledgeRef: ref, artifactRef: "artifact:acme/spec", relationType: "DESCRIBED_BY", evidenceRef: "acme:proof/link-1", scope: acmeScope }, asAcme()); + expect(linked.isError).toBeUndefined(); + + expect(rows(dbPath, "SELECT caller_id, provenance_ref FROM promotions")).toEqual([{ caller_id: "acme-backend", provenance_ref: "acme:proof/acme-1" }]); + expect(rows(dbPath, "SELECT caller_id FROM stage_evidence")).toEqual([{ caller_id: "acme-backend" }]); + expect(rows(dbPath, "SELECT caller_id, evidence_ref FROM artifact_links")).toEqual([{ caller_id: "acme-backend", evidence_ref: "acme:proof/link-1" }]); + }); + + it("is denied outside its boundary, under another namespace, and on pipeline tools", async () => { + const { base, dbPath } = await startServer(grants(governed())); + const denied = { isError: true, structuredContent: { code: "gks_scope_denied" } }; + expect(await call(base, ACME, "gks_search", { query: "LINE", scope: mspScope }, asAcme(mspScope))).toMatchObject(denied); + expect(await call(base, ACME, "gks_knowledge_promote", promotion({ idempotency_key: "acme-msp-ns", scope: acmeScope, provenance_ref: "msp:proof/forged" }), asAcme())).toMatchObject(denied); + expect(await call(base, ACME, "gks_knowledge_promote", promotion({ idempotency_key: "acme-no-envelope", scope: acmeScope, provenance_ref: "acme:proof/x" }))).toMatchObject(denied); + expect(await call(base, ACME, "gks_pipeline_claim", { scope: acmeScope }, asAcme())).toMatchObject(denied); + expect(rows(dbPath, "SELECT COUNT(*) AS n FROM promotions")).toEqual([{ n: 0 }]); + }); + + it("takes its portfolios away from MSP, which keeps every other portfolio", async () => { + const { base, dbPath } = await startServer(grants(governed())); + const onAcme = await call(base, MSP_SECRET, "gks_knowledge_promote", promotion({ idempotency_key: "msp-on-acme", scope: acmeScope }), asMsp(acmeScope)); + expect(onAcme).toMatchObject({ isError: true, structuredContent: { code: "gks_scope_denied", message: "This portfolio is governed by another caller." } }); + const onZuri = await call(base, MSP_SECRET, "gks_knowledge_promote", promotion({ idempotency_key: "msp-on-zuri", scope: mspScope }), asMsp(mspScope)); + expect(onZuri.isError).toBeUndefined(); + expect(rows(dbPath, "SELECT caller_id, idempotency_key FROM promotions")).toEqual([{ caller_id: "msp-runtime", idempotency_key: "msp-on-zuri" }]); + }); +}); + +describe("caller attribution (migration 0008)", () => { + it("backfills every earlier write as msp-runtime", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "gks-governed-backfill-")); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + const dbPath = path.join(dir, "gks.sqlite"); + + // A store from before governed callers, seeded at migration 0001 the way + // stage9-migration.test.mjs does, holding a promotion written the old + // way. Opening it applies 0002-0008, hooks included. + const raw = new Database(dbPath); + raw.exec(readFileSync(path.resolve("migrations/0001_init.sql"), "utf8")); + raw.exec("CREATE TABLE schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); + raw.prepare("INSERT INTO schema_migrations (name, applied_at) VALUES ('0001_init.sql', '2026-08-01T00:00:00.000Z')").run(); + const seeded = scope(); + const key = scopeKey(seeded); + const canonicalRef = `gks:entity/pre-${"0".repeat(32)}`; + raw.prepare(`INSERT INTO entities (canonical_ref, scope_key, candidate_ref, type, title, summary, source_ref, confidence, portfolio_id, tenant_id, business_id, workspace_id, project_id, sharing, metadata_json, created_at, updated_at, graph_version) + VALUES (?, ?, 'PRE', 'ENTITY', 'Pre', 'Seeded.', NULL, NULL, ?, ?, ?, ?, ?, ?, '{}', '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z', 'gks:graph/1')`) + .run(canonicalRef, key, seeded.portfolioId, seeded.tenantId, seeded.businessId, seeded.workspaceId, seeded.projectId, seeded.sharing); + raw.prepare(`INSERT INTO promotions (scope_key, idempotency_key, knowledge_ref, source_hash, provenance_ref, candidate_json, canonical_mappings_json, graph_version, created_at) + VALUES (?, 'pre-0008', 'gks:knowledge/gks_knowledge_pre', ?, 'msp:proof/pre', ?, ?, 'gks:graph/1', '2026-08-01T00:00:00.000Z')`) + .run(key, "a".repeat(64), JSON.stringify({ entities: [{ candidateRef: "PRE", type: "ENTITY", title: "Pre" }] }), JSON.stringify([{ candidateRef: "PRE", canonicalRef, canonicalType: "ENTITY" }])); + raw.prepare("UPDATE graph_state SET version = 1 WHERE singleton = 1").run(); + raw.close(); + + openSqlitePersistence({ dbPath }).close(); + expect(rows(dbPath, "SELECT idempotency_key, caller_id FROM promotions")).toEqual([{ idempotency_key: "pre-0008", caller_id: "msp-runtime" }]); + // Migration 0005 backfilled a stage-evidence row for it; that row is MSP's too. + expect(rows(dbPath, "SELECT caller_id FROM stage_evidence")).toEqual([{ caller_id: "msp-runtime" }]); + for (const table of ["promotions", "stage_evidence", "human_resolutions", "artifact_links"]) { + expect(rows(dbPath, `SELECT "notnull" AS required, dflt_value AS fallback FROM pragma_table_info('${table}') WHERE name = 'caller_id'`), table).toEqual([{ required: 1, fallback: "'msp-runtime'" }]); + } + }); + + it("records msp-runtime for every write that comes through MSP", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "gks-governed-msp-")); + const dbPath = path.join(dir, "gks.sqlite"); + const persistence = openSqlitePersistence({ dbPath }); + cleanups.push(() => { + persistence.close(); + rmSync(dir, { recursive: true, force: true }); + }); + await createGksService({ persistence }).promoteCandidate(promotion({ idempotency_key: "via-msp" })); + expect(rows(dbPath, "SELECT caller_id FROM promotions")).toEqual([{ caller_id: "msp-runtime" }]); + expect(rows(dbPath, "SELECT caller_id FROM stage_evidence")).toEqual([{ caller_id: "msp-runtime" }]); + }); +}); diff --git a/tests/fixtures/baseline-lock.json b/tests/fixtures/baseline-lock.json index 77f5bcc..82f77f9 100644 --- a/tests/fixtures/baseline-lock.json +++ b/tests/fixtures/baseline-lock.json @@ -1,7 +1,7 @@ { "lockVersion": "gks-baseline-lock/v1", "lockedAt": "2026-09-27", - "lockedFromCommit": "96a28a0a53b44192de73d471ceadd433acdc5630+working-tree", + "lockedFromCommit": "5c06f0cf68079787c2db92c68a35c190ac2132e8+working-tree", "nodeEngine": ">=22", "packageLockSha256": "6ccaf0dddbb445c26554df5d391a3b55900de5d0e6fabd73b0bbbfd78b3cb71f", "workflowSha256": "c15b8c35bff3baf2d643f0e309cc74a209baf5985d78944386510aadb7ec8eb3", @@ -33,6 +33,10 @@ { "name": "0007_pipeline_entity_origin.sql", "sha256": "c6a4f3b8c9a1a7544be9424788fe0e9346c600e3358a803e394a20eed9eee985" + }, + { + "name": "0008_caller_attribution.sql", + "sha256": "59a12c39c093e8d7f3dfd6aac6842f1d03f071cc29a782efe29f667c786ea46b" } ], "tools": [ @@ -54,5 +58,5 @@ "gks_pipeline_publication_receipt", "gks_pipeline_evidence" ], - "toolRegistrySha256": "f91dea5545557ee33313325f9bd2001473586a21735383c6386faf1eb7f43131" + "toolRegistrySha256": "10791ea8b32caafe4c452f13e51cc991cd7aaf8950ba5c4fa016ff83dddf6d8f" } diff --git a/tests/fixtures/c0-qualification/expected/C0.4-TOOL-001.json b/tests/fixtures/c0-qualification/expected/C0.4-TOOL-001.json index c1d93ca..6e38909 100644 --- a/tests/fixtures/c0-qualification/expected/C0.4-TOOL-001.json +++ b/tests/fixtures/c0-qualification/expected/C0.4-TOOL-001.json @@ -62,7 +62,7 @@ }, "provenance_ref": { "type": "string", - "pattern": "^msp:proof/" + "pattern": "^[a-z][a-z0-9-]{0,30}:proof/" }, "candidate": { "type": "object" @@ -159,7 +159,7 @@ }, "provenanceRef": { "type": "string", - "pattern": "^msp:proof/" + "pattern": "^[a-z][a-z0-9-]{0,30}:proof/" }, "scope": { "type": "object" diff --git a/tests/fixtures/c0-qualification/registry.json b/tests/fixtures/c0-qualification/registry.json index e35b76d..3d60b76 100644 --- a/tests/fixtures/c0-qualification/registry.json +++ b/tests/fixtures/c0-qualification/registry.json @@ -40,7 +40,7 @@ "fixture": "cases/C0.4-TOOL-001.json", "expectedResult": "expected/C0.4-TOOL-001.json", "requestSha256": "3d15c56992c2b3fcab6dff586a66778f4259ec3e7f56ad34f199a63ee4d211b5", - "expectedResultSha256": "812225259f9b3e5fa4cfb90b213bf7170d11bda82bb6457b36dfdfe3a9cf4ac8", + "expectedResultSha256": "891ca0329e26b476d4d11e830f1dd2a79b0370de2d9b6987f2c3a99f6ef90bdb", "normalizedScope": { "portfolioId": "c0-portfolio", "tenantId": "c0-tenant", diff --git a/tests/fixtures/c0-qualification/result-manifest.json b/tests/fixtures/c0-qualification/result-manifest.json index 42a0c78..191ba09 100644 --- a/tests/fixtures/c0-qualification/result-manifest.json +++ b/tests/fixtures/c0-qualification/result-manifest.json @@ -3,8 +3,8 @@ "registryVersion": "c0-qualification/v2", "fixtureId": "gks-c0.4-golden", "recordedAt": "2026-09-27", - "productSha": "e8a6f0aa0323e2e7a03a86174805311218f27a79+working-tree", - "corpusSha": "e8a6f0aa0323e2e7a03a86174805311218f27a79+working-tree", + "productSha": "5c06f0cf68079787c2db92c68a35c190ac2132e8+working-tree", + "corpusSha": "5c06f0cf68079787c2db92c68a35c190ac2132e8+working-tree", "runtime": { "node": "24.19.0", "platform": "win32",