From e215652038e3c367e4a01e7d67dd8256e21ebd2f Mon Sep 17 00:00:00 2001 From: donmasakayan <1178461+donmasakayan@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:24:48 +1300 Subject: [PATCH] feat: add delegated credential lifecycle and private storage --- packages/core/sdk/src/core-schema.ts | 129 + packages/core/sdk/src/delegation.test.ts | 627 ++++ packages/core/sdk/src/delegation.ts | 2882 +++++++++++++++++ packages/core/sdk/src/fuma-runtime.ts | 22 +- packages/core/sdk/src/ids.ts | 12 + packages/core/sdk/src/index.ts | 49 + .../core/sdk/src/pending-approval.test.ts | 51 + packages/core/sdk/src/pending-approval.ts | 58 +- .../core/sdk/src/subject-registry.test.ts | 72 +- packages/core/sdk/src/subject-registry.ts | 158 +- 10 files changed, 4050 insertions(+), 10 deletions(-) create mode 100644 packages/core/sdk/src/delegation.test.ts create mode 100644 packages/core/sdk/src/delegation.ts diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..3bf360bb85 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -197,6 +197,131 @@ export const coreTables = defineTables({ ["tenant", "external_id"], ), + // Delegated credentials are deliberately tenant-scoped and are only exposed + // through the delegation service. They are not owner rows: the row belongs + // to the grant, while the effective subject is an immutable field inside it. + // `scope` contains the exact consent snapshot; it is never re-expanded from + // mutable toolkit rows during dispatch. + credential_delegation: (() => { + const out = tenantExecutorTable( + "credential_delegation", + { + id: keyColumn("id"), + label: keyColumn("label"), + created_by: keyColumn("created_by"), + credential_id: keyColumn("credential_id"), + credential_kind: keyColumn("credential_kind"), + adapter: keyColumn("adapter"), + issuer: textColumn("issuer"), + audience: textColumn("audience"), + credential_subject: textColumn("credential_subject"), + credential_ref: keyColumn("credential_ref"), + issued_key_ref: nullableKeyColumn("issued_key_ref"), + key_hash: nullableKeyColumn("key_hash"), + actor_subject: keyColumn("actor_subject"), + effective_subject: keyColumn("effective_subject"), + toolkit_membership: jsonColumn("toolkit_membership"), + scope: jsonColumn("scope"), + scope_version: keyColumn("scope_version"), + expires_at: bigintColumn("expires_at"), + revoked_at: nullableBigintColumn("revoked_at"), + revoke_reason: nullableTextColumn("revoke_reason"), + created_at: dateColumn("created_at"), + last_used_at: nullableBigintColumn("last_used_at"), + }, + ["tenant", "id"], + ); + out.unique("credential_delegation_binding_uidx", [ + "tenant", + "adapter", + "issuer", + "audience", + "credential_subject", + "credential_ref", + ]); + // Provider/local key references are the durable revocation and rotation + // handle. Keep them unique inside a tenant even when a malformed adapter + // attempts to present the same reference under a different tuple. + out.unique("credential_delegation_issued_key_ref_uidx", ["tenant", "issued_key_ref"]); + return out; + })(), + + // Single-use proof challenges. Only the digest is persisted; proof material + // is supplied by the host adapter and is never written to this table. + delegation_challenge: tenantExecutorTable( + "delegation_challenge", + { + id: keyColumn("id"), + created_by: keyColumn("created_by"), + label: keyColumn("label"), + credential_id: nullableKeyColumn("credential_id"), + credential_kind: nullableKeyColumn("credential_kind"), + adapter: keyColumn("adapter"), + issuer: nullableTextColumn("issuer"), + audience: nullableTextColumn("audience"), + credential_subject: nullableTextColumn("credential_subject"), + credential_ref: nullableKeyColumn("credential_ref"), + actor_subject: nullableKeyColumn("actor_subject"), + effective_subject: keyColumn("effective_subject"), + scope: jsonColumn("scope"), + proof_hash: keyColumn("proof_hash"), + expires_at: bigintColumn("expires_at"), + grant_expires_at: bigintColumn("grant_expires_at"), + proof_consumed_at: nullableBigintColumn("proof_consumed_at"), + consent_consumed_at: nullableBigintColumn("consent_consumed_at"), + created_at: dateColumn("created_at"), + }, + ["tenant", "id"], + ), + + // Append-only operational audit. The service accepts identifiers and + // outcomes only; arguments, results, tokens, and authorization headers are + // intentionally absent from this schema. + delegation_audit: tenantExecutorTable( + "delegation_audit", + { + id: keyColumn("id"), + grant_id: nullableKeyColumn("grant_id"), + credential_id: nullableKeyColumn("credential_id"), + actor_subject: keyColumn("actor_subject"), + effective_subject: keyColumn("effective_subject"), + action: keyColumn("action"), + tool: nullableTextColumn("tool"), + outcome: keyColumn("outcome"), + request_id: nullableTextColumn("request_id"), + execution_id: nullableTextColumn("execution_id"), + occurred_at: dateColumn("occurred_at"), + }, + ["tenant", "id"], + ), + + // Durable target-owned notices for grants created by another subject. The + // service writes these in the same transaction as the grant, so a notice + // failure cannot leave an active delegated credential without a notification. + delegation_notification: (() => { + const out = tenantExecutorTable( + "delegation_notification", + { + id: keyColumn("id"), + grant_id: keyColumn("grant_id"), + credential_id: keyColumn("credential_id"), + created_by: keyColumn("created_by"), + target_subject: keyColumn("target_subject"), + label: keyColumn("label"), + credential_kind: keyColumn("credential_kind"), + created_at: dateColumn("created_at"), + read_at: nullableBigintColumn("read_at"), + }, + ["tenant", "id"], + ); + out.unique("delegation_notification_grant_target_uidx", [ + "tenant", + "grant_id", + "target_subject", + ]); + return out; + })(), + // THE saved credential, one per (owner, integration, name). Resolves each named // input via `provider` + the `item_ids` map (variable → provider item id). A // single-secret connection is `{ "token": }`; an apiKey method with two @@ -444,6 +569,10 @@ export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow; export type SubjectRow = FumaRow; +export type CredentialDelegationRow = FumaRow; +export type DelegationChallengeRow = FumaRow; +export type DelegationAuditRow = FumaRow; +export type DelegationNotificationRow = FumaRow; export type ConnectionRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; diff --git a/packages/core/sdk/src/delegation.test.ts b/packages/core/sdk/src/delegation.test.ts new file mode 100644 index 0000000000..384e34b962 --- /dev/null +++ b/packages/core/sdk/src/delegation.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; + +import { collectTables } from "./executor"; +import { makeDelegationService } from "./delegation"; +import { DelegationDeniedError } from "./fuma-runtime"; +import { Subject, Tenant, ToolAddress } from "./ids"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; + +const CREATED_BY = Subject.make("human_creator"); +const TARGET = Subject.make("human_target"); +const MACHINE = Subject.make("machine_actor"); +const CREDENTIAL = { + kind: "external" as const, + adapter: "access", + issuer: "issuer.example", + audience: "executor", + subject: "machine-public-subject", + credentialRef: "service-token-id", +}; +const PROVIDER_CREDENTIAL = { + kind: "issued-key" as const, + adapter: "better-auth-api-key", + issuer: "https://auth.example", + audience: "tenant-a", + subject: "provider-key-subject", + credentialRef: "provider-key-1", +}; +const SCOPE = { + actions: ["discover", "invoke"] as const, + toolkitMembership: [ + { + toolkitId: "toolkit-one", + revision: "rev-1", + toolAddresses: ["org.integration.connection.tool"], + connectionAddresses: ["org.integration.connection"], + }, + ], + toolAddresses: ["org.integration.connection.tool"], + connectionAddresses: ["org.integration.connection"], +}; + +const withDb = (body: (db: SqliteTestFumaDb) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + body, + (db) => Effect.promise(() => db.close()), + ); + +const makeService = (db: SqliteTestFumaDb, tenant: string, now: () => number) => + makeDelegationService({ + db: db.db, + tenant: Tenant.make(tenant), + issuanceEnabled: true, + now, + isVerifiedHuman: () => Effect.succeed(true), + isCurrentlyAuthorized: () => Effect.succeed(true), + isManagementAuthorized: () => Effect.succeed(true), + verifyProof: () => Effect.succeed(true), + }); + +const makeProviderService = (db: SqliteTestFumaDb, now: () => number) => + makeDelegationService({ + db: db.db, + tenant: Tenant.make("tenant-a"), + issuanceEnabled: true, + now, + isVerifiedHuman: () => Effect.succeed(true), + isCurrentlyAuthorized: () => Effect.succeed(true), + isManagementAuthorized: () => Effect.succeed(true), + verifyProof: () => Effect.succeed(true), + verifyProviderCredential: () => Effect.succeed(true), + }); + +const expectDenied = ( + effect: Effect.Effect, + reason: DelegationDeniedError["reason"], +) => + effect.pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(DelegationDeniedError); + expect((error as DelegationDeniedError).reason).toBe(reason); + }), + ); + +describe("delegation service", () => { + it.effect("projects private status and rotates issued keys as tombstones", () => + withDb((db) => + Effect.gen(function* () { + const now = () => 1_700_000_000_000; + const service = makeService(db, "tenant-a", now); + const issued = yield* service.createIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Local agent", + scope: SCOPE, + }); + expect(issued.secret).toMatch(/^key_[0-9a-f]{32}\.[0-9a-f]{64}$/); + expect(issued.keyRef).toMatch(/^key_[0-9a-f]{32}$/); + const localResolved = yield* service.resolveIssuedKey(issued.secret); + expect(localResolved.grantId).toBe(issued.grant.grantId); + yield* expectDenied( + service.resolveIssuedKey(`${issued.keyRef}.${"0".repeat(64)}`), + "invalid", + ); + yield* expectDenied( + service.resolveIssuedKey(`key_${"f".repeat(32)}.${"0".repeat(64)}`), + "unmapped", + ); + const view = yield* service.get(issued.grant.grantId); + expect(view?.credentialRefMasked).not.toContain(issued.secret); + expect(view?.effectiveSubject).toBe(TARGET); + expect((yield* service.list()).length).toBe(1); + const rebound = yield* service.resolveBinding({ + tenant: issued.grant.tenant, + grantId: issued.grant.grantId, + credentialId: issued.grant.credentialId, + credentialRef: issued.grant.credentialRef, + actorSubject: issued.grant.actorSubject, + effectiveSubject: issued.grant.effectiveSubject, + scopeVersion: issued.grant.scopeVersion, + }); + expect(rebound.grantId).toBe(issued.grant.grantId); + yield* expectDenied( + service.resolveBinding({ + tenant: Tenant.make("tenant-b"), + grantId: issued.grant.grantId, + credentialId: issued.grant.credentialId, + credentialRef: issued.grant.credentialRef, + actorSubject: issued.grant.actorSubject, + effectiveSubject: issued.grant.effectiveSubject, + scopeVersion: issued.grant.scopeVersion, + }), + "invalid", + ); + const expandedScope = { + ...SCOPE, + toolAddresses: [...SCOPE.toolAddresses, "org.integration.connection.second"], + connectionAddresses: [...SCOPE.connectionAddresses, "org.integration.connection.second"], + toolkitMembership: [ + { + ...SCOPE.toolkitMembership[0]!, + toolAddresses: [ + ...SCOPE.toolkitMembership[0]!.toolAddresses, + "org.integration.connection.second", + ], + connectionAddresses: [ + ...SCOPE.toolkitMembership[0]!.connectionAddresses, + "org.integration.connection.second", + ], + }, + ], + }; + const expanded = yield* service.expand({ + credentialId: issued.grant.credentialId, + consentedBy: CREATED_BY, + scope: expandedScope, + expiresAt: now() + 86_400_000, + expectedScopeVersion: issued.grant.scopeVersion, + }); + yield* expectDenied( + service.resolveBinding({ + tenant: issued.grant.tenant, + grantId: issued.grant.grantId, + credentialId: issued.grant.credentialId, + credentialRef: issued.grant.credentialRef, + actorSubject: issued.grant.actorSubject, + effectiveSubject: issued.grant.effectiveSubject, + scopeVersion: issued.grant.scopeVersion, + }), + "scope", + ); + expect(expanded.scopeVersion).not.toBe(issued.grant.scopeVersion); + + const challenge = yield* service.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "External agent", + scope: SCOPE, + }); + const pending = yield* service.getChallenge(challenge.challengeId); + expect(pending?.status).toBe("awaiting-proof"); + expect(pending?.proofConsumedAt).toBeNull(); + expect(pending?.provenCredentialRefMasked).toBeNull(); + yield* service.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }); + const proven = yield* service.getChallenge(challenge.challengeId); + expect(proven?.status).toBe("proof-complete"); + expect(proven?.provenCredentialRefMasked).not.toBe(CREDENTIAL.credentialRef); + expect(proven?.provenActorSubject).toBe(MACHINE); + yield* service.create({ proofChallengeId: challenge.challengeId, consentedBy: CREATED_BY }); + + const rotated = yield* service.rotateIssuedKey({ + credentialId: issued.grant.credentialId, + actorSubject: CREATED_BY, + }); + yield* expectDenied(service.resolveIssuedKey(issued.secret), "revoked"); + const rotatedResolved = yield* service.resolveIssuedKey(rotated.secret); + expect(rotatedResolved.grantId).toBe(rotated.grant.grantId); + yield* expectDenied( + service.resolve({ + kind: "issued-key", + adapter: "executor", + issuer: "executor", + audience: "delegated", + subject: issued.keyRef, + credentialRef: issued.keyRef, + }), + "revoked", + ); + const resolved = yield* service.resolve({ + kind: "issued-key", + adapter: "executor", + issuer: "executor", + audience: "delegated", + subject: rotated.keyRef, + credentialRef: rotated.keyRef, + }); + expect(resolved.grantId).toBe(rotated.grant.grantId); + expect((yield* service.list({ includeRevoked: true })).length).toBe(3); + expect((yield* service.listAudit({ limit: 100 })).length).toBeGreaterThanOrEqual(6); + }), + ), + ); + + it.effect("requires machine proof before human consent and consumes both phases once", () => + withDb((db) => + Effect.gen(function* () { + let clock = 1_700_000_000_000; + const service = makeService(db, "tenant-a", () => clock); + const challenge = yield* service.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Build agent", + scope: SCOPE, + }); + + yield* expectDenied( + service.create({ proofChallengeId: challenge.challengeId, consentedBy: CREATED_BY }), + "proof", + ); + const completed = yield* service.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }); + expect(completed.actorSubject).toBe(MACHINE); + + const grant = yield* service.create({ + proofChallengeId: challenge.challengeId, + consentedBy: CREATED_BY, + }); + expect(grant.effectiveSubject).toBe(TARGET); + const resolved = yield* service.resolve(CREDENTIAL); + expect(resolved.grantId).toBe(grant.grantId); + + yield* expectDenied( + service.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }), + "proof", + ); + yield* expectDenied( + service.create({ proofChallengeId: challenge.challengeId, consentedBy: CREATED_BY }), + "proof", + ); + + clock += 1; + yield* service.revoke({ grantId: grant.grantId, actorSubject: CREATED_BY }); + const revokedView = yield* service.get(grant.grantId); + expect(revokedView?.revokedAt).toBe(clock); + yield* expectDenied( + service.revoke({ grantId: grant.grantId, actorSubject: CREATED_BY }), + "revoked", + ); + expect((yield* service.get(grant.grantId))?.revokedAt).toBe(clock); + yield* expectDenied(service.resolve(CREDENTIAL), "revoked"); + }), + ), + ); + + it.effect("persists target-owned notifications across reconstruction and tenants", () => + withDb((db) => + Effect.gen(function* () { + const now = () => 1_700_000_000_000; + const service = makeService(db, "tenant-a", now); + const issued = yield* service.createIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Notified agent", + scope: SCOPE, + }); + const first = yield* service.listNotifications({ reader: TARGET }); + expect(first).toHaveLength(1); + const notice = first[0]; + expect(notice).toBeDefined(); + if (notice === undefined) return; + expect(notice.grantId).toBe(issued.grant.grantId); + expect(notice.credentialId).toBe(issued.grant.credentialId); + expect(notice.createdBy).toBe(CREATED_BY); + expect(notice.targetSubject).toBe(TARGET); + expect(notice.label).toBe("Notified agent"); + expect(notice.credentialKind).toBe("issued-key"); + expect(notice.readAt).toBeNull(); + + expect(yield* service.listNotifications({ reader: CREATED_BY })).toHaveLength(0); + expect( + yield* makeService(db, "tenant-b", now).listNotifications({ reader: TARGET }), + ).toHaveLength(0); + const rebuilt = makeService(db, "tenant-a", now); + expect(yield* rebuilt.listNotifications({ reader: TARGET })).toHaveLength(1); + yield* expectDenied( + rebuilt.markNotificationRead({ notificationId: notice.id, reader: CREATED_BY }), + "unauthorized", + ); + yield* rebuilt.markNotificationRead({ notificationId: notice.id, reader: TARGET }); + expect(yield* rebuilt.listNotifications({ reader: TARGET })).toHaveLength(0); + const read = yield* rebuilt.listNotifications({ reader: TARGET, includeRead: true }); + expect(read[0]?.readAt).toBe(now()); + yield* rebuilt.markNotificationRead({ notificationId: notice.id, reader: TARGET }); + }), + ), + ); + + it.effect("rolls back an admin grant when notification persistence fails", () => + withDb((db) => + Effect.gen(function* () { + const service = makeService(db, "tenant-a", () => 1_700_000_000_000); + yield* Effect.promise(() => db.client.execute("DROP TABLE delegation_notification")); + const result = yield* Effect.exit( + service.createIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Missing notice table", + scope: SCOPE, + }), + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* service.list({ includeRevoked: true })).toHaveLength(0); + }), + ), + ); + + it.effect("registers and rotates provider-managed keys without storing secrets", () => + withDb((db) => + Effect.gen(function* () { + const now = () => 1_700_000_000_000; + const service = makeProviderService(db, now); + const withoutVerifier = makeService(db, "tenant-a", now); + yield* expectDenied( + withoutVerifier.registerProviderIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + actorSubject: MACHINE, + label: "Provider agent", + scope: SCOPE, + credential: PROVIDER_CREDENTIAL, + }), + "unauthorized", + ); + const first = yield* service.registerProviderIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + actorSubject: MACHINE, + label: "Provider agent", + scope: SCOPE, + credential: PROVIDER_CREDENTIAL, + }); + expect(first.credentialRef).toBe(PROVIDER_CREDENTIAL.credentialRef); + expect((yield* service.resolve(PROVIDER_CREDENTIAL)).grantId).toBe(first.grantId); + const providerNotices = yield* service.listNotifications({ reader: TARGET }); + expect(providerNotices).toHaveLength(1); + expect(providerNotices[0]?.credentialKind).toBe("issued-key"); + yield* expectDenied( + service.resolveIssuedKey(`${PROVIDER_CREDENTIAL.credentialRef}.${"0".repeat(64)}`), + "invalid", + ); + yield* expectDenied( + service.registerProviderIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + actorSubject: MACHINE, + label: "Provider agent duplicate", + scope: SCOPE, + credential: PROVIDER_CREDENTIAL, + }), + "invalid", + ); + yield* expectDenied( + service.registerProviderIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + actorSubject: Subject.make("machine_actor_other"), + label: "Provider ref collision", + scope: SCOPE, + credential: { + ...PROVIDER_CREDENTIAL, + subject: "different-provider-subject", + }, + }), + "invalid", + ); + + const replacement = { + ...PROVIDER_CREDENTIAL, + subject: "provider-key-subject-2", + credentialRef: "provider-key-2", + }; + const rotations = yield* Effect.all( + [0, 1].map(() => + Effect.exit( + service.rotateProviderIssuedKey({ + credentialId: first.credentialId, + managedBy: CREATED_BY, + actorSubject: Subject.make("machine_actor_2"), + credential: replacement, + }), + ), + ), + { concurrency: "unbounded" }, + ); + expect(rotations.filter(Exit.isSuccess)).toHaveLength(1); + const rotated = rotations.find(Exit.isSuccess)?.value; + expect(rotated).toBeDefined(); + if (rotated === undefined) return; + yield* expectDenied(service.resolve(PROVIDER_CREDENTIAL), "revoked"); + expect((yield* service.resolve(replacement)).grantId).toBe(rotated.grantId); + expect((yield* service.list({ includeRevoked: true })).length).toBe(2); + }), + ), + ); + + it.effect("serializes concurrent proof and consent claims", () => + withDb((db) => + Effect.gen(function* () { + const service = makeService(db, "tenant-a", () => 1_700_000_000_000); + const challenge = yield* service.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Concurrent agent", + scope: SCOPE, + }); + const proofAttempts = yield* Effect.all( + [0, 1].map(() => + Effect.exit( + service.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }), + ), + ), + { concurrency: "unbounded" }, + ); + expect(proofAttempts.filter(Exit.isSuccess)).toHaveLength(1); + + const consentAttempts = yield* Effect.all( + [0, 1].map(() => + Effect.exit( + service.create({ + proofChallengeId: challenge.challengeId, + consentedBy: CREATED_BY, + }), + ), + ), + { concurrency: "unbounded" }, + ); + expect(consentAttempts.filter(Exit.isSuccess)).toHaveLength(1); + expect((yield* service.getChallenge(challenge.challengeId))?.status).toBe("consented"); + }), + ), + ); + + it.effect("keeps exact credential bindings tenant-local and rejects tuple mismatches", () => + withDb((db) => + Effect.gen(function* () { + const now = () => 1_700_000_000_000; + const first = makeService(db, "tenant-a", now); + const second = makeService(db, "tenant-b", now); + const challenge = yield* first.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Agent", + scope: SCOPE, + }); + yield* first.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }); + const firstGrant = yield* first.create({ + proofChallengeId: challenge.challengeId, + consentedBy: CREATED_BY, + }); + + yield* expectDenied(second.resolve(CREDENTIAL), "unmapped"); + yield* expectDenied( + first.resolve({ ...CREDENTIAL, audience: "other-audience" }), + "invalid", + ); + const tenantTampered = { + ...firstGrant, + tenant: Tenant.make("tenant-b"), + }; + yield* expectDenied( + first.revalidate(tenantTampered, { + operation: "invoke", + tool: ToolAddress.make(SCOPE.toolAddresses[0]), + }), + "unauthorized", + ); + }), + ), + ); + + it.effect("fails closed for disabled issuance, wildcard scopes, and expiry", () => + withDb((db) => + Effect.gen(function* () { + let clock = 1_700_000_000_000; + const service = makeDelegationService({ + db: db.db, + tenant: Tenant.make("tenant-a"), + issuanceEnabled: false, + now: () => clock, + isVerifiedHuman: () => Effect.succeed(true), + isCurrentlyAuthorized: () => Effect.succeed(true), + isManagementAuthorized: () => Effect.succeed(true), + verifyProof: () => Effect.succeed(true), + }); + yield* expectDenied( + service.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Agent", + scope: { ...SCOPE, toolAddresses: ["org.*"] }, + }), + "management", + ); + + const enabled = makeService(db, "tenant-a", () => clock); + yield* expectDenied( + enabled.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Agent", + scope: { ...SCOPE, toolAddresses: ["org.*"] }, + }), + "invalid", + ); + yield* expectDenied( + enabled.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Agent", + scope: { ...SCOPE, toolkitMembership: [null] } as never, + }), + "invalid", + ); + yield* expectDenied(enabled.resolve(null as never), "invalid"); + const challenge = yield* enabled.issueProofChallenge({ + adapter: "access", + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Agent", + scope: SCOPE, + grantExpiresAt: clock + 1, + }); + yield* enabled.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: CREDENTIAL, + machineActor: MACHINE, + nonce: challenge.nonce, + }); + clock += 2; + yield* expectDenied( + enabled.create({ proofChallengeId: challenge.challengeId, consentedBy: CREATED_BY }), + "invalid", + ); + }), + ), + ); + + it.effect("bounds omitted expiry to a configured lower TTL", () => + withDb((db) => + Effect.gen(function* () { + const now = 1_700_000_000_000; + const maxTtlMs = 24 * 60 * 60 * 1000; + const service = makeDelegationService({ + db: db.db, + tenant: Tenant.make("tenant-a"), + issuanceEnabled: true, + maxTtlMs, + now: () => now, + isVerifiedHuman: () => Effect.succeed(true), + isCurrentlyAuthorized: () => Effect.succeed(true), + isManagementAuthorized: () => Effect.succeed(true), + verifyProof: () => Effect.succeed(true), + }); + const issued = yield* service.createIssuedKey({ + createdBy: CREATED_BY, + effectiveSubject: TARGET, + label: "Short lived agent", + scope: SCOPE, + }); + expect(issued.grant.expiresAt).toBe(now + maxTtlMs); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/delegation.ts b/packages/core/sdk/src/delegation.ts new file mode 100644 index 0000000000..28b526d97f --- /dev/null +++ b/packages/core/sdk/src/delegation.ts @@ -0,0 +1,2882 @@ +import { Effect, Option, Predicate, Schema } from "effect"; + +import { + DelegationDeniedError, + makeFumaClient, + StorageError, + type FumaDb, + type StorageFailure, +} from "./fuma-runtime"; +import { withQueryContext } from "@executor-js/fumadb/query"; +import type { Condition, ConditionBuilder } from "@executor-js/fumadb/query"; +import type { AnyColumn } from "@executor-js/fumadb/schema"; +import type { ExecutorOwnerPolicyContext } from "./owner-policy"; +import { + ConnectionAddress, + CredentialId, + DelegationId, + DelegationScopeVersion, + Subject, + Tenant, + ToolAddress, +} from "./ids"; + +export const DEFAULT_DELEGATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; +export const MAX_DELEGATION_TTL_MS = 90 * 24 * 60 * 60 * 1000; +export const PROOF_CHALLENGE_TTL_MS = 5 * 60 * 1000; + +export type DelegationScopeAction = "discover" | "invoke"; + +/** A verified credential after a host adapter has checked its signature and + * issuer. The SDK deliberately knows no provider-specific credential shape. */ +export interface VerifiedDelegatedCredential { + readonly adapter: string; + readonly issuer: string; + readonly audience: string; + readonly subject: string; + /** Stable provider key id/reference; never the bearer secret. */ + readonly credentialRef: string; + readonly kind: "external" | "issued-key"; +} + +/** Exact immutable membership captured at consent. Wildcards are forbidden. */ +export interface DelegationScope { + /** Closed v1 action set. Discovery covers catalog/schema; invoke also covers + * approval and resume continuations for an already-authorized invocation. */ + readonly actions: readonly DelegationScopeAction[]; + readonly toolkitMembership: readonly { + readonly toolkitId: string; + readonly revision: string; + readonly toolAddresses: readonly string[]; + readonly connectionAddresses: readonly string[]; + }[]; + readonly toolAddresses: readonly string[]; + /** Omitted means the frozen toolkit connection membership; [] denies all. */ + readonly connectionAddresses?: readonly string[]; +} + +export type DelegationOperation = + | "catalog" + | "schema" + | "invoke" + | "approval" + | "resume" + | "management"; + +export interface DelegationCapability { + readonly operation: DelegationOperation; + readonly tool?: ToolAddress; + readonly connection?: ConnectionAddress; + readonly requestId?: string; + readonly executionId?: string; +} + +export interface DelegatedPrincipal { + readonly kind: "delegated"; + readonly tenant: Tenant; + readonly grantId: DelegationId; + readonly credentialId: CredentialId; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly scopeVersion: DelegationScopeVersion; + readonly credentialRef: string; +} + +/** Serializable request/session binding. Hosts may persist this metadata, but + * the SDK always resolves it back to the durable row before authorizing. */ +export interface DelegationBinding { + readonly tenant: Tenant; + readonly grantId: DelegationId; + readonly credentialId: CredentialId; + readonly credentialRef: string; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly scopeVersion: DelegationScopeVersion; +} + +export interface DelegationContext extends DelegatedPrincipal { + readonly expiresAt: number; + readonly scope: DelegationScope; + /** Live check. A context is never authorization evidence by itself. */ + readonly revalidate: ( + capability: DelegationCapability, + ) => Effect.Effect; +} + +export type DelegationGrant = DelegationContext; + +export interface DelegationStoreOptions { + readonly db: FumaDb; + readonly tenant: Tenant; + readonly issuanceEnabled?: boolean; + readonly now?: () => number; + /** Host-authoritative membership and disabled-user check. */ + readonly isVerifiedHuman: (subject: Subject) => Effect.Effect; + /** Re-checks current actor/target authorization on every request. */ + readonly isCurrentlyAuthorized: ( + grant: Pick, + ) => Effect.Effect; + /** Human-session authorization for grant creation/revocation. */ + readonly isManagementAuthorized: ( + actorSubject: Subject, + targetSubject?: Subject, + ) => Effect.Effect; + /** Operator-configurable upper bound; defaults to 90 days. */ + readonly maxTtlMs?: number; + /** Host adapter verification is mandatory; nonce-only fallback is forbidden. */ + readonly verifyProof: (input: { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: Subject; + readonly nonce: string; + }) => Effect.Effect; + /** Provider-managed key registration is a trusted host boundary. The host + * must verify that the provider key exists, belongs to the requested target, + * and is usable by the supplied machine actor; omission fails closed. */ + readonly verifyProviderCredential?: (input: { + readonly credential: VerifiedDelegatedCredential; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }) => Effect.Effect; +} + +export interface CreateDelegationInput { + /** Human consent activates a previously proven challenge. */ + readonly proofChallengeId: string; + readonly consentedBy: Subject; + readonly scopeVersion?: DelegationScopeVersion; +} + +export interface DelegationAuditEvent { + readonly grantId?: DelegationId; + readonly credentialId?: CredentialId; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly action: string; + readonly tool?: ToolAddress; + readonly outcome: "allowed" | "denied" | "created" | "changed" | "revoked" | "challenge"; + readonly requestId?: string; + readonly executionId?: string; +} + +export interface ProofChallenge { + readonly challengeId: string; + readonly nonce: string; + readonly adapter: string; + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly grantExpiresAt: number; + readonly expiresAt: number; +} + +export interface ProofCompletion { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly scope: DelegationScope; +} + +export interface DelegationGrantView { + readonly grantId: DelegationId; + readonly label: string; + readonly createdBy: Subject; + readonly credentialId: CredentialId; + readonly credentialKind: VerifiedDelegatedCredential["kind"]; + readonly adapter: string; + /** Exact provider reference for server-side lifecycle operations. This is + * intentionally absent from the HTTP summary, which uses the masked field. */ + readonly credentialRef: string; + readonly credentialRefMasked: string; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly scope: DelegationScope; + readonly scopeVersion: DelegationScopeVersion; + readonly expiresAt: number; + readonly createdAt: number; + readonly revokedAt: number | null; + readonly lastUsedAt: number | null; +} + +/** Status returned to the human console while a machine completes proof. The + * public reference is always masked; the nonce and any bearer material stay + * with the challenge caller and are never persisted in a projection. */ +export interface DelegationChallengeView { + readonly challengeId: string; + readonly status: "awaiting-proof" | "proof-complete" | "consented" | "expired"; + readonly adapter: string; + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly grantExpiresAt: number; + readonly expiresAt: number; + readonly proofConsumedAt: number | null; + readonly consentConsumedAt: number | null; + readonly provenCredentialKind: VerifiedDelegatedCredential["kind"] | null; + readonly provenCredentialRefMasked: string | null; + readonly provenActorSubject: Subject | null; +} + +export interface CreateIssuedDelegationInput { + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly expiresAt?: number; + readonly scopeVersion?: DelegationScopeVersion; + /** Host/provider identity for this local key. Defaults to the SDK's local + * carrier and remains generic; it is never interpreted as an Access type. */ + readonly adapter?: string; + readonly issuer?: string; + readonly audience?: string; +} + +export interface IssuedDelegationKey { + readonly keyRef: string; + /** Complete one-time bearer token: `.<256-bit-secret>`. */ + readonly secret: string; + readonly grant: DelegationGrant; +} + +export interface RotateIssuedDelegationInput { + readonly credentialId: CredentialId; + readonly actorSubject: Subject; + readonly expiresAt?: number; +} + +/** Register a key already created by a host/provider. The SDK persists only + * the stable provider tuple and never receives the provider secret. */ +export interface RegisterProviderIssuedDelegationInput { + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly actorSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly credential: VerifiedDelegatedCredential; + readonly expiresAt?: number; + readonly scopeVersion?: DelegationScopeVersion; +} + +export interface RotateProviderIssuedDelegationInput { + readonly credentialId: CredentialId; + /** Human operator who created the replacement provider key. */ + readonly managedBy: Subject; + readonly actorSubject: Subject; + readonly credential: VerifiedDelegatedCredential; + readonly expiresAt?: number; + readonly scopeVersion?: DelegationScopeVersion; +} + +export interface ExpandDelegationInput { + readonly credentialId: CredentialId; + readonly consentedBy: Subject; + readonly scope: DelegationScope; + readonly expiresAt: number; + /** Optional optimistic version supplied by a host/UI read. */ + readonly expectedScopeVersion?: DelegationScopeVersion; + readonly scopeVersion?: DelegationScopeVersion; +} + +export interface DelegationAuditRecord { + readonly id: string; + readonly grantId: DelegationId | null; + readonly credentialId: CredentialId | null; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + readonly action: string; + readonly tool: ToolAddress | null; + readonly outcome: DelegationAuditEvent["outcome"]; + readonly requestId: string | null; + readonly executionId: string | null; + readonly occurredAt: number; +} + +export interface DelegationNotificationRecord { + readonly id: string; + readonly grantId: DelegationId; + readonly credentialId: CredentialId; + readonly createdBy: Subject; + readonly targetSubject: Subject; + readonly label: string; + readonly credentialKind: VerifiedDelegatedCredential["kind"]; + readonly createdAt: number; + readonly readAt: number | null; +} + +export interface DelegationService { + readonly create: ( + input: CreateDelegationInput, + ) => Effect.Effect; + readonly resolve: ( + credential: VerifiedDelegatedCredential, + ) => Effect.Effect; + readonly resolveBinding: ( + binding: DelegationBinding, + ) => Effect.Effect; + readonly revalidate: ( + context: DelegationContext, + capability: DelegationCapability, + ) => Effect.Effect; + readonly get: ( + grantId: DelegationId, + ) => Effect.Effect; + readonly list: (input?: { + readonly effectiveSubject?: Subject; + readonly credentialId?: CredentialId; + readonly includeRevoked?: boolean; + }) => Effect.Effect; + readonly getChallenge: ( + challengeId: string, + ) => Effect.Effect; + readonly createIssuedKey: ( + input: CreateIssuedDelegationInput, + ) => Effect.Effect; + readonly rotateIssuedKey: ( + input: RotateIssuedDelegationInput, + ) => Effect.Effect; + readonly registerProviderIssuedKey: ( + input: RegisterProviderIssuedDelegationInput, + ) => Effect.Effect; + readonly rotateProviderIssuedKey: ( + input: RotateProviderIssuedDelegationInput, + ) => Effect.Effect; + /** Resolve the SDK-local `.` token format. Provider-managed + * keys use the external adapter path and never pass bearer material here. */ + readonly resolveIssuedKey: ( + token: string, + ) => Effect.Effect; + readonly expand: ( + input: ExpandDelegationInput, + ) => Effect.Effect; + readonly revoke: (input: { + readonly grantId: DelegationId; + readonly actorSubject: Subject; + readonly reason?: string; + }) => Effect.Effect; + readonly revokeAll: (input: { + readonly effectiveSubject: Subject; + readonly actorSubject: Subject; + }) => Effect.Effect; + readonly audit: (event: DelegationAuditEvent) => Effect.Effect; + readonly listAudit: (input?: { + readonly grantId?: DelegationId; + readonly effectiveSubject?: Subject; + readonly limit?: number; + }) => Effect.Effect; + readonly listNotifications: (input: { + readonly reader: Subject; + readonly includeRead?: boolean; + }) => Effect.Effect< + readonly DelegationNotificationRecord[], + DelegationDeniedError | StorageFailure + >; + readonly markNotificationRead: (input: { + readonly notificationId: string; + readonly reader: Subject; + }) => Effect.Effect; + readonly issueProofChallenge: (input: { + readonly adapter: string; + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly grantExpiresAt?: number; + }) => Effect.Effect; + readonly completeProofChallenge: (input: { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: Subject; + readonly nonce: string; + }) => Effect.Effect; +} + +const ScopeSchema = Schema.Struct({ + actions: Schema.Array(Schema.Union([Schema.Literal("discover"), Schema.Literal("invoke")])), + toolkitMembership: Schema.Array( + Schema.Struct({ + toolkitId: Schema.String, + revision: Schema.String, + toolAddresses: Schema.Array(Schema.String), + connectionAddresses: Schema.Array(Schema.String), + }), + ), + toolAddresses: Schema.Array(Schema.String), + connectionAddresses: Schema.optional(Schema.Array(Schema.String)), +}); +const decodeScope = Schema.decodeUnknownOption(ScopeSchema); +const encodeScope = Schema.encodeUnknownSync(ScopeSchema); +const PersistedGrantSchema = Schema.Struct({ + tenant: Schema.String, + id: Schema.String, + label: Schema.String, + created_by: Schema.String, + credential_id: Schema.String, + credential_kind: Schema.String, + adapter: Schema.String, + issuer: Schema.String, + audience: Schema.String, + credential_subject: Schema.String, + credential_ref: Schema.String, + issued_key_ref: Schema.NullOr(Schema.String), + key_hash: Schema.NullOr(Schema.String), + actor_subject: Schema.String, + effective_subject: Schema.String, + scope: Schema.Unknown, + scope_version: Schema.String, + expires_at: Schema.Number, + revoked_at: Schema.NullOr(Schema.Number), + created_at: Schema.Number, + last_used_at: Schema.NullOr(Schema.Number), +}); +type PersistedGrant = typeof PersistedGrantSchema.Type; +const decodeGrant = Schema.decodeUnknownOption(PersistedGrantSchema); +const PersistedChallengeSchema = Schema.Struct({ + tenant: Schema.String, + id: Schema.String, + created_by: Schema.String, + label: Schema.String, + credential_id: Schema.NullOr(Schema.String), + credential_kind: Schema.NullOr(Schema.String), + adapter: Schema.String, + issuer: Schema.NullOr(Schema.String), + audience: Schema.NullOr(Schema.String), + credential_subject: Schema.NullOr(Schema.String), + credential_ref: Schema.NullOr(Schema.String), + actor_subject: Schema.NullOr(Schema.String), + effective_subject: Schema.String, + scope: Schema.Unknown, + proof_hash: Schema.String, + expires_at: Schema.Number, + grant_expires_at: Schema.Number, + proof_consumed_at: Schema.NullOr(Schema.Number), + consent_consumed_at: Schema.NullOr(Schema.Number), + created_at: Schema.Number, +}); +type PersistedChallenge = typeof PersistedChallengeSchema.Type; +const decodeChallenge = Schema.decodeUnknownOption(PersistedChallengeSchema); +const PersistedAuditSchema = Schema.Struct({ + tenant: Schema.String, + id: Schema.String, + grant_id: Schema.NullOr(Schema.String), + credential_id: Schema.NullOr(Schema.String), + actor_subject: Schema.String, + effective_subject: Schema.String, + action: Schema.String, + tool: Schema.NullOr(Schema.String), + outcome: Schema.Union([ + Schema.Literal("allowed"), + Schema.Literal("denied"), + Schema.Literal("created"), + Schema.Literal("changed"), + Schema.Literal("revoked"), + Schema.Literal("challenge"), + ]), + request_id: Schema.NullOr(Schema.String), + execution_id: Schema.NullOr(Schema.String), + occurred_at: Schema.Number, +}); +type PersistedAudit = typeof PersistedAuditSchema.Type; +const decodeAudit = Schema.decodeUnknownOption(PersistedAuditSchema); +const PersistedNotificationSchema = Schema.Struct({ + tenant: Schema.String, + id: Schema.String, + grant_id: Schema.String, + credential_id: Schema.String, + created_by: Schema.String, + target_subject: Schema.String, + label: Schema.String, + credential_kind: Schema.String, + created_at: Schema.Number, + read_at: Schema.NullOr(Schema.Number), +}); +type PersistedNotification = typeof PersistedNotificationSchema.Type; +const decodeNotification = Schema.decodeUnknownOption(PersistedNotificationSchema); +type AnyDelegationBuilder = ConditionBuilder>; + +const nonEmpty = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= 255; +const validEpoch = (value: number): boolean => Number.isSafeInteger(value) && value >= 0; +const validMaxTtl = (value: number): boolean => + validEpoch(value) && value > 0 && value <= MAX_DELEGATION_TTL_MS; +const validScopeVersion = (value: DelegationScopeVersion | undefined): boolean => + value === undefined || nonEmpty(value); +const validTtlWindow = (issuedAt: number, expiresAt: number, maxTtlMs: number): boolean => { + const limit = issuedAt + maxTtlMs; + return ( + validEpoch(issuedAt) && + validEpoch(expiresAt) && + validMaxTtl(maxTtlMs) && + validEpoch(limit) && + expiresAt > issuedAt && + expiresAt <= limit + ); +}; +const validCredentialTuple = (value: { + readonly credential_kind: unknown; + readonly adapter: unknown; + readonly issuer: unknown; + readonly audience: unknown; + readonly credential_subject: unknown; + readonly credential_ref: unknown; +}): boolean => + (value.credential_kind === "external" || value.credential_kind === "issued-key") && + [ + value.adapter, + value.issuer, + value.audience, + value.credential_subject, + value.credential_ref, + ].every(nonEmpty); +const validCredential = (value: VerifiedDelegatedCredential): boolean => + (() => { + const record = asRecord(value); + return ( + record !== null && + validCredentialTuple({ + credential_kind: record.kind, + adapter: record.adapter, + issuer: record.issuer, + audience: record.audience, + credential_subject: record.subject, + credential_ref: record.credentialRef, + }) + ); + })(); +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((a, b) => a.localeCompare(b)); + +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +const epochNumber = (value: unknown): number | null => { + if (typeof value === "number") return Number.isSafeInteger(value) ? value : null; + if (typeof value === "bigint") { + const max = BigInt(Number.MAX_SAFE_INTEGER); + return value >= 0n && value <= max ? Number(value) : null; + } + if (value instanceof Date) { + const epoch = value.getTime(); + return Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : null; + } + return null; +}; +const normalizeEpochFields = ( + value: unknown, + fields: readonly { readonly name: string; readonly nullable: boolean }[], +): Record | null => { + const record = asRecord(value); + if (!record) return null; + const output = { ...record }; + for (const field of fields) { + const source = record[field.name]; + if (source === null && field.nullable) continue; + const normalized = epochNumber(source); + if (normalized === null) return null; + output[field.name] = normalized; + } + return output; +}; + +const randomHex = (bytes = 32): string => { + const output = new Uint8Array(bytes); + globalThis.crypto.getRandomValues(output); + return [...output].map((value) => value.toString(16).padStart(2, "0")).join(""); +}; + +const maskReference = (value: string): string => { + if (value.length <= 8) return "••••"; + return `${value.slice(0, 4)}…${value.slice(-4)}`; +}; + +const invalidStoredRow = (table: string): StorageError => + new StorageError({ + message: `Delegation record in ${table} is invalid.`, + operation: `delegation.${table}.read`, + cause: undefined, + }); + +const sha256Hex = (value: string): Effect.Effect => + Effect.promise(async () => { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return [...new Uint8Array(digest)].map((item) => item.toString(16).padStart(2, "0")).join(""); + }); + +/** Compare bearer digests without returning early on the first differing byte. */ +const constantTimeHexEqual = (left: string, right: string): boolean => { + const length = Math.max(left.length, right.length); + let difference = left.length ^ right.length; + for (let index = 0; index < length; index += 1) { + const leftCode = index < left.length ? left.charCodeAt(index) : 0; + const rightCode = index < right.length ? right.charCodeAt(index) : 0; + difference |= leftCode ^ rightCode; + } + return difference === 0; +}; + +const nowDate = (now: number): Date => new Date(now); + +const denied = (reason: DelegationDeniedError["reason"], message: string): DelegationDeniedError => + new DelegationDeniedError({ reason, message }); + +const canonicalizeScope = (parsed: typeof ScopeSchema.Type): DelegationScope | null => { + const isAction = (action: string): action is DelegationScopeAction => + action === "discover" || action === "invoke"; + if (parsed.actions.length === 0) { + return null; + } + const actions = uniqueSorted(parsed.actions).filter(isAction); + const tools = uniqueSorted(parsed.toolAddresses); + const connections = + parsed.connectionAddresses === undefined ? undefined : uniqueSorted(parsed.connectionAddresses); + if (tools.some((tool) => tool.includes("*") || !nonEmpty(tool))) return null; + if (connections?.some((connection) => connection.includes("*") || !nonEmpty(connection))) { + return null; + } + const toolkitMembership = parsed.toolkitMembership.map((toolkit) => { + if (!nonEmpty(toolkit.toolkitId) || !nonEmpty(toolkit.revision)) return null; + return { + toolkitId: toolkit.toolkitId, + revision: toolkit.revision, + toolAddresses: uniqueSorted(toolkit.toolAddresses).map((item) => ToolAddress.make(item)), + connectionAddresses: uniqueSorted(toolkit.connectionAddresses).map((item) => + ConnectionAddress.make(item), + ), + }; + }); + if (toolkitMembership.some((value) => value === null)) return null; + const validToolkitMembership = toolkitMembership.filter(Predicate.isNotNull); + if ( + validToolkitMembership.some( + (toolkit) => + !nonEmpty(toolkit.toolkitId) || + !nonEmpty(toolkit.revision) || + toolkit.toolAddresses.length === 0 || + toolkit.toolAddresses.some( + (tool) => !nonEmpty(String(tool)) || String(tool).includes("*"), + ) || + toolkit.connectionAddresses.some( + (connection) => !nonEmpty(String(connection)) || String(connection).includes("*"), + ), + ) + ) { + return null; + } + return { + actions: actions.sort(), + toolkitMembership: validToolkitMembership, + toolAddresses: tools.map((value) => ToolAddress.make(value)), + ...(connections === undefined + ? {} + : { connectionAddresses: connections.map((value) => ConnectionAddress.make(value)) }), + }; +}; + +const normalizeScope = (scope: DelegationScope): DelegationScope | null => { + const decoded = decodeScope(scope); + return Option.isNone(decoded) ? null : canonicalizeScope(decoded.value); +}; + +const includesAll = (container: readonly string[], required: readonly string[]): boolean => + required.every((value) => container.includes(value)); + +/** Expansion is deliberately monotonic. A caller that needs to narrow a + * grant can revoke it and create a new consented grant, preserving a clear + * audit trail and avoiding stale session assumptions. */ +const isScopeExpansion = (previous: DelegationScope, next: DelegationScope): boolean => { + if (!includesAll(next.actions, previous.actions)) return false; + if (!includesAll(next.toolAddresses.map(String), previous.toolAddresses.map(String))) + return false; + const previousConnections = + previous.connectionAddresses === undefined + ? previous.toolkitMembership.flatMap((toolkit) => toolkit.connectionAddresses.map(String)) + : previous.connectionAddresses.map(String); + const nextConnections = + next.connectionAddresses === undefined + ? next.toolkitMembership.flatMap((toolkit) => toolkit.connectionAddresses.map(String)) + : next.connectionAddresses.map(String); + if (!includesAll(nextConnections, previousConnections)) return false; + return previous.toolkitMembership.every((oldToolkit) => { + const matching = next.toolkitMembership.find( + (candidate) => + candidate.toolkitId === oldToolkit.toolkitId && candidate.revision === oldToolkit.revision, + ); + return ( + matching !== undefined && + includesAll(matching.toolAddresses.map(String), oldToolkit.toolAddresses.map(String)) && + includesAll( + matching.connectionAddresses.map(String), + oldToolkit.connectionAddresses.map(String), + ) + ); + }); +}; + +const toGrant = ( + service: DelegationService, + row: PersistedGrant, + scope: DelegationScope, +): DelegationGrant => { + const tenant = Tenant.make(row.tenant); + const grantId = DelegationId.make(row.id); + const context: DelegationGrant = { + kind: "delegated", + tenant, + grantId, + credentialId: CredentialId.make(row.credential_id), + actorSubject: Subject.make(row.actor_subject), + effectiveSubject: Subject.make(row.effective_subject), + scopeVersion: DelegationScopeVersion.make(row.scope_version), + credentialRef: row.credential_ref, + expiresAt: row.expires_at, + scope, + revalidate: (capability) => service.revalidate(context, capability), + }; + return context; +}; + +const rowScope = (row: { readonly scope: unknown }): DelegationScope | null => { + const decoded = decodeScope(row.scope); + return Option.isNone(decoded) ? null : canonicalizeScope(decoded.value); +}; + +/** Build the private grant store/resolver over the executor database. */ +export const makeDelegationService = (options: DelegationStoreOptions): DelegationService => { + const tenant = String(options.tenant); + const now = options.now ?? Date.now; + const tables = new Set([ + "credential_delegation", + "delegation_challenge", + "delegation_audit", + "delegation_notification", + ]); + const context: ExecutorOwnerPolicyContext = { tenant, subject: null }; + const db = makeFumaClient(withQueryContext(options.db, context), { tables }); + let api: DelegationService; + + const tenantWhere = ( + builder: AnyDelegationBuilder, + field: string, + value: unknown, + ): Condition | boolean => builder.and(builder("tenant", "=", tenant), builder(field, "=", value)); + + const parseGrant = (row: unknown): PersistedGrant | null => { + const decoded = decodeGrant( + normalizeEpochFields(row, [ + { name: "expires_at", nullable: false }, + { name: "revoked_at", nullable: true }, + { name: "created_at", nullable: false }, + { name: "last_used_at", nullable: true }, + ]), + ); + if (Option.isNone(decoded)) return null; + const value = decoded.value; + return nonEmpty(value.id) && + nonEmpty(value.label) && + nonEmpty(value.created_by) && + nonEmpty(value.actor_subject) && + nonEmpty(value.effective_subject) && + nonEmpty(value.scope_version) && + validCredentialTuple(value) && + validEpoch(value.expires_at) && + validEpoch(value.created_at) && + (value.revoked_at === null || validEpoch(value.revoked_at)) && + (value.last_used_at === null || validEpoch(value.last_used_at)) + ? value + : null; + }; + const parseChallenge = (row: unknown): PersistedChallenge | null => { + const decoded = decodeChallenge( + normalizeEpochFields(row, [ + { name: "expires_at", nullable: false }, + { name: "grant_expires_at", nullable: false }, + { name: "proof_consumed_at", nullable: true }, + { name: "consent_consumed_at", nullable: true }, + { name: "created_at", nullable: false }, + ]), + ); + if (Option.isNone(decoded)) return null; + const value = decoded.value; + return nonEmpty(value.created_by) && + nonEmpty(value.label) && + nonEmpty(value.adapter) && + nonEmpty(value.effective_subject) && + validEpoch(value.expires_at) && + validEpoch(value.grant_expires_at) && + validEpoch(value.created_at) && + (value.proof_consumed_at === null || validEpoch(value.proof_consumed_at)) && + (value.consent_consumed_at === null || validEpoch(value.consent_consumed_at)) + ? value + : null; + }; + + const parseAudit = (row: unknown): PersistedAudit | null => { + const decoded = decodeAudit( + normalizeEpochFields(row, [{ name: "occurred_at", nullable: false }]), + ); + if (Option.isNone(decoded)) return null; + const value = decoded.value; + return nonEmpty(value.id) && + nonEmpty(value.actor_subject) && + nonEmpty(value.effective_subject) && + nonEmpty(value.action) && + validEpoch(value.occurred_at) + ? value + : null; + }; + + const parseNotification = (row: unknown): PersistedNotification | null => { + const decoded = decodeNotification( + normalizeEpochFields(row, [ + { name: "created_at", nullable: false }, + { name: "read_at", nullable: true }, + ]), + ); + if (Option.isNone(decoded)) return null; + const value = decoded.value; + return nonEmpty(value.id) && + nonEmpty(value.grant_id) && + nonEmpty(value.credential_id) && + nonEmpty(value.created_by) && + nonEmpty(value.target_subject) && + nonEmpty(value.label) && + (value.credential_kind === "external" || value.credential_kind === "issued-key") && + validEpoch(value.created_at) && + (value.read_at === null || validEpoch(value.read_at)) + ? value + : null; + }; + + const findGrant = (grantId: DelegationId) => + db + .use("delegation.grant.get", (query) => + query.findFirst("credential_delegation", { + where: (builder) => tenantWhere(builder, "id", String(grantId)), + }), + ) + .pipe(Effect.map(parseGrant)); + + const toGrantView = (row: PersistedGrant, scope: DelegationScope): DelegationGrantView => ({ + grantId: DelegationId.make(row.id), + label: row.label, + createdBy: Subject.make(row.created_by), + credentialId: CredentialId.make(row.credential_id), + credentialKind: row.credential_kind === "issued-key" ? "issued-key" : "external", + adapter: row.adapter, + credentialRef: row.credential_ref, + credentialRefMasked: maskReference(row.credential_ref), + actorSubject: Subject.make(row.actor_subject), + effectiveSubject: Subject.make(row.effective_subject), + scope, + scopeVersion: DelegationScopeVersion.make(row.scope_version), + expiresAt: row.expires_at, + createdAt: row.created_at, + revokedAt: row.revoked_at, + lastUsedAt: row.last_used_at, + }); + + const toNotificationRecord = (row: PersistedNotification): DelegationNotificationRecord => ({ + id: row.id, + grantId: DelegationId.make(row.grant_id), + credentialId: CredentialId.make(row.credential_id), + createdBy: Subject.make(row.created_by), + targetSubject: Subject.make(row.target_subject), + label: row.label, + credentialKind: row.credential_kind === "issued-key" ? "issued-key" : "external", + createdAt: row.created_at, + readAt: row.read_at, + }); + + const toChallengeView = ( + row: PersistedChallenge, + scope: DelegationScope, + currentTime: number, + ): DelegationChallengeView => ({ + challengeId: row.id, + status: + row.consent_consumed_at !== null + ? "consented" + : row.expires_at <= currentTime + ? "expired" + : row.proof_consumed_at === null + ? "awaiting-proof" + : "proof-complete", + adapter: row.adapter, + createdBy: Subject.make(row.created_by), + effectiveSubject: Subject.make(row.effective_subject), + label: row.label, + scope, + grantExpiresAt: row.grant_expires_at, + expiresAt: row.expires_at, + proofConsumedAt: row.proof_consumed_at, + consentConsumedAt: row.consent_consumed_at, + provenCredentialKind: + row.credential_kind === "issued-key" || row.credential_kind === "external" + ? row.credential_kind + : null, + provenCredentialRefMasked: + row.credential_ref === null ? null : maskReference(row.credential_ref), + provenActorSubject: row.actor_subject === null ? null : Subject.make(row.actor_subject), + }); + + let audit: DelegationService["audit"]; + + const checkCurrent = (grant: DelegationGrant) => + Effect.gen(function* () { + if (grant.expiresAt <= now()) + return yield* Effect.fail(denied("expired", "Delegation expired.")); + const verified = yield* options.isVerifiedHuman(grant.effectiveSubject); + if (!verified) + return yield* Effect.fail( + denied("unauthorized", "Delegated subject is no longer authorized."), + ); + const authorized = yield* options.isCurrentlyAuthorized(grant); + if (!authorized) + return yield* Effect.fail( + denied("unauthorized", "Delegation authorization is no longer current."), + ); + }); + + audit = (event: DelegationAuditEvent): Effect.Effect => + db + .use("delegation.audit.create", (query) => + query.create("delegation_audit", { + tenant, + id: `audit_${randomHex(16)}`, + grant_id: event.grantId ? String(event.grantId) : null, + credential_id: event.credentialId ? String(event.credentialId) : null, + actor_subject: String(event.actorSubject), + effective_subject: String(event.effectiveSubject), + action: event.action, + tool: event.tool ? String(event.tool) : null, + outcome: event.outcome, + request_id: event.requestId ?? null, + execution_id: event.executionId ?? null, + occurred_at: nowDate(now()), + }), + ) + .pipe(Effect.asVoid); + + // Fuma's portable updateMany contract intentionally does not expose an + // affected-row count. A deterministic, tenant-scoped audit id gives state + // transitions a portable single-use claim: the database unique constraint + // serializes contenders, while the enclosing transaction rolls the claim + // back if the state update or grant insert fails. + const claimOperation = (input: { + readonly claimId: string; + readonly action: string; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }): Effect.Effect => + db + .use(`delegation.claim.${input.action}`, (query) => + query.create("delegation_audit", { + tenant, + id: input.claimId, + grant_id: null, + credential_id: null, + actor_subject: String(input.actorSubject), + effective_subject: String(input.effectiveSubject), + action: input.action, + tool: null, + outcome: "challenge", + request_id: null, + execution_id: null, + occurred_at: nowDate(now()), + }), + ) + .pipe(Effect.asVoid); + + const claimChallengePhase = (input: { + readonly challengeId: string; + readonly phase: "proof" | "consent"; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }): Effect.Effect => + claimOperation({ + claimId: `delegation_claim_${input.phase}_${input.challengeId}`, + action: `proof.${input.phase}.claim`, + actorSubject: input.actorSubject, + effectiveSubject: input.effectiveSubject, + }); + + const createNotification = (input: { + readonly grantId: DelegationId; + readonly credentialId: CredentialId; + readonly createdBy: Subject; + readonly targetSubject: Subject; + readonly label: string; + readonly credentialKind: VerifiedDelegatedCredential["kind"]; + }): Effect.Effect => { + if (String(input.createdBy) === String(input.targetSubject)) return Effect.void; + const id = `delegation_notification_${String(input.grantId)}`; + return db + .use("delegation.notification.find", (query) => + query.findFirst("delegation_notification", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("grant_id", "=", String(input.grantId)), + builder("target_subject", "=", String(input.targetSubject)), + ), + }), + ) + .pipe( + Effect.flatMap((existing) => + existing === null + ? db.use("delegation.notification.create", (query) => + query.create("delegation_notification", { + tenant, + id, + grant_id: String(input.grantId), + credential_id: String(input.credentialId), + created_by: String(input.createdBy), + target_subject: String(input.targetSubject), + label: input.label, + credential_kind: input.credentialKind, + created_at: nowDate(now()), + read_at: null, + }), + ) + : (() => { + const row = parseNotification(existing); + if ( + row === null || + row.id !== id || + row.grant_id !== String(input.grantId) || + row.credential_id !== String(input.credentialId) || + row.created_by !== String(input.createdBy) || + row.target_subject !== String(input.targetSubject) || + row.label !== input.label || + row.credential_kind !== input.credentialKind + ) { + return Effect.fail(invalidStoredRow("delegation_notification")); + } + return Effect.void; + })(), + ), + // A retry or a concurrent transaction may win the unique grant/target + // claim between the find and create. The durable row is the idempotency + // key, so that race is already satisfied. + Effect.catchTag("UniqueViolationError", () => Effect.void), + Effect.asVoid, + ); + }; + + const withDenialAudit = ( + operation: Effect.Effect, + event: DelegationAuditEvent, + ): Effect.Effect => + operation.pipe( + Effect.catchTag("DelegationDeniedError", (error) => + audit({ ...event, outcome: "denied" }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + + const get = (grantId: DelegationId) => + Effect.gen(function* () { + const raw = yield* findGrant(grantId); + if (raw === null) return null; + const scope = rowScope(raw); + if (!scope) return yield* Effect.fail(invalidStoredRow("credential_delegation")); + return toGrantView(raw, scope); + }); + + const list = ( + input: { + readonly effectiveSubject?: Subject; + readonly credentialId?: CredentialId; + readonly includeRevoked?: boolean; + } = {}, + ) => + Effect.gen(function* () { + const rawRows = yield* db.use("delegation.grant.list", (query) => + query.findMany("credential_delegation", { + where: (builder) => { + const predicates: (Condition | boolean)[] = [builder("tenant", "=", tenant)]; + if (input.effectiveSubject !== undefined) { + predicates.push(builder("effective_subject", "=", String(input.effectiveSubject))); + } + if (input.credentialId !== undefined) { + predicates.push(builder("credential_id", "=", String(input.credentialId))); + } + if (input.includeRevoked !== true) predicates.push(builder.isNull("revoked_at")); + return builder.and(...predicates); + }, + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + }), + ); + return yield* Effect.forEach(rawRows, (raw) => { + const row = parseGrant(raw); + if (!row) return Effect.fail(invalidStoredRow("credential_delegation")); + const scope = rowScope(row); + if (!scope) return Effect.fail(invalidStoredRow("credential_delegation")); + return Effect.succeed(toGrantView(row, scope)); + }); + }); + + const getChallenge = (challengeId: string) => + Effect.gen(function* () { + if (!nonEmpty(challengeId)) return null; + const raw = yield* db.use("delegation.challenge.status", (query) => + query.findFirst("delegation_challenge", { + where: (builder) => tenantWhere(builder, "id", challengeId), + }), + ); + if (raw === null) return null; + const row = parseChallenge(raw); + if (!row) return yield* Effect.fail(invalidStoredRow("delegation_challenge")); + const scope = rowScope(row); + if (!scope) return yield* Effect.fail(invalidStoredRow("delegation_challenge")); + return toChallengeView(row, scope, now()); + }); + + const listAudit = ( + input: { + readonly grantId?: DelegationId; + readonly effectiveSubject?: Subject; + readonly limit?: number; + } = {}, + ) => + Effect.gen(function* () { + const limit = input.limit ?? 100; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) { + return yield* new StorageError({ + message: "Delegation audit limit is invalid.", + operation: "delegation.audit.list", + cause: undefined, + }); + } + const rawRows = yield* db.use("delegation.audit.list", (query) => + query.findMany("delegation_audit", { + where: (builder) => { + const predicates: (Condition | boolean)[] = [builder("tenant", "=", tenant)]; + if (input.grantId !== undefined) { + predicates.push(builder("grant_id", "=", String(input.grantId))); + } + if (input.effectiveSubject !== undefined) { + predicates.push(builder("effective_subject", "=", String(input.effectiveSubject))); + } + return builder.and(...predicates); + }, + orderBy: [ + ["occurred_at", "desc"], + ["id", "desc"], + ], + limit, + }), + ); + return yield* Effect.forEach(rawRows, (raw) => { + const row = parseAudit(raw); + if (!row) return Effect.fail(invalidStoredRow("delegation_audit")); + return Effect.succeed({ + id: row.id, + grantId: row.grant_id === null ? null : DelegationId.make(row.grant_id), + credentialId: row.credential_id === null ? null : CredentialId.make(row.credential_id), + actorSubject: Subject.make(row.actor_subject), + effectiveSubject: Subject.make(row.effective_subject), + action: row.action, + tool: row.tool === null ? null : ToolAddress.make(row.tool), + outcome: row.outcome, + requestId: row.request_id, + executionId: row.execution_id, + occurredAt: row.occurred_at, + } satisfies DelegationAuditRecord); + }); + }); + + const listNotifications = (input: { readonly reader: Subject; readonly includeRead?: boolean }) => + Effect.gen(function* () { + if (!nonEmpty(String(input.reader))) { + return yield* Effect.fail(denied("invalid", "Notification reader is invalid.")); + } + if (!(yield* options.isVerifiedHuman(input.reader))) { + return yield* Effect.fail( + denied("unauthorized", "Notification reader is not a verified human."), + ); + } + const rawRows = yield* db.use("delegation.notification.list", (query) => + query.findMany("delegation_notification", { + where: (builder) => { + const predicates: (Condition | boolean)[] = [ + builder("tenant", "=", tenant), + builder("target_subject", "=", String(input.reader)), + ]; + if (input.includeRead !== true) predicates.push(builder.isNull("read_at")); + return builder.and(...predicates); + }, + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + }), + ); + return yield* Effect.forEach(rawRows, (raw) => { + const row = parseNotification(raw); + if (!row) return Effect.fail(invalidStoredRow("delegation_notification")); + return Effect.succeed(toNotificationRecord(row)); + }); + }); + + const markNotificationRead = (input: { + readonly notificationId: string; + readonly reader: Subject; + }) => + Effect.gen(function* () { + if (!nonEmpty(input.notificationId) || !nonEmpty(String(input.reader))) { + return yield* Effect.fail(denied("invalid", "Notification binding is invalid.")); + } + if (!(yield* options.isVerifiedHuman(input.reader))) { + return yield* Effect.fail( + denied("unauthorized", "Notification reader is not a verified human."), + ); + } + const raw = yield* db.use("delegation.notification.get", (query) => + query.findFirst("delegation_notification", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", input.notificationId), + builder("target_subject", "=", String(input.reader)), + ), + }), + ); + if (raw === null) { + return yield* Effect.fail(denied("unauthorized", "Notification is unavailable.")); + } + const row = parseNotification(raw); + if (!row) return yield* Effect.fail(invalidStoredRow("delegation_notification")); + if (row.read_at !== null) return; + yield* db.use("delegation.notification.mark-read", (query) => + query.updateMany("delegation_notification", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", input.notificationId), + builder("target_subject", "=", String(input.reader)), + builder.isNull("read_at"), + ), + set: { read_at: now() }, + }), + ); + }); + + const resolve = (credential: VerifiedDelegatedCredential) => { + const operation = Effect.gen(function* () { + if (!validCredential(credential)) { + return yield* Effect.fail(denied("invalid", "Credential reference is required.")); + } + const rawRow = yield* db.use("delegation.grant.resolve", (query) => + query.findFirst("credential_delegation", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("adapter", "=", credential.adapter), + builder("issuer", "=", credential.issuer), + builder("audience", "=", credential.audience), + builder("credential_subject", "=", credential.subject), + builder("credential_kind", "=", credential.kind), + builder("credential_ref", "=", credential.credentialRef), + ), + }), + ); + if (!rawRow) { + const colliding = yield* db.use("delegation.grant.binding", (query) => + query.findFirst("credential_delegation", { + where: (builder) => tenantWhere(builder, "credential_ref", credential.credentialRef), + }), + ); + if (colliding) { + return yield* Effect.fail(denied("invalid", "Credential binding does not match.")); + } + return yield* Effect.fail(denied("unmapped", "Credential is not delegated.")); + } + const row = parseGrant(rawRow); + if (!row) return yield* Effect.fail(denied("invalid", "Delegation record is invalid.")); + const revokedAt = row.revoked_at; + if (revokedAt !== null) { + return yield* Effect.fail(denied("revoked", "Delegation revoked.")); + } + const expiresAt = row.expires_at; + if (expiresAt <= now()) return yield* Effect.fail(denied("expired", "Delegation expired.")); + const scope = rowScope(row); + if (!scope) return yield* Effect.fail(denied("invalid", "Delegation scope is invalid.")); + const grant = toGrant(api, row, scope); + yield* checkCurrent(grant); + yield* db.use("delegation.grant.touch", (query) => + query.updateMany("credential_delegation", { + where: (builder) => tenantWhere(builder, "id", String(grant.grantId)), + set: { last_used_at: now() }, + }), + ); + return grant; + }); + const credentialRecord = asRecord(credential); + const subject = Subject.make( + nonEmpty(credentialRecord?.subject) ? credentialRecord.subject : "unknown", + ); + return operation.pipe( + Effect.catchTag("DelegationDeniedError", (error) => + audit({ + actorSubject: subject, + effectiveSubject: subject, + action: "resolve", + outcome: "denied", + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + }; + + const resolveIssuedKey = (token: string) => + withDenialAudit( + Effect.gen(function* () { + // Local bearer tokens are deliberately a narrow, versioned format. A + // provider-managed binding has no local secret and must use resolve() + // through its adapter instead. + if (typeof token !== "string" || token.length > 512) { + return yield* Effect.fail(denied("invalid", "Issued key token is invalid.")); + } + const separator = token.indexOf("."); + if ( + separator <= 0 || + separator === token.length - 1 || + token.indexOf(".", separator + 1) !== -1 + ) { + return yield* Effect.fail(denied("invalid", "Issued key token is invalid.")); + } + const keyRef = token.slice(0, separator); + const secret = token.slice(separator + 1); + if (!/^key_[0-9a-f]{32}$/.test(keyRef) || !/^[0-9a-f]{64}$/.test(secret)) { + return yield* Effect.fail(denied("invalid", "Issued key token is invalid.")); + } + const rawRow = yield* db.use("delegation.issued-key.resolve", (query) => + query.findFirst("credential_delegation", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("credential_kind", "=", "issued-key"), + builder("issued_key_ref", "=", keyRef), + ), + }), + ); + if (!rawRow) return yield* Effect.fail(denied("unmapped", "Issued key is not delegated.")); + const row = parseGrant(rawRow); + if (!row || row.issued_key_ref !== keyRef || row.key_hash === null) { + return yield* Effect.fail(denied("invalid", "Issued key binding is invalid.")); + } + if (!/^[0-9a-f]{64}$/.test(row.key_hash)) { + return yield* Effect.fail(denied("invalid", "Issued key binding is invalid.")); + } + const digest = yield* sha256Hex(secret); + if (!constantTimeHexEqual(digest, row.key_hash)) { + return yield* Effect.fail(denied("invalid", "Issued key token is invalid.")); + } + if (row.revoked_at !== null) { + return yield* Effect.fail(denied("revoked", "Delegated key is revoked.")); + } + if (row.expires_at <= now()) { + return yield* Effect.fail(denied("expired", "Delegated key is expired.")); + } + return yield* resolve({ + kind: "issued-key", + adapter: row.adapter, + issuer: row.issuer, + audience: row.audience, + subject: row.credential_subject, + credentialRef: row.credential_ref, + }); + }), + { + actorSubject: Subject.make("issued-key"), + effectiveSubject: Subject.make("issued-key"), + action: "resolve.issued-key", + outcome: "denied", + }, + ); + + const resolveBinding = (binding: DelegationBinding) => + withDenialAudit( + Effect.gen(function* () { + if ( + String(binding.tenant) !== tenant || + !nonEmpty(String(binding.grantId)) || + !nonEmpty(String(binding.credentialId)) || + !nonEmpty(binding.credentialRef) || + !nonEmpty(String(binding.actorSubject)) || + !nonEmpty(String(binding.effectiveSubject)) || + !nonEmpty(String(binding.scopeVersion)) + ) { + return yield* Effect.fail(denied("invalid", "Delegation binding is invalid.")); + } + const row = yield* findGrant(binding.grantId); + if (!row) + return yield* Effect.fail(denied("revoked", "Delegation binding is unavailable.")); + if ( + row.tenant !== tenant || + row.credential_id !== String(binding.credentialId) || + row.credential_ref !== binding.credentialRef || + row.actor_subject !== String(binding.actorSubject) || + row.effective_subject !== String(binding.effectiveSubject) + ) { + return yield* Effect.fail(denied("unauthorized", "Delegation binding does not match.")); + } + if (row.scope_version !== String(binding.scopeVersion)) { + return yield* Effect.fail(denied("scope", "Delegation scope changed.")); + } + if (row.revoked_at !== null) + return yield* Effect.fail(denied("revoked", "Delegation revoked.")); + if (row.expires_at <= now()) + return yield* Effect.fail(denied("expired", "Delegation expired.")); + const scope = rowScope(row); + if (!scope) return yield* Effect.fail(invalidStoredRow("credential_delegation")); + const grant = toGrant(api, row, scope); + yield* checkCurrent(grant); + yield* audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: grant.actorSubject, + effectiveSubject: grant.effectiveSubject, + action: "resolve.binding", + outcome: "allowed", + }); + return grant; + }), + { + actorSubject: binding.actorSubject, + effectiveSubject: binding.effectiveSubject, + action: "resolve.binding", + outcome: "denied", + }, + ); + + const revalidate = (context: DelegationContext, capability: DelegationCapability) => { + const operation = Effect.gen(function* () { + const row = yield* findGrant(context.grantId); + if (!row) return yield* Effect.fail(denied("revoked", "Delegation grant is unavailable.")); + if ( + row.tenant !== tenant || + String(context.tenant) !== row.tenant || + row.credential_id !== String(context.credentialId) || + row.credential_ref !== context.credentialRef || + row.actor_subject !== String(context.actorSubject) || + row.effective_subject !== String(context.effectiveSubject) + ) { + return yield* Effect.fail(denied("unauthorized", "Delegation context does not match.")); + } + const revokedAt = row.revoked_at; + if (revokedAt !== null) return yield* Effect.fail(denied("revoked", "Delegation revoked.")); + if (row.expires_at <= now()) + return yield* Effect.fail(denied("expired", "Delegation expired.")); + const scope = rowScope(row); + if (!scope || String(row.scope_version) !== String(context.scopeVersion)) { + return yield* Effect.fail(denied("scope", "Delegation scope changed.")); + } + const current = toGrant(api, row, scope); + yield* checkCurrent(current); + if (capability.operation === "management") { + return yield* Effect.fail( + denied("management", "Delegated credentials cannot manage accounts or connections."), + ); + } + const requiredAction: DelegationScopeAction = + capability.operation === "catalog" || capability.operation === "schema" + ? "discover" + : "invoke"; + if (!scope.actions.includes(requiredAction)) { + return yield* Effect.fail( + denied("scope", "Delegation action is outside the consented scope."), + ); + } + if ( + capability.tool && + !scope.toolAddresses.some((tool) => String(tool) === String(capability.tool)) + ) { + return yield* Effect.fail(denied("scope", "Tool is outside the delegated snapshot.")); + } + if (capability.connection) { + const toolkitConnections = scope.toolkitMembership.flatMap((toolkit) => + toolkit.connectionAddresses.map(String), + ); + const allowedConnections = + scope.connectionAddresses === undefined + ? toolkitConnections + : scope.connectionAddresses.map(String); + if (!allowedConnections.includes(String(capability.connection))) { + return yield* Effect.fail( + denied("scope", "Connection is outside the delegated allowlist."), + ); + } + } + yield* audit({ + grantId: context.grantId, + credentialId: context.credentialId, + actorSubject: context.actorSubject, + effectiveSubject: context.effectiveSubject, + action: capability.operation, + tool: capability.tool, + outcome: "allowed", + requestId: capability.requestId, + executionId: capability.executionId, + }); + }); + return operation.pipe( + Effect.catchTag("DelegationDeniedError", (error) => + audit({ + grantId: context.grantId, + credentialId: context.credentialId, + actorSubject: context.actorSubject, + effectiveSubject: context.effectiveSubject, + action: capability.operation, + tool: capability.tool, + outcome: "denied", + requestId: capability.requestId, + executionId: capability.executionId, + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + }; + + const create = (input: CreateDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + if (!validMaxTtl(maxTtlMs)) { + return yield* Effect.fail(denied("invalid", "Delegation maximum TTL is invalid.")); + } + const rawChallenge = yield* db.use("delegation.challenge.consent", (query) => + query.findFirst("delegation_challenge", { + where: (builder) => tenantWhere(builder, "id", input.proofChallengeId), + }), + ); + const proven = rawChallenge ? parseChallenge(rawChallenge) : null; + if (!proven) return yield* Effect.fail(denied("proof", "Proof challenge is invalid.")); + if (proven.consent_consumed_at !== null || proven.proof_consumed_at === null) { + return yield* Effect.fail(denied("proof", "Proof challenge is not ready for consent.")); + } + const scope = rowScope(proven); + if (!scope) return yield* Effect.fail(denied("scope", "Consent scope is invalid.")); + const issuedAt = now(); + const expiresAt = proven.grant_expires_at; + if (!validTtlWindow(issuedAt, expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + if (!(yield* options.isVerifiedHuman(input.consentedBy))) { + return yield* Effect.fail( + denied("unauthorized", "Grant consenter is not a verified human."), + ); + } + if (String(input.consentedBy) !== proven.created_by) { + return yield* Effect.fail( + denied("unauthorized", "Only the challenge creator may give consent."), + ); + } + const effectiveSubject = Subject.make(proven.effective_subject); + if (!(yield* options.isManagementAuthorized(input.consentedBy, effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Grant consenter is not authorized.")); + } + if (!(yield* options.isVerifiedHuman(effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Target must be a verified human.")); + } + if ( + proven.credential_kind === null || + proven.issuer === null || + proven.audience === null || + proven.credential_subject === null || + proven.credential_ref === null || + proven.actor_subject === null + ) { + return yield* Effect.fail(denied("proof", "Proof challenge has no proven credential.")); + } + const credential: VerifiedDelegatedCredential = { + kind: proven.credential_kind === "issued-key" ? "issued-key" : "external", + adapter: proven.adapter, + issuer: proven.issuer, + audience: proven.audience, + subject: proven.credential_subject, + credentialRef: proven.credential_ref, + }; + if (!validCredential(credential)) { + return yield* Effect.fail(denied("proof", "Proven credential tuple is invalid.")); + } + const actorSubject = Subject.make(proven.actor_subject); + const grantId = DelegationId.make(`grant_${randomHex(16)}`); + const credentialId = CredentialId.make(`cred_${randomHex(16)}`); + if (!validScopeVersion(input.scopeVersion)) { + return yield* Effect.fail(denied("invalid", "Delegation scope version is invalid.")); + } + const scopeVersion = + input.scopeVersion ?? DelegationScopeVersion.make(`scope_${randomHex(16)}`); + if (!validEpoch(expiresAt) || expiresAt <= issuedAt) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry must be in the allowed window."), + ); + } + yield* db.transaction( + Effect.gen(function* () { + yield* claimChallengePhase({ + challengeId: input.proofChallengeId, + phase: "consent", + actorSubject: input.consentedBy, + effectiveSubject, + }).pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("proof", "Proof challenge consent has already been used.")), + ), + ); + yield* db.use("delegation.challenge.consent.consume", (query) => + query.updateMany("delegation_challenge", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", input.proofChallengeId), + builder.isNull("consent_consumed_at"), + ), + set: { consent_consumed_at: issuedAt, credential_id: String(credentialId) }, + }), + ); + const claimedRaw = yield* db.use("delegation.challenge.consent.verify", (query) => + query.findFirst("delegation_challenge", { + where: (builder) => tenantWhere(builder, "id", input.proofChallengeId), + }), + ); + const claimed = claimedRaw ? parseChallenge(claimedRaw) : null; + if ( + !claimed || + claimed.consent_consumed_at === null || + claimed.credential_id !== String(credentialId) + ) { + return yield* Effect.fail( + denied("proof", "Proof challenge consent could not be claimed."), + ); + } + yield* db + .use("delegation.grant.create", (query) => + query.create("credential_delegation", { + tenant, + id: String(grantId), + label: proven.label, + created_by: proven.created_by, + credential_id: String(credentialId), + credential_kind: credential.kind, + adapter: credential.adapter, + issuer: credential.issuer, + audience: credential.audience, + credential_subject: credential.subject, + credential_ref: credential.credentialRef, + issued_key_ref: + credential.kind === "issued-key" ? credential.credentialRef : null, + key_hash: null, + actor_subject: String(actorSubject), + effective_subject: String(effectiveSubject), + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: scope.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope: encodeScope(scope), + scope_version: String(scopeVersion), + expires_at: expiresAt, + revoked_at: null, + revoke_reason: null, + created_at: nowDate(issuedAt), + last_used_at: null, + }), + ) + .pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("invalid", "Credential is already bound to a delegation.")), + ), + ); + yield* audit({ + grantId, + credentialId, + actorSubject: input.consentedBy, + effectiveSubject, + action: "grant.create", + outcome: "created", + }); + yield* createNotification({ + grantId, + credentialId, + createdBy: Subject.make(proven.created_by), + targetSubject: effectiveSubject, + label: proven.label, + credentialKind: credential.kind, + }); + }), + ); + const grant: DelegationGrant = { + kind: "delegated", + tenant: options.tenant, + grantId, + credentialId, + credentialRef: credential.credentialRef, + actorSubject, + effectiveSubject, + scopeVersion, + expiresAt, + scope, + revalidate: (capability) => api.revalidate(grant, capability), + }; + return grant; + }), + { + actorSubject: input.consentedBy, + effectiveSubject: input.consentedBy, + action: "grant.create", + outcome: "denied", + }, + ); + + const createIssuedKey = (input: CreateIssuedDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + if (!nonEmpty(input.label)) { + return yield* Effect.fail(denied("invalid", "Delegated key label is required.")); + } + const scope = normalizeScope(input.scope); + if (!scope) return yield* Effect.fail(denied("invalid", "Key scope is invalid.")); + const issuedAt = now(); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + const expiresAt = + input.expiresAt ?? issuedAt + Math.min(DEFAULT_DELEGATION_TTL_MS, maxTtlMs); + if (!validTtlWindow(issuedAt, expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + if (!(yield* options.isVerifiedHuman(input.createdBy))) { + return yield* Effect.fail(denied("unauthorized", "Key creator is not a verified human.")); + } + if (!(yield* options.isVerifiedHuman(input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Key target is not a verified human.")); + } + if (!(yield* options.isManagementAuthorized(input.createdBy, input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Key creator is not authorized.")); + } + const adapter = input.adapter ?? "executor"; + const issuer = input.issuer ?? "executor"; + const audience = input.audience ?? "delegated"; + if (![adapter, issuer, audience].every(nonEmpty)) { + return yield* Effect.fail(denied("invalid", "Issued key binding is invalid.")); + } + const generated = yield* generateDelegatedKey(); + const keyRef = `key_${randomHex(16)}`; + const credential: VerifiedDelegatedCredential = { + kind: "issued-key", + adapter, + issuer, + audience, + subject: keyRef, + credentialRef: keyRef, + }; + const actorSubject = Subject.make(`issued:${keyRef}`); + const grantId = DelegationId.make(`grant_${randomHex(16)}`); + const credentialId = CredentialId.make(`cred_${randomHex(16)}`); + if (!validScopeVersion(input.scopeVersion)) { + return yield* Effect.fail(denied("invalid", "Delegation scope version is invalid.")); + } + const scopeVersion = + input.scopeVersion ?? DelegationScopeVersion.make(`scope_${randomHex(16)}`); + yield* db.transaction( + Effect.gen(function* () { + yield* db.use("delegation.issued-key.create", (query) => + query.create("credential_delegation", { + tenant, + id: String(grantId), + label: input.label, + created_by: String(input.createdBy), + credential_id: String(credentialId), + credential_kind: credential.kind, + adapter: credential.adapter, + issuer: credential.issuer, + audience: credential.audience, + credential_subject: credential.subject, + credential_ref: credential.credentialRef, + issued_key_ref: keyRef, + key_hash: generated.hash, + actor_subject: String(actorSubject), + effective_subject: String(input.effectiveSubject), + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: scope.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope: encodeScope(scope), + scope_version: String(scopeVersion), + expires_at: expiresAt, + revoked_at: null, + revoke_reason: null, + created_at: nowDate(issuedAt), + last_used_at: null, + }), + ); + yield* audit({ + grantId, + credentialId, + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "grant.create.issued-key", + outcome: "created", + }); + yield* createNotification({ + grantId, + credentialId, + createdBy: input.createdBy, + targetSubject: input.effectiveSubject, + label: input.label, + credentialKind: credential.kind, + }); + }), + ); + const grant: DelegationGrant = { + kind: "delegated", + tenant: options.tenant, + grantId, + credentialId, + credentialRef: credential.credentialRef, + actorSubject, + effectiveSubject: input.effectiveSubject, + scopeVersion, + expiresAt, + scope, + revalidate: (capability) => api.revalidate(grant, capability), + }; + return { + keyRef, + secret: `${keyRef}.${generated.secret}`, + grant, + } satisfies IssuedDelegationKey; + }), + { + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "grant.create.issued-key", + outcome: "denied", + }, + ); + + const rotateIssuedKey = (input: RotateIssuedDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + const row = yield* db + .use("delegation.issued-key.rotate.get", (query) => + query.findFirst("credential_delegation", { + where: (builder) => tenantWhere(builder, "credential_id", String(input.credentialId)), + }), + ) + .pipe(Effect.map(parseGrant)); + if (!row) return yield* Effect.fail(denied("invalid", "Delegated key not found.")); + if ( + row.credential_kind !== "issued-key" || + row.issued_key_ref === null || + row.key_hash === null + ) { + return yield* Effect.fail(denied("invalid", "Only local issued keys can rotate.")); + } + if (row.revoked_at !== null) { + return yield* Effect.fail(denied("revoked", "Delegated key is revoked.")); + } + if (row.expires_at <= now()) { + return yield* Effect.fail(denied("expired", "Delegated key is expired.")); + } + const target = Subject.make(row.effective_subject); + if (!(yield* options.isVerifiedHuman(input.actorSubject))) { + return yield* Effect.fail(denied("unauthorized", "Key rotator is not a verified human.")); + } + if (!(yield* options.isManagementAuthorized(input.actorSubject, target))) { + return yield* Effect.fail(denied("unauthorized", "Key rotator is not authorized.")); + } + const scope = rowScope(row); + if (!scope) return yield* Effect.fail(invalidStoredRow("credential_delegation")); + const generated = yield* generateDelegatedKey(); + const keyRef = `key_${randomHex(16)}`; + const credential: VerifiedDelegatedCredential = { + kind: "issued-key", + adapter: row.adapter, + issuer: row.issuer, + audience: row.audience, + subject: keyRef, + credentialRef: keyRef, + }; + const actorSubject = Subject.make(`issued:${keyRef}`); + const grantId = DelegationId.make(`grant_${randomHex(16)}`); + const credentialId = CredentialId.make(`cred_${randomHex(16)}`); + const scopeVersion = DelegationScopeVersion.make(`scope_${randomHex(16)}`); + const at = now(); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + const expiresAt = Math.min(row.expires_at, at + maxTtlMs); + if (!validTtlWindow(at, expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + yield* db.transaction( + Effect.gen(function* () { + // Claim the old binding before changing it. Fuma's updateMany does + // not return an affected-row count, so a deterministic unique claim + // is the portable single-use guard for concurrent rotations. + yield* claimOperation({ + claimId: `delegation_claim_rotate_${row.id}`, + action: "grant.rotate.claim", + actorSubject: input.actorSubject, + effectiveSubject: target, + }).pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("revoked", "Delegated key rotation has already been used.")), + ), + ); + // Keep the old row as a revocation tombstone. The conditional update + // means a stale concurrent rotation cannot silently revive it. + yield* db.use("delegation.issued-key.rotate.revoke", (query) => + query.updateMany("credential_delegation", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", row.id), + builder.isNull("revoked_at"), + ), + set: { revoked_at: at, revoke_reason: "rotated" }, + }), + ); + yield* db.use("delegation.issued-key.rotate.create", (query) => + query.create("credential_delegation", { + tenant, + id: String(grantId), + label: row.label, + created_by: row.created_by, + credential_id: String(credentialId), + credential_kind: credential.kind, + adapter: credential.adapter, + issuer: credential.issuer, + audience: credential.audience, + credential_subject: credential.subject, + credential_ref: credential.credentialRef, + issued_key_ref: keyRef, + key_hash: generated.hash, + actor_subject: String(actorSubject), + effective_subject: row.effective_subject, + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: scope.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope: encodeScope(scope), + scope_version: String(scopeVersion), + expires_at: expiresAt, + revoked_at: null, + revoke_reason: null, + created_at: nowDate(at), + last_used_at: null, + }), + ); + yield* audit({ + grantId: DelegationId.make(row.id), + credentialId: CredentialId.make(row.credential_id), + actorSubject: input.actorSubject, + effectiveSubject: target, + action: "grant.rotate.revoke", + outcome: "revoked", + }); + yield* audit({ + grantId, + credentialId, + actorSubject: input.actorSubject, + effectiveSubject: target, + action: "grant.rotate.create", + outcome: "created", + }); + yield* createNotification({ + grantId, + credentialId, + createdBy: Subject.make(row.created_by), + targetSubject: target, + label: row.label, + credentialKind: credential.kind, + }); + }), + ); + const grant: DelegationGrant = { + kind: "delegated", + tenant: options.tenant, + grantId, + credentialId, + credentialRef: credential.credentialRef, + actorSubject, + effectiveSubject: target, + scopeVersion, + expiresAt, + scope, + revalidate: (capability) => api.revalidate(grant, capability), + }; + return { + keyRef, + secret: `${keyRef}.${generated.secret}`, + grant, + } satisfies IssuedDelegationKey; + }), + { + actorSubject: input.actorSubject, + effectiveSubject: input.actorSubject, + action: "grant.rotate", + outcome: "denied", + }, + ); + + const registerProviderIssuedKey = (input: RegisterProviderIssuedDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + if (!nonEmpty(input.label) || !validCredential(input.credential)) { + return yield* Effect.fail(denied("invalid", "Provider key binding is invalid.")); + } + if (input.credential.kind !== "issued-key" || !nonEmpty(String(input.actorSubject))) { + return yield* Effect.fail(denied("invalid", "Provider key binding is invalid.")); + } + const scope = normalizeScope(input.scope); + if (!scope) return yield* Effect.fail(denied("invalid", "Key scope is invalid.")); + const issuedAt = now(); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + const expiresAt = + input.expiresAt ?? issuedAt + Math.min(DEFAULT_DELEGATION_TTL_MS, maxTtlMs); + if (!validTtlWindow(issuedAt, expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + if (!(yield* options.isVerifiedHuman(input.createdBy))) { + return yield* Effect.fail(denied("unauthorized", "Key creator is not a verified human.")); + } + if (!(yield* options.isVerifiedHuman(input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Key target is not a verified human.")); + } + if (!(yield* options.isManagementAuthorized(input.createdBy, input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Key creator is not authorized.")); + } + const verifyProviderCredential = options.verifyProviderCredential; + if (verifyProviderCredential === undefined) { + return yield* Effect.fail( + denied("unauthorized", "Provider key verification is unavailable."), + ); + } + if ( + !(yield* verifyProviderCredential({ + credential: input.credential, + actorSubject: input.actorSubject, + effectiveSubject: input.effectiveSubject, + })) + ) { + return yield* Effect.fail(denied("unauthorized", "Provider key is not authorized.")); + } + const grantId = DelegationId.make(`grant_${randomHex(16)}`); + const credentialId = CredentialId.make(`cred_${randomHex(16)}`); + if (!validScopeVersion(input.scopeVersion)) { + return yield* Effect.fail(denied("invalid", "Delegation scope version is invalid.")); + } + const scopeVersion = + input.scopeVersion ?? DelegationScopeVersion.make(`scope_${randomHex(16)}`); + yield* db + .transaction( + Effect.gen(function* () { + yield* db.use("delegation.provider-key.create", (query) => + query.create("credential_delegation", { + tenant, + id: String(grantId), + label: input.label, + created_by: String(input.createdBy), + credential_id: String(credentialId), + credential_kind: input.credential.kind, + adapter: input.credential.adapter, + issuer: input.credential.issuer, + audience: input.credential.audience, + credential_subject: input.credential.subject, + credential_ref: input.credential.credentialRef, + issued_key_ref: input.credential.credentialRef, + // Provider verification happens at the adapter boundary; + // core must never manufacture or persist a provider secret. + key_hash: null, + actor_subject: String(input.actorSubject), + effective_subject: String(input.effectiveSubject), + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: scope.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope: encodeScope(scope), + scope_version: String(scopeVersion), + expires_at: expiresAt, + revoked_at: null, + revoke_reason: null, + created_at: nowDate(issuedAt), + last_used_at: null, + }), + ); + yield* audit({ + grantId, + credentialId, + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "grant.create.provider-key", + outcome: "created", + }); + yield* createNotification({ + grantId, + credentialId, + createdBy: input.createdBy, + targetSubject: input.effectiveSubject, + label: input.label, + credentialKind: input.credential.kind, + }); + }), + ) + .pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("invalid", "Credential is already bound to a delegation.")), + ), + ); + const grant: DelegationGrant = { + kind: "delegated", + tenant: options.tenant, + grantId, + credentialId, + credentialRef: input.credential.credentialRef, + actorSubject: input.actorSubject, + effectiveSubject: input.effectiveSubject, + scopeVersion, + expiresAt, + scope, + revalidate: (capability) => api.revalidate(grant, capability), + }; + return grant; + }), + { + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "grant.create.provider-key", + outcome: "denied", + }, + ); + + const rotateProviderIssuedKey = (input: RotateProviderIssuedDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + if (!validCredential(input.credential) || input.credential.kind !== "issued-key") { + return yield* Effect.fail(denied("invalid", "Provider key binding is invalid.")); + } + if (!nonEmpty(String(input.actorSubject))) { + return yield* Effect.fail(denied("invalid", "Provider key actor is invalid.")); + } + const row = yield* db + .use("delegation.provider-key.rotate.get", (query) => + query.findFirst("credential_delegation", { + where: (builder) => tenantWhere(builder, "credential_id", String(input.credentialId)), + }), + ) + .pipe(Effect.map(parseGrant)); + if (!row) return yield* Effect.fail(denied("invalid", "Delegated key not found.")); + if ( + row.credential_kind !== "issued-key" || + row.issued_key_ref === null || + row.key_hash !== null + ) { + return yield* Effect.fail(denied("invalid", "Only provider-issued keys can rotate.")); + } + if (row.revoked_at !== null) { + return yield* Effect.fail(denied("revoked", "Delegated key is revoked.")); + } + const at = now(); + if (row.expires_at <= at) { + return yield* Effect.fail(denied("expired", "Delegated key is expired.")); + } + const target = Subject.make(row.effective_subject); + if (!(yield* options.isVerifiedHuman(input.managedBy))) { + return yield* Effect.fail(denied("unauthorized", "Key rotator is not a verified human.")); + } + if (!(yield* options.isManagementAuthorized(input.managedBy, target))) { + return yield* Effect.fail(denied("unauthorized", "Key rotator is not authorized.")); + } + const verifyProviderCredential = options.verifyProviderCredential; + if (verifyProviderCredential === undefined) { + return yield* Effect.fail( + denied("unauthorized", "Provider key verification is unavailable."), + ); + } + if ( + !(yield* verifyProviderCredential({ + credential: input.credential, + actorSubject: input.actorSubject, + effectiveSubject: target, + })) + ) { + return yield* Effect.fail(denied("unauthorized", "Provider key is not authorized.")); + } + const scope = rowScope(row); + if (!scope) return yield* Effect.fail(invalidStoredRow("credential_delegation")); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + const expiresAt = input.expiresAt ?? Math.min(row.expires_at, at + maxTtlMs); + if (!validTtlWindow(at, expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + const grantId = DelegationId.make(`grant_${randomHex(16)}`); + const credentialId = CredentialId.make(`cred_${randomHex(16)}`); + if (!validScopeVersion(input.scopeVersion)) { + return yield* Effect.fail(denied("invalid", "Delegation scope version is invalid.")); + } + const scopeVersion = + input.scopeVersion ?? DelegationScopeVersion.make(`scope_${randomHex(16)}`); + yield* db + .transaction( + Effect.gen(function* () { + yield* claimOperation({ + claimId: `delegation_claim_rotate_${row.id}`, + action: "grant.rotate.claim", + actorSubject: input.managedBy, + effectiveSubject: target, + }).pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("revoked", "Delegated key rotation has already been used.")), + ), + ); + yield* db.use("delegation.provider-key.rotate.revoke", (query) => + query.updateMany("credential_delegation", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", row.id), + builder.isNull("revoked_at"), + ), + set: { revoked_at: at, revoke_reason: "rotated" }, + }), + ); + yield* db.use("delegation.provider-key.rotate.create", (query) => + query.create("credential_delegation", { + tenant, + id: String(grantId), + label: row.label, + created_by: row.created_by, + credential_id: String(credentialId), + credential_kind: input.credential.kind, + adapter: input.credential.adapter, + issuer: input.credential.issuer, + audience: input.credential.audience, + credential_subject: input.credential.subject, + credential_ref: input.credential.credentialRef, + issued_key_ref: input.credential.credentialRef, + key_hash: null, + actor_subject: String(input.actorSubject), + effective_subject: row.effective_subject, + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: scope.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope: encodeScope(scope), + scope_version: String(scopeVersion), + expires_at: expiresAt, + revoked_at: null, + revoke_reason: null, + created_at: nowDate(at), + last_used_at: null, + }), + ); + yield* audit({ + grantId: DelegationId.make(row.id), + credentialId: CredentialId.make(row.credential_id), + actorSubject: input.managedBy, + effectiveSubject: target, + action: "grant.rotate.revoke", + outcome: "revoked", + }); + yield* audit({ + grantId, + credentialId, + actorSubject: input.managedBy, + effectiveSubject: target, + action: "grant.rotate.create.provider-key", + outcome: "created", + }); + yield* createNotification({ + grantId, + credentialId, + createdBy: Subject.make(row.created_by), + targetSubject: target, + label: row.label, + credentialKind: input.credential.kind, + }); + }), + ) + .pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("invalid", "Credential is already bound to a delegation.")), + ), + ); + const grant: DelegationGrant = { + kind: "delegated", + tenant: options.tenant, + grantId, + credentialId, + credentialRef: input.credential.credentialRef, + actorSubject: input.actorSubject, + effectiveSubject: target, + scopeVersion, + expiresAt, + scope, + revalidate: (capability) => api.revalidate(grant, capability), + }; + return grant; + }), + { + actorSubject: input.managedBy, + effectiveSubject: input.managedBy, + action: "grant.rotate.provider-key", + outcome: "denied", + }, + ); + + const expand = (input: ExpandDelegationInput) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + const row = yield* db + .use("delegation.grant.expand.get", (query) => + query.findFirst("credential_delegation", { + where: (builder) => tenantWhere(builder, "credential_id", String(input.credentialId)), + }), + ) + .pipe(Effect.map(parseGrant)); + if (!row) return yield* Effect.fail(denied("invalid", "Delegation grant not found.")); + if (row.revoked_at !== null) + return yield* Effect.fail(denied("revoked", "Delegation revoked.")); + const at = now(); + if (row.expires_at <= at) + return yield* Effect.fail(denied("expired", "Delegation expired.")); + if (!validEpoch(input.expiresAt) || input.expiresAt <= at) { + return yield* Effect.fail(denied("invalid", "Delegation expiry is invalid.")); + } + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + if (!validTtlWindow(at, input.expiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry is outside the allowed window."), + ); + } + const previous = rowScope(row); + const next = normalizeScope(input.scope); + if (!previous || !next) { + return yield* Effect.fail(denied("scope", "Delegation scope is invalid.")); + } + if (!isScopeExpansion(previous, next)) { + return yield* Effect.fail( + denied("scope", "Delegation scope may only expand with consent."), + ); + } + const target = Subject.make(row.effective_subject); + if (!(yield* options.isVerifiedHuman(input.consentedBy))) { + return yield* Effect.fail( + denied("unauthorized", "Grant consenter is not a verified human."), + ); + } + if (!(yield* options.isVerifiedHuman(target))) { + return yield* Effect.fail( + denied("unauthorized", "Delegated subject is no longer authorized."), + ); + } + if (!(yield* options.isManagementAuthorized(input.consentedBy, target))) { + return yield* Effect.fail(denied("unauthorized", "Grant consenter is not authorized.")); + } + if ( + input.expectedScopeVersion !== undefined && + String(input.expectedScopeVersion) !== row.scope_version + ) { + return yield* Effect.fail(denied("scope", "Delegation scope changed before consent.")); + } + if (!validScopeVersion(input.scopeVersion)) { + return yield* Effect.fail(denied("invalid", "Delegation scope version is invalid.")); + } + const scopeVersion = + input.scopeVersion ?? DelegationScopeVersion.make(`scope_${randomHex(16)}`); + yield* db.transaction( + Effect.gen(function* () { + yield* db.use("delegation.grant.expand", (query) => + query.updateMany("credential_delegation", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("credential_id", "=", String(input.credentialId)), + builder("scope_version", "=", row.scope_version), + builder.isNull("revoked_at"), + ), + set: { + scope: encodeScope(next), + toolkit_membership: encodeScope({ + actions: ["discover", "invoke"], + toolkitMembership: next.toolkitMembership, + toolAddresses: [], + connectionAddresses: [], + }), + scope_version: String(scopeVersion), + expires_at: input.expiresAt, + }, + }), + ); + yield* audit({ + grantId: DelegationId.make(row.id), + credentialId: input.credentialId, + actorSubject: input.consentedBy, + effectiveSubject: target, + action: "grant.expand", + outcome: "changed", + }); + }), + ); + const grant = toGrant( + api, + { + ...row, + scope: encodeScope(next), + scope_version: String(scopeVersion), + expires_at: input.expiresAt, + }, + next, + ); + return grant; + }), + { + actorSubject: input.consentedBy, + effectiveSubject: input.consentedBy, + action: "grant.expand", + outcome: "denied", + }, + ); + + const revoke = (input: { + readonly grantId: DelegationId; + readonly actorSubject: Subject; + readonly reason?: string; + }) => + withDenialAudit( + Effect.gen(function* () { + const row = yield* findGrant(input.grantId); + if (!row) return yield* Effect.fail(denied("invalid", "Delegation grant not found.")); + if (row.revoked_at !== null) { + return yield* Effect.fail(denied("revoked", "Delegation already revoked.")); + } + if (!(yield* options.isVerifiedHuman(input.actorSubject))) { + return yield* Effect.fail(denied("unauthorized", "Revoker is not a verified human.")); + } + if ( + !(yield* options.isManagementAuthorized( + input.actorSubject, + Subject.make(row.effective_subject), + )) + ) { + return yield* Effect.fail(denied("unauthorized", "Revoker is not authorized.")); + } + const at = now(); + yield* db.transaction( + Effect.gen(function* () { + yield* db.use("delegation.grant.revoke", (query) => + query.updateMany("credential_delegation", { + where: (builder) => + builder.and( + tenantWhere(builder, "id", String(input.grantId)), + builder.isNull("revoked_at"), + ), + set: { revoked_at: at, revoke_reason: input.reason ?? "revoked" }, + }), + ); + yield* audit({ + grantId: input.grantId, + credentialId: CredentialId.make(row.credential_id), + actorSubject: input.actorSubject, + effectiveSubject: Subject.make(row.effective_subject), + action: "grant.revoke", + outcome: "revoked", + }); + }), + ); + }), + { + actorSubject: input.actorSubject, + effectiveSubject: input.actorSubject, + action: "grant.revoke", + outcome: "denied", + }, + ); + + const revokeAll = (input: { + readonly effectiveSubject: Subject; + readonly actorSubject: Subject; + }) => + withDenialAudit( + Effect.gen(function* () { + if (!(yield* options.isVerifiedHuman(input.actorSubject))) { + return yield* Effect.fail(denied("unauthorized", "Revoker is not a verified human.")); + } + if (!(yield* options.isManagementAuthorized(input.actorSubject, input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Revoker is not authorized.")); + } + return yield* db.transaction( + Effect.gen(function* () { + const rows = yield* db.use("delegation.grant.listForSubject", (query) => + query.findMany("credential_delegation", { + where: (builder) => + builder.and( + tenantWhere(builder, "effective_subject", String(input.effectiveSubject)), + builder.isNull("revoked_at"), + ), + }), + ); + const at = now(); + yield* db.use("delegation.grant.revokeAll", (query) => + query.updateMany("credential_delegation", { + where: (builder) => + builder.and( + tenantWhere(builder, "effective_subject", String(input.effectiveSubject)), + builder.isNull("revoked_at"), + ), + set: { revoked_at: at, revoke_reason: "subject-revoked" }, + }), + ); + yield* Effect.forEach(rows, (raw) => { + const row = parseGrant(raw); + if (!row) return Effect.fail(invalidStoredRow("credential_delegation")); + return audit({ + grantId: DelegationId.make(row.id), + credentialId: CredentialId.make(row.credential_id), + actorSubject: input.actorSubject, + effectiveSubject: input.effectiveSubject, + action: "grant.revoke_all", + outcome: "revoked", + }); + }); + return rows.length; + }), + ); + }), + { + actorSubject: input.actorSubject, + effectiveSubject: input.effectiveSubject, + action: "grant.revoke_all", + outcome: "denied", + }, + ); + + const issueProofChallenge = (input: { + readonly adapter: string; + readonly createdBy: Subject; + readonly effectiveSubject: Subject; + readonly label: string; + readonly scope: DelegationScope; + readonly grantExpiresAt?: number; + }) => + withDenialAudit( + Effect.gen(function* () { + if (!options.issuanceEnabled) { + return yield* Effect.fail( + denied("management", "Delegated credential issuance is disabled."), + ); + } + if (!nonEmpty(input.adapter) || !nonEmpty(input.label)) { + return yield* Effect.fail(denied("invalid", "Challenge adapter and label are required.")); + } + if (!(yield* options.isVerifiedHuman(input.createdBy))) { + return yield* Effect.fail( + denied("unauthorized", "Challenge creator is not a verified human."), + ); + } + if (!(yield* options.isVerifiedHuman(input.effectiveSubject))) { + return yield* Effect.fail( + denied("unauthorized", "Challenge target is not a verified human."), + ); + } + if (!(yield* options.isManagementAuthorized(input.createdBy, input.effectiveSubject))) { + return yield* Effect.fail(denied("unauthorized", "Challenge creator is not authorized.")); + } + const scope = normalizeScope(input.scope); + if (!scope) + return yield* Effect.fail(denied("invalid", "Proof scope must contain exact addresses.")); + const issuedAt = now(); + const maxTtlMs = options.maxTtlMs ?? MAX_DELEGATION_TTL_MS; + const grantExpiresAt = + input.grantExpiresAt ?? issuedAt + Math.min(DEFAULT_DELEGATION_TTL_MS, maxTtlMs); + if (!validTtlWindow(issuedAt, grantExpiresAt, maxTtlMs)) { + return yield* Effect.fail( + denied("invalid", "Delegation expiry must be in the allowed window."), + ); + } + const challengeId = `challenge_${randomHex(16)}`; + const nonce = randomHex(32); + const challengeExpiresAt = issuedAt + PROOF_CHALLENGE_TTL_MS; + if (!validTtlWindow(issuedAt, challengeExpiresAt, PROOF_CHALLENGE_TTL_MS)) { + return yield* Effect.fail(denied("invalid", "Proof challenge expiry is invalid.")); + } + const proofHash = yield* sha256Hex(nonce); + yield* db.transaction( + Effect.gen(function* () { + yield* db.use("delegation.challenge.create", (query) => + query.create("delegation_challenge", { + tenant, + id: challengeId, + created_by: String(input.createdBy), + label: input.label, + credential_id: null, + credential_kind: null, + adapter: input.adapter, + issuer: null, + audience: null, + credential_subject: null, + credential_ref: null, + actor_subject: null, + effective_subject: String(input.effectiveSubject), + scope: encodeScope(scope), + proof_hash: proofHash, + expires_at: challengeExpiresAt, + grant_expires_at: grantExpiresAt, + proof_consumed_at: null, + consent_consumed_at: null, + created_at: nowDate(issuedAt), + }), + ); + yield* audit({ + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "proof.challenge", + outcome: "challenge", + }); + }), + ); + return { + challengeId, + nonce, + adapter: input.adapter, + createdBy: input.createdBy, + effectiveSubject: input.effectiveSubject, + label: input.label, + scope, + grantExpiresAt, + expiresAt: challengeExpiresAt, + }; + }), + { + actorSubject: input.createdBy, + effectiveSubject: input.effectiveSubject, + action: "proof.challenge", + outcome: "denied", + }, + ); + + const completeProofChallenge = (input: { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: Subject; + readonly nonce: string; + }) => + withDenialAudit( + Effect.gen(function* () { + if (!validCredential(input.credential) || !nonEmpty(String(input.machineActor))) { + return yield* Effect.fail(denied("invalid", "Verified machine credential is invalid.")); + } + return yield* db.transaction( + Effect.gen(function* () { + const rawRow = yield* db.use("delegation.challenge.get", (query) => + query.findFirst("delegation_challenge", { + where: (builder) => tenantWhere(builder, "id", input.challengeId), + }), + ); + const row = rawRow ? parseChallenge(rawRow) : null; + if (!row || row.proof_consumed_at !== null || row.expires_at <= now()) { + return yield* Effect.fail( + denied("proof", "Proof challenge is expired or already used."), + ); + } + if (row.adapter !== input.credential.adapter) { + return yield* Effect.fail( + denied("proof", "Credential adapter does not match the challenge."), + ); + } + const proofHash = yield* sha256Hex(input.nonce); + if (!constantTimeHexEqual(proofHash, row.proof_hash)) { + return yield* Effect.fail(denied("proof", "Challenge nonce is invalid.")); + } + if ( + !(yield* options.verifyProof({ + challengeId: row.id, + credential: input.credential, + machineActor: input.machineActor, + nonce: input.nonce, + })) + ) { + return yield* Effect.fail(denied("proof", "Proof of possession failed.")); + } + const completedScope = rowScope(row); + if (!completedScope) + return yield* Effect.fail(denied("proof", "Proof challenge scope is invalid.")); + yield* claimChallengePhase({ + challengeId: input.challengeId, + phase: "proof", + actorSubject: input.machineActor, + effectiveSubject: Subject.make(row.effective_subject), + }).pipe( + Effect.catchTag("UniqueViolationError", () => + Effect.fail(denied("proof", "Proof challenge has already been used.")), + ), + ); + yield* db.use("delegation.challenge.consumeProof", (query) => + query.updateMany("delegation_challenge", { + where: (builder) => + builder.and( + builder("tenant", "=", tenant), + builder("id", "=", input.challengeId), + builder.isNull("proof_consumed_at"), + ), + set: { + credential_kind: input.credential.kind, + issuer: input.credential.issuer, + audience: input.credential.audience, + credential_subject: input.credential.subject, + credential_ref: input.credential.credentialRef, + actor_subject: String(input.machineActor), + proof_consumed_at: now(), + }, + }), + ); + const consumedRaw = yield* db.use("delegation.challenge.consumeProof.verify", (query) => + query.findFirst("delegation_challenge", { + where: (builder) => tenantWhere(builder, "id", input.challengeId), + }), + ); + const consumed = consumedRaw ? parseChallenge(consumedRaw) : null; + if ( + !consumed || + consumed.proof_consumed_at === null || + consumed.credential_kind !== input.credential.kind || + consumed.issuer !== input.credential.issuer || + consumed.audience !== input.credential.audience || + consumed.credential_subject !== input.credential.subject || + consumed.credential_ref !== input.credential.credentialRef || + consumed.actor_subject !== String(input.machineActor) + ) { + return yield* Effect.fail(denied("proof", "Proof challenge was already used.")); + } + yield* audit({ + actorSubject: input.machineActor, + effectiveSubject: Subject.make(row.effective_subject), + action: "proof.complete", + outcome: "allowed", + }); + return { + challengeId: row.id, + credential: input.credential, + actorSubject: input.machineActor, + effectiveSubject: Subject.make(row.effective_subject), + scope: completedScope, + } satisfies ProofCompletion; + }), + ); + }), + { + actorSubject: input.machineActor, + effectiveSubject: input.machineActor, + action: "proof.complete", + outcome: "denied", + }, + ); + + api = { + create, + resolve, + resolveIssuedKey, + resolveBinding, + revalidate, + get, + list, + getChallenge, + createIssuedKey, + rotateIssuedKey, + registerProviderIssuedKey, + rotateProviderIssuedKey, + expand, + revoke, + revokeAll, + audit, + listAudit, + listNotifications, + markNotificationRead, + issueProofChallenge, + completeProofChallenge, + }; + return api; +}; + +/** One gate used by catalog, schema, dispatch, approval, and resume paths. */ +export const assertDelegationCapability = ( + context: DelegationContext | undefined, + capability: DelegationCapability, +): Effect.Effect => + context === undefined + ? Effect.void + : Effect.gen(function* () { + yield* context.revalidate(capability); + if (capability.operation === "management") { + return yield* Effect.fail( + denied("management", "Delegated credentials cannot manage this surface."), + ); + } + }); + +export const isDelegatedContext = (value: unknown): value is DelegationContext => + asRecord(value)?.["kind"] === "delegated"; + +export const generateDelegatedKey = (): Effect.Effect< + { readonly secret: string; readonly hash: string }, + never +> => + Effect.gen(function* () { + const secret = randomHex(32); + return { secret, hash: yield* sha256Hex(secret) }; + }); diff --git a/packages/core/sdk/src/fuma-runtime.ts b/packages/core/sdk/src/fuma-runtime.ts index bb7b7a1e57..f3f8c82bd6 100644 --- a/packages/core/sdk/src/fuma-runtime.ts +++ b/packages/core/sdk/src/fuma-runtime.ts @@ -26,6 +26,22 @@ export class UniqueViolationError extends Data.TaggedError("UniqueViolationError readonly model?: string; }> {} +/** A delegated credential cannot use the requested SDK surface. This is a + * storage-shaped failure so every existing executor boundary fails closed + * without widening each public method's error union. */ +export class DelegationDeniedError extends Data.TaggedError("DelegationDeniedError")<{ + readonly message: string; + readonly reason: + | "expired" + | "revoked" + | "unmapped" + | "unauthorized" + | "scope" + | "management" + | "proof" + | "invalid"; +}> {} + /** * The database connection itself failed — the statement never got a verdict. * Distinct from `StorageError` (a query the backend answered with an error) @@ -60,7 +76,8 @@ export type StorageFailure = | StorageError | CredentialWriteIncompleteError | StorageConnectionError - | UniqueViolationError; + | UniqueViolationError + | DelegationDeniedError; export type FumaTables = Record; type EmptyFumaSchema = FumaSchema<"latest", Record>; @@ -196,7 +213,8 @@ export const isStorageFailure = (error: unknown): error is StorageFailure => Predicate.isTagged(error, "StorageError") || Predicate.isTagged(error, "CredentialWriteIncompleteError") || Predicate.isTagged(error, "StorageConnectionError") || - Predicate.isTagged(error, "UniqueViolationError"); + Predicate.isTagged(error, "UniqueViolationError") || + Predicate.isTagged(error, "DelegationDeniedError"); export const fumaFailureFromCause = (label: string, cause: unknown): StorageFailure => { if (isStorageFailure(cause)) return cause; diff --git a/packages/core/sdk/src/ids.ts b/packages/core/sdk/src/ids.ts index 432295d7f7..ec7783ddfc 100644 --- a/packages/core/sdk/src/ids.ts +++ b/packages/core/sdk/src/ids.ts @@ -75,6 +75,18 @@ export type Tenant = typeof Tenant.Type; export const Subject = Schema.String.pipe(Schema.brand("Subject")); export type Subject = typeof Subject.Type; +/** Immutable identifier for one delegated authorization grant. */ +export const DelegationId = Schema.String.pipe(Schema.brand("DelegationId")); +export type DelegationId = typeof DelegationId.Type; + +/** Identifier for the credential binding a delegated grant. */ +export const CredentialId = Schema.String.pipe(Schema.brand("CredentialId")); +export type CredentialId = typeof CredentialId.Type; + +/** Monotonic/opaque scope version captured at consent. */ +export const DelegationScopeVersion = Schema.String.pipe(Schema.brand("DelegationScopeVersion")); +export type DelegationScopeVersion = typeof DelegationScopeVersion.Type; + /** * Who owns a connection: the org (tenant-shared, everyone uses it) or the acting * user (this subject's own). The `` segment of an address. Maps onto the diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 627d3de1de..80915a7d19 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -41,6 +41,7 @@ export { StorageError, StorageConnectionError, UniqueViolationError, + DelegationDeniedError, isStorageFailure, } from "./fuma-runtime"; @@ -61,8 +62,51 @@ export { ArtifactId, Tenant, Subject, + DelegationId, + CredentialId, + DelegationScopeVersion, Owner, } from "./ids"; +export { + DEFAULT_DELEGATION_TTL_MS, + MAX_DELEGATION_TTL_MS, + PROOF_CHALLENGE_TTL_MS, + assertDelegationCapability, + generateDelegatedKey, + isDelegatedContext, + makeDelegationService, + type CreateDelegationInput, + type CreateIssuedDelegationInput, + type ExpandDelegationInput, + type DelegatedPrincipal, + type DelegationBinding, + type DelegationAuditEvent, + type DelegationAuditRecord, + type DelegationCapability, + type DelegationChallengeView, + type DelegationContext, + type DelegationGrant, + type DelegationGrantView, + type DelegationOperation, + type DelegationNotificationRecord, + type DelegationScopeAction, + type DelegationScope, + type DelegationService, + type DelegationStoreOptions, + type IssuedDelegationKey, + type ProofChallenge, + type ProofCompletion, + type RegisterProviderIssuedDelegationInput, + type RotateIssuedDelegationInput, + type RotateProviderIssuedDelegationInput, + type VerifiedDelegatedCredential, +} from "./delegation"; +export { + makeVerifiedHumanSubjectRegistry, + REVOKED_HUMAN_SUBJECT_STATUS, + VERIFIED_HUMAN_SUBJECT_STATUS, + type VerifiedHumanSubjectRegistry, +} from "./subject-registry"; export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier"; // Errors (tagged) — the ExecuteError set + integration lifecycle. @@ -157,6 +201,10 @@ export { type CoreSchema, type IntegrationRow, type SubjectRow, + type CredentialDelegationRow, + type DelegationChallengeRow, + type DelegationAuditRow, + type DelegationNotificationRow, type ConnectionRow, type OAuthClientRow, type OAuthSessionRow, @@ -262,6 +310,7 @@ export { PendingApproval, PENDING_APPROVAL_TTL_MS, type PendingApprovalStore, + type PendingApprovalRecord, } from "./pending-approval"; // Plugin storage. diff --git a/packages/core/sdk/src/pending-approval.test.ts b/packages/core/sdk/src/pending-approval.test.ts index 1224b72bba..e574e33624 100644 --- a/packages/core/sdk/src/pending-approval.test.ts +++ b/packages/core/sdk/src/pending-approval.test.ts @@ -7,6 +7,18 @@ import { PENDING_APPROVAL_TTL_MS, type PendingApproval, } from "./pending-approval"; +import { CredentialId, DelegationId, DelegationScopeVersion, Subject, Tenant } from "./ids"; +import type { DelegationBinding } from "./delegation"; + +const delegationBinding: DelegationBinding = { + tenant: Tenant.make("tenant-a"), + grantId: DelegationId.make("grant_1"), + credentialId: CredentialId.make("credential_1"), + credentialRef: "service-token-1", + actorSubject: Subject.make("machine-1"), + effectiveSubject: Subject.make("human-1"), + scopeVersion: DelegationScopeVersion.make("scope_1"), +}; const approval = (overrides?: Partial): PendingApproval => ({ executionId: "exec_1", @@ -28,6 +40,45 @@ describe("makePendingApprovalStore", () => { }), ); + it.effect("round-trips the exact delegated binding when present", () => + Effect.gen(function* () { + const store = makePendingApprovalStore(makeInMemoryBlobStore(), "u:t:s"); + const recorded = approval({ delegationBinding }); + yield* store.put(recorded); + + expect(yield* store.consume("exec_1")).toStrictEqual(recorded); + }), + ); + + it.effect("does not consume a same-partition record for another grant", () => + Effect.gen(function* () { + const store = makePendingApprovalStore(makeInMemoryBlobStore(), "u:t:s"); + yield* store.put(approval({ delegationBinding })); + + const otherGrant: DelegationBinding = { + ...delegationBinding, + grantId: DelegationId.make("grant_other"), + }; + expect( + yield* store.consume( + "exec_1", + (candidate) => + candidate.delegationBinding !== undefined && + candidate.delegationBinding.grantId === otherGrant.grantId, + ), + ).toBeNull(); + + expect( + yield* store.consume( + "exec_1", + (candidate) => + candidate.delegationBinding !== undefined && + candidate.delegationBinding.grantId === delegationBinding.grantId, + ), + ).not.toBeNull(); + }), + ); + // The whole point of the record: the engine that paused is gone, so the // approval has to be readable by a caller that never saw the pause. it.effect("is readable through a second store over the same partition", () => diff --git a/packages/core/sdk/src/pending-approval.ts b/packages/core/sdk/src/pending-approval.ts index 72c76fa7ef..a1c522ea25 100644 --- a/packages/core/sdk/src/pending-approval.ts +++ b/packages/core/sdk/src/pending-approval.ts @@ -26,6 +26,7 @@ import { Effect, Option, Schema } from "effect"; import type { BlobStore } from "./blob"; import type { StorageError } from "./fuma-runtime"; +import { CredentialId, DelegationId, DelegationScopeVersion, Subject, Tenant } from "./ids"; /** How long a recorded approval stays valid. Human-scale: someone reading an * argument preview and deciding takes minutes, not seconds. Past this the @@ -33,6 +34,19 @@ import type { StorageError } from "./fuma-runtime"; * action again, which is safe because nothing has run yet. */ export const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000; +/** The immutable grant identity captured when a delegated execution pauses. + * Branded ids keep this boundary aligned with the SDK's resolver rather than + * allowing a resume caller to supply a looser, look-alike object. */ +const DelegationBindingSchema = Schema.Struct({ + tenant: Tenant, + grantId: DelegationId, + credentialId: CredentialId, + credentialRef: Schema.String, + actorSubject: Subject, + effectiveSubject: Subject, + scopeVersion: DelegationScopeVersion, +}); + /** * The resolved call an approval authorizes. * @@ -52,8 +66,15 @@ export const PendingApproval = Schema.Struct({ address: Schema.String, /** Epoch ms after which this record is ignored. */ expiresAt: Schema.Number, + /** Optional for records written before delegated execution retention was + * introduced. When present, a resume must resolve this exact binding again; + * it is never a bearer credential or a replacement for live revalidation. */ + delegationBinding: Schema.optional(DelegationBindingSchema), }); export type PendingApproval = typeof PendingApproval.Type; +/** Type-only alias for package barrels, where the runtime schema and its + * inferred type cannot be re-exported under the same name. */ +export type PendingApprovalRecord = PendingApproval; const encodePendingApproval = Schema.encodeUnknownSync(Schema.fromJsonString(PendingApproval)); const decodePendingApproval = Schema.decodeUnknownOption(Schema.fromJsonString(PendingApproval)); @@ -67,8 +88,16 @@ const decodePendingApproval = Schema.decodeUnknownOption(Schema.fromJsonString(P */ export interface PendingApprovalStore { readonly put: (approval: PendingApproval) => Effect.Effect; - /** Read and DELETE in one step — an approval authorizes exactly one run. */ - readonly consume: (executionId: string) => Effect.Effect; + /** Read without consuming. Expired or malformed records read as absent. */ + readonly get: (executionId: string) => Effect.Effect; + /** Read and DELETE in one step — an approval authorizes exactly one run. + * An optional matcher is evaluated before deletion. This lets a resume + * caller prove it owns the exact delegated binding without consuming a + * different grant that happens to share the same owner partition. */ + readonly consume: ( + executionId: string, + matches?: (approval: PendingApproval) => boolean, + ) => Effect.Effect; /** Drop a record without running it (decline/cancel). */ readonly discard: (executionId: string) => Effect.Effect; } @@ -91,13 +120,10 @@ export const makePendingApprovalStore = ( return { put: (approval) => blobs.put(namespace, approval.executionId, encodePendingApproval(approval)), - consume: (executionId) => + get: (executionId) => Effect.gen(function* () { const raw = yield* blobs.get(namespace, executionId); if (raw === null) return null; - // Consume before validating: a record we are about to reject is a record - // nobody should be able to retry against. - yield* blobs.delete(namespace, executionId); const decoded = decodePendingApproval(raw); if (Option.isNone(decoded)) return null; const approval = decoded.value; @@ -105,6 +131,26 @@ export const makePendingApprovalStore = ( return approval; }), + consume: (executionId, matches) => + Effect.gen(function* () { + const raw = yield* blobs.get(namespace, executionId); + if (raw === null) return null; + const decoded = decodePendingApproval(raw); + if (Option.isNone(decoded)) return null; + const approval = decoded.value; + if (approval.expiresAt <= now()) { + // Consume before returning an expired record: it cannot be retried. + yield* blobs.delete(namespace, executionId); + return null; + } + // A failed ownership/binding check deliberately leaves the record in + // place. The authorized human or exact delegated caller may still + // approve it; a same-partition caller must not be able to burn it. + if (matches !== undefined && !matches(approval)) return null; + yield* blobs.delete(namespace, executionId); + return approval; + }), + discard: (executionId) => blobs.delete(namespace, executionId), }; }; diff --git a/packages/core/sdk/src/subject-registry.test.ts b/packages/core/sdk/src/subject-registry.test.ts index c07ba0508a..ca10a5adcb 100644 --- a/packages/core/sdk/src/subject-registry.test.ts +++ b/packages/core/sdk/src/subject-registry.test.ts @@ -7,7 +7,13 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./i import type { ExecutorOwnerPolicyContext } from "./owner-policy"; import { definePlugin } from "./plugin"; import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; -import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; +import { + makeVerifiedHumanSubjectRegistry, + resetSubjectTouchCache, + REVOKED_HUMAN_SUBJECT_STATUS, + touchSubject, + VERIFIED_HUMAN_SUBJECT_STATUS, +} from "./subject-registry"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; // `touchSubject` is the only writer of the `subject` table. Written against the @@ -311,6 +317,70 @@ describe("touchSubject", () => { ); }); +describe("verified human subject registry", () => { + it.effect("creates a subject at the verified browser boundary", () => + withDb((db) => + Effect.gen(function* () { + const registry = makeVerifiedHumanSubjectRegistry(db.db); + expect(yield* registry.isVerifiedHuman({ tenant: TENANT, subject: "new-human" })).toBe( + false, + ); + + yield* registry.markVerifiedHuman({ tenant: TENANT, subject: "new-human" }); + + expect(yield* registry.isVerifiedHuman({ tenant: TENANT, subject: "new-human" })).toBe( + true, + ); + const rows = yield* Effect.promise(() => + scopedTo(db, TENANT).findMany("subject", { + where: (builder) => builder("external_id", "=", "new-human"), + }), + ); + expect(rows[0]?.status).toBe(VERIFIED_HUMAN_SUBJECT_STATUS); + }), + ), + ); + + it.effect("keeps an operator revocation disabled across later sightings", () => + withDb((db) => + Effect.gen(function* () { + const registry = makeVerifiedHumanSubjectRegistry(db.db); + const input = { tenant: TENANT, subject: "offboarded-human" } as const; + yield* registry.revokeVerifiedHuman(input); + expect(yield* registry.isVerifiedHuman(input)).toBe(false); + + // A later verified browser sighting must not turn a durable tombstone + // back into a verified state. + yield* registry.markVerifiedHuman(input); + expect(yield* registry.isVerifiedHuman(input)).toBe(false); + const rows = yield* Effect.promise(() => + scopedTo(db, TENANT).findMany("subject", { + where: (builder) => builder("external_id", "=", input.subject), + }), + ); + expect(rows[0]?.status).toBe(REVOKED_HUMAN_SUBJECT_STATUS); + }), + ), + ); + + it.effect("partitions verification and revocation by tenant", () => + withDb((db) => + Effect.gen(function* () { + const registry = makeVerifiedHumanSubjectRegistry(db.db); + yield* registry.markVerifiedHuman({ tenant: TENANT, subject: "shared-human" }); + yield* registry.revokeVerifiedHuman({ tenant: OTHER_TENANT, subject: "shared-human" }); + + expect(yield* registry.isVerifiedHuman({ tenant: TENANT, subject: "shared-human" })).toBe( + true, + ); + expect( + yield* registry.isVerifiedHuman({ tenant: OTHER_TENANT, subject: "shared-human" }), + ).toBe(false); + }), + ), + ); +}); + // --------------------------------------------------------------------------- // The second writer: `connections.create`. The request seam covers every hosted // call, so this is the belt for a direct SDK/CLI caller that never passes diff --git a/packages/core/sdk/src/subject-registry.ts b/packages/core/sdk/src/subject-registry.ts index c103c13c64..67fe7cc6f0 100644 --- a/packages/core/sdk/src/subject-registry.ts +++ b/packages/core/sdk/src/subject-registry.ts @@ -28,7 +28,7 @@ import { Effect } from "effect"; import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query"; import type { AnyColumn } from "@executor-js/fumadb/schema"; -import { makeFumaClient, type FumaDb } from "./fuma-runtime"; +import { makeFumaClient, type FumaDb, type StorageFailure } from "./fuma-runtime"; import type { ExecutorOwnerPolicyContext } from "./owner-policy"; type AnyCb = ConditionBuilder>; @@ -107,6 +107,162 @@ export interface TouchSubjectInput { readonly lastSeenThrottleMs?: number; } +export const VERIFIED_HUMAN_SUBJECT_STATUS = "verified-human"; +/** + * A revoked human is deliberately represented by a durable tombstone. A + * later browser sighting can prove that the subject exists, but it must not + * silently re-enable delegation after an operator offboards the account. + */ +export const REVOKED_HUMAN_SUBJECT_STATUS = "delegation-revoked"; + +/** + * Small persistent seam for hosts that keep their authoritative membership + * directory outside the SDK. Hosts may wrap this with their membership read; + * an absent or non-verified row always returns false. + */ +export interface VerifiedHumanSubjectRegistry { + readonly isVerifiedHuman: (input: { + readonly tenant: string; + readonly subject: string; + }) => Effect.Effect; + readonly markVerifiedHuman: (input: { + readonly tenant: string; + readonly subject: string; + }) => Effect.Effect; + readonly revokeVerifiedHuman: (input: { + readonly tenant: string; + readonly subject: string; + }) => Effect.Effect; +} + +/** Build the SDK's persistent registry seam. The caller remains responsible + * for proving the human before calling markVerifiedHuman. */ +export const makeVerifiedHumanSubjectRegistry = (db: FumaDb): VerifiedHumanSubjectRegistry => { + const lookup = (input: { readonly tenant: string; readonly subject: string }) => + makeFumaClient( + withQueryContext(db, { + tenant: input.tenant, + subject: null, + } satisfies ExecutorOwnerPolicyContext), + ); + const where = + (input: { readonly tenant: string; readonly subject: string }) => + (builder: AnyCb): Condition | boolean => + builder.and(builder("tenant", "=", input.tenant), builder("external_id", "=", input.subject)); + const writableWhere = + (input: { readonly tenant: string; readonly subject: string }) => + (builder: AnyCb): Condition | boolean => + builder.and( + where(input)(builder), + // SQL NULL does not satisfy `!=`, so explicitly include the ordinary + // sighting state as well as any non-revoked lifecycle value. + builder.or(builder.isNull("status"), builder("status", "!=", REVOKED_HUMAN_SUBJECT_STATUS)), + ); + const isVerifiedHuman = (input: { readonly tenant: string; readonly subject: string }) => + lookup(input) + .use("subject.verifiedHuman.get", (query) => + asLooseSubjectDb(query).findFirst("subject", { where: where(input) }), + ) + .pipe(Effect.map((row) => row?.["status"] === VERIFIED_HUMAN_SUBJECT_STATUS)); + const markVerifiedHuman = (input: { readonly tenant: string; readonly subject: string }) => + Effect.gen(function* () { + const fuma = lookup(input); + const existing = yield* fuma.use("subject.verifiedHuman.get", (query) => + asLooseSubjectDb(query).findFirst("subject", { where: where(input) }), + ); + if (existing?.["status"] === REVOKED_HUMAN_SUBJECT_STATUS) return; + + if (existing) { + // The lifecycle predicate makes this a no-op if a revoke wins a race + // between the read and this update. The reread also makes that race + // explicit for drivers whose updateMany does not return a row count. + yield* fuma.use("subject.verifiedHuman.set", (query) => + asLooseSubjectDb(query).updateMany("subject", { + where: writableWhere(input), + set: { status: VERIFIED_HUMAN_SUBJECT_STATUS }, + }), + ); + const after = yield* fuma.use("subject.verifiedHuman.get", (query) => + asLooseSubjectDb(query).findFirst("subject", { where: where(input) }), + ); + if (after?.["status"] === REVOKED_HUMAN_SUBJECT_STATUS) return; + return; + } + + // Account flows can reach the verified browser boundary before the + // request sighting seam has created a subject row. Upsert here so that + // direct account management does not remain permanently unverified. + yield* fuma + .use("subject.verifiedHuman.create", (query) => + asLooseSubjectDb(query).create("subject", { + tenant: input.tenant, + external_id: input.subject, + created_at: new Date(), + last_seen_at: null, + status: VERIFIED_HUMAN_SUBJECT_STATUS, + }), + ) + .pipe( + // A concurrent revoke may create the tombstone first. In that case + // the guarded update below observes the tombstone and leaves it in + // place; a concurrent sighting may likewise win the insert race. + Effect.catchTag("UniqueViolationError", () => + fuma.use("subject.verifiedHuman.set", (query) => + asLooseSubjectDb(query).updateMany("subject", { + where: writableWhere(input), + set: { status: VERIFIED_HUMAN_SUBJECT_STATUS }, + }), + ), + ), + ); + }); + + const revokeVerifiedHuman = (input: { readonly tenant: string; readonly subject: string }) => + Effect.gen(function* () { + const fuma = lookup(input); + const existing = yield* fuma.use("subject.verifiedHuman.get", (query) => + asLooseSubjectDb(query).findFirst("subject", { where: where(input) }), + ); + if (existing?.["status"] === REVOKED_HUMAN_SUBJECT_STATUS) return; + + if (existing) { + yield* fuma.use("subject.verifiedHuman.revoke", (query) => + asLooseSubjectDb(query).updateMany("subject", { + where: where(input), + set: { status: REVOKED_HUMAN_SUBJECT_STATUS }, + }), + ); + return; + } + + yield* fuma + .use("subject.verifiedHuman.createRevocation", (query) => + asLooseSubjectDb(query).create("subject", { + tenant: input.tenant, + external_id: input.subject, + created_at: new Date(), + last_seen_at: null, + status: REVOKED_HUMAN_SUBJECT_STATUS, + }), + ) + .pipe( + Effect.catchTag("UniqueViolationError", () => + fuma.use("subject.verifiedHuman.revoke", (query) => + asLooseSubjectDb(query).updateMany("subject", { + where: where(input), + set: { status: REVOKED_HUMAN_SUBJECT_STATUS }, + }), + ), + ), + ); + }); + return { + isVerifiedHuman, + markVerifiedHuman, + revokeVerifiedHuman, + }; +}; + /** * Record that `externalId` was seen under `tenant`: create the row on first * sight, bump `last_seen_at` on later ones (subject to the throttle). Never