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 1/5] 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 From ab4a4aedbe946f7f98fc20e781d541cbbc5458cb Mon Sep 17 00:00:00 2001 From: donmasakayan <1178461+donmasakayan@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:04:55 +1300 Subject: [PATCH 2/5] feat: enforce delegated scope across execution and sessions --- bun.lock | 1 + packages/core/api/src/account/api.ts | 519 ++++++++++ .../src/account/delegation-provider.test.ts | 568 ++++++++++ .../api/src/account/delegation-provider.ts | 971 ++++++++++++++++++ .../core/api/src/account/handlers.test.ts | 107 ++ packages/core/api/src/account/handlers.ts | 138 ++- packages/core/api/src/account/org-slug.ts | 1 + packages/core/api/src/account/service.ts | 128 +++ packages/core/api/src/client.ts | 33 + .../core/api/src/handlers/executions.test.ts | 65 ++ packages/core/api/src/handlers/executions.ts | 201 +++- packages/core/api/src/handlers/tools.ts | 38 +- packages/core/api/src/index.ts | 33 + packages/core/api/src/server.ts | 24 +- .../src/server/delegated-middleware.test.ts | 314 ++++++ .../src/server/execution-stack-middleware.ts | 103 +- .../core/api/src/server/execution-stack.ts | 11 +- packages/core/api/src/server/executor-app.ts | 18 +- .../src/server/fixed-execution-middleware.ts | 3 +- .../core/api/src/server/host-foundation.ts | 68 +- packages/core/api/src/server/identity.test.ts | 66 ++ packages/core/api/src/server/identity.ts | 95 +- .../core/api/src/server/scoped-executor.ts | 4 + packages/core/api/src/services.ts | 49 +- packages/core/api/src/tools/api.ts | 5 +- .../core/execution/src/description.test.ts | 83 ++ packages/core/execution/src/description.ts | 38 +- packages/core/execution/src/engine.ts | 87 +- .../core/execution/src/tool-invoker.test.ts | 89 +- packages/core/sdk/src/api-errors.ts | 7 + packages/core/sdk/src/blob.ts | 36 + .../core/sdk/src/delegated-executor.test.ts | 298 ++++++ packages/core/sdk/src/executor.ts | 201 +++- packages/core/sdk/src/index.ts | 2 +- .../core/sdk/src/pending-approval.test.ts | 70 +- packages/core/sdk/src/pending-approval.ts | 15 +- packages/core/sdk/src/plugin.ts | 6 +- packages/core/sdk/src/shared.ts | 2 +- packages/hosts/cloudflare/package.json | 1 + .../hosts/cloudflare/src/blob-store.test.ts | 43 + packages/hosts/cloudflare/src/blob-store.ts | 11 + .../src/mcp/agent-session-durable-object.ts | 124 ++- .../hosts/cloudflare/src/mcp/do-headers.ts | 19 + .../src/mcp/execution-owner-directory.ts | 15 + packages/hosts/mcp/src/envelope.test.ts | 49 + .../mcp/src/in-memory-session-store.test.ts | 1 + .../hosts/mcp/src/in-memory-session-store.ts | 155 ++- packages/hosts/mcp/src/seams.ts | 78 +- packages/hosts/mcp/src/tool-server.ts | 7 +- packages/plugins/toolkits/src/server.test.ts | 121 ++- packages/plugins/toolkits/src/server.ts | 90 +- 51 files changed, 5015 insertions(+), 196 deletions(-) create mode 100644 packages/core/api/src/account/delegation-provider.test.ts create mode 100644 packages/core/api/src/account/delegation-provider.ts create mode 100644 packages/core/api/src/account/handlers.test.ts create mode 100644 packages/core/api/src/handlers/executions.test.ts create mode 100644 packages/core/api/src/server/delegated-middleware.test.ts create mode 100644 packages/core/api/src/server/identity.test.ts create mode 100644 packages/core/sdk/src/delegated-executor.test.ts diff --git a/bun.lock b/bun.lock index b2431ad8d7..667b2e3334 100644 --- a/bun.lock +++ b/bun.lock @@ -694,6 +694,7 @@ "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "miniflare": "4.20260424.0", "typescript": "catalog:", "urlpattern-polyfill": "^10.1.0", "vitest": "catalog:", diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts index 87ad648b9e..e0acd00b5b 100644 --- a/packages/core/api/src/account/api.ts +++ b/packages/core/api/src/account/api.ts @@ -46,6 +46,18 @@ export class AccountUnauthorized extends Schema.TaggedErrorClass()( + "AccountBadRequest", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class AccountConflict extends Schema.TaggedErrorClass()( + "AccountConflict", + { message: Schema.String }, + { httpApiStatus: 409 }, +) {} + // ── Shared shapes ──────────────────────────────────────────────────────────── export const AccountUser = Schema.Struct({ @@ -176,8 +188,264 @@ export const SuccessResponse = Schema.Struct({ success: Schema.Boolean, }); +// ── Delegated credentials ────────────────────────────────────────────────── +// +// A delegated credential is deliberately represented as a grant plus a +// provider reference. The API never returns an external client secret, and a +// local key's plaintext value is only present in the create/rotate response. +// `scope` is the exact membership snapshot the operator consented to; hosts +// must not silently widen it while resolving a request. + +export const DelegationScope = Schema.Struct({ + /** Closed v1 actions. Discovery covers catalog/schema; invocation covers + * approval/resume for an already-authorized execution. Management is never + * consentable. */ + scopes: Schema.Array(Schema.Literals(["discover", "invoke"])), + toolkitIds: Schema.Array(Schema.String), + /** Omitted means frozen toolkit membership; [] denies all connections. */ + connectionIds: Schema.optional(Schema.Array(Schema.String)), +}); + +export const DelegationTarget = Schema.Struct({ + id: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + isCurrentUser: Schema.Boolean, + status: Schema.Literals(["verified", "suspended", "removed"]), +}); + +export const DelegationTargetSelector = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("self") }), + Schema.Struct({ kind: Schema.Literal("verified-human"), id: Schema.String }), +]); + +export const DelegationCredentialReference = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("local-key"), prefix: Schema.String }), + Schema.Struct({ + kind: Schema.Literal("external"), + adapter: Schema.String, + /** Masked provider reference; never a raw secret or full binding tuple. */ + reference: Schema.String, + }), +]); + +export const DelegationLifecycle = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("active"), + expiresAt: Schema.String, + revokedAt: Schema.NullOr(Schema.String), + revokeReason: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + status: Schema.Literals(["expired", "revoked"]), + expiresAt: Schema.String, + revokedAt: Schema.NullOr(Schema.String), + revokeReason: Schema.NullOr(Schema.String), + }), +]); + +/** Audit actors are separate from grant targets: an administrator can create + * a grant for another human, and a machine actor must retain its own kind. */ +export const DelegationActor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("human"), + id: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("machine"), + adapter: Schema.String, + reference: Schema.String, + }), +]); + +export const DelegationNotification = Schema.Struct({ + id: Schema.String, + target: DelegationTarget, + title: Schema.String, + message: Schema.String, + createdAt: Schema.String, + readAt: Schema.NullOr(Schema.String), +}); + +export const DelegationDelivery = Schema.Struct({ + status: Schema.Literals(["queued", "sent", "unavailable", "failed"]), + channel: Schema.NullOr(Schema.String), + occurredAt: Schema.NullOr(Schema.String), +}); + +export const DelegatedCredentialSummary = Schema.Struct({ + id: Schema.String, + grantId: Schema.String, + label: Schema.String, + credentialKind: Schema.Literals(["local-key", "external"]), + reference: DelegationCredentialReference, + target: DelegationTarget, + scope: DelegationScope, + lifecycle: DelegationLifecycle, + createdAt: Schema.String, + createdBy: DelegationActor, + lastUsedAt: Schema.NullOr(Schema.String), + supportsRotation: Schema.Boolean, + notification: Schema.NullOr(DelegationDelivery), +}); + +export const DelegationCapabilities = Schema.Struct({ + issuanceEnabled: Schema.Boolean, + localKeysEnabled: Schema.Boolean, + externalAdapters: Schema.Array(Schema.String), + canFilterTargets: Schema.Boolean, +}); + +export const DelegatedCredentialsResponse = Schema.Struct({ + credentials: Schema.Array(DelegatedCredentialSummary), + nextCursor: Schema.NullOr(Schema.String), + capabilities: DelegationCapabilities, + notifications: Schema.Array(DelegationNotification), +}); + +export const DelegatedTargetsResponse = Schema.Struct({ + targets: Schema.Array(DelegationTarget), +}); + +/** Human-readable, target-scoped choices for an exact delegation snapshot. + * IDs are stable executor IDs/addresses; labels are display-only and never + * accepted as authorization input. */ +export const DelegationScopeConnectionOption = Schema.Struct({ + id: Schema.String, + label: Schema.String, + description: Schema.NullOr(Schema.String), + toolkitIds: Schema.Array(Schema.String), +}); + +export const DelegationScopeToolkitOption = Schema.Struct({ + id: Schema.String, + slug: Schema.String, + name: Schema.String, +}); + +export const DelegationScopeOptionsResponse = Schema.Struct({ + actions: Schema.Array(Schema.Literals(["discover", "invoke"])), + toolkits: Schema.Array(DelegationScopeToolkitOption), + connections: Schema.Array(DelegationScopeConnectionOption), + /** The host's effective maximum, including operator configuration. */ + maxExpiresAt: Schema.String, +}); + +export const CreateDelegatedKeyBody = Schema.Struct({ + label: Schema.String, + target: DelegationTargetSelector, + scope: DelegationScope, + expiresAt: Schema.String, +}); + +export const CreatedDelegatedKeyResponse = Schema.Struct({ + credential: DelegatedCredentialSummary, + /** One-time local key value. Never populated for an external credential. */ + value: Schema.String, +}); + +export const StartDelegationChallengeBody = Schema.Struct({ + adapter: Schema.String, + label: Schema.String, + target: DelegationTargetSelector, + scope: DelegationScope, + expiresAt: Schema.String, +}); + +export const DelegationChallengeResponse = Schema.Struct({ + id: Schema.String, + /** One-time challenge nonce. It is not a credential secret; the machine + * must still authenticate through the host adapter when presenting it. */ + nonce: Schema.String, + status: Schema.Literals(["awaiting-proof", "proof-complete", "consented", "expired"]), + expiresAt: Schema.String, + adapter: Schema.String, + target: DelegationTargetSelector, + scope: DelegationScope, + label: Schema.String, + instructions: Schema.NullOr(Schema.String), + /** The masked machine identity that proved possession, before final human + * consent creates the durable credential. */ + provenCredential: Schema.NullOr(DelegationCredentialReference), + credential: Schema.NullOr(DelegatedCredentialSummary), +}); + +/** Provider-specific proof metadata. Possession is established from the + * authenticated request; this body is intentionally not a client-id + * registration or a place to submit a raw secret. */ +export const CompleteDelegationProofBody = Schema.Struct({ + nonce: Schema.String, + proofType: Schema.String, +}); + +export const ConsentDelegationBody = Schema.Struct({ + scope: DelegationScope, + expiresAt: Schema.String, +}); + +export const RotateDelegatedKeyResponse = Schema.Struct({ + credential: DelegatedCredentialSummary, + value: Schema.String, +}); + +export const RevokeAllDelegatedCredentialsBody = Schema.Struct({ + target: DelegationTargetSelector, + /** Explicit operator action that disables future delegations for the target. + * Ordinary revoke-all keeps the target eligible for later consent. */ + disableTarget: Schema.optional(Schema.Boolean), +}); + +export const RevokeAllDelegatedCredentialsResponse = Schema.Struct({ + revokedCount: Schema.Number, +}); + +export const DelegationAuditEvent = Schema.Struct({ + id: Schema.String, + grantId: Schema.NullOr(Schema.String), + credentialId: Schema.NullOr(Schema.String), + actor: DelegationActor, + effectiveTarget: DelegationTarget, + action: Schema.String, + outcome: Schema.String, + tool: Schema.NullOr(Schema.String), + requestId: Schema.NullOr(Schema.String), + executionId: Schema.NullOr(Schema.String), + occurredAt: Schema.String, +}); + +export const DelegationAuditResponse = Schema.Struct({ + events: Schema.Array(DelegationAuditEvent), + nextCursor: Schema.NullOr(Schema.String), +}); + +export const DelegatedCredentialsQuery = Schema.Struct({ + targetId: Schema.optional(Schema.String), + includeRevoked: Schema.optional(Schema.Literals(["true", "false"])), + cursor: Schema.optional(Schema.String), +}); + +export const DelegationScopeOptionsQuery = Schema.Struct({ + targetId: Schema.optional(Schema.String), +}); + +export const DelegationAuditQuery = Schema.Struct({ + targetId: Schema.optional(Schema.String), + credentialId: Schema.optional(Schema.String), + cursor: Schema.optional(Schema.String), +}); + +export const DelegationNotificationsResponse = Schema.Struct({ + notifications: Schema.Array(DelegationNotification), +}); + +export const DelegationNotificationParams = { notificationId: Schema.String }; + const ApiKeyParams = { apiKeyId: Schema.String }; const MembershipParams = { membershipId: Schema.String }; +const DelegationCredentialParams = { credentialId: Schema.String }; +const DelegationChallengeParams = { challengeId: Schema.String }; // ── Group ──────────────────────────────────────────────────────────────────── @@ -278,6 +546,257 @@ export const AccountApi = HttpApiGroup.make("account") success: UpdateOrgNameResponse, error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization], }), + ) + .add( + HttpApiEndpoint.get("listDelegatedCredentials", "/account/delegated-credentials", { + query: DelegatedCredentialsQuery, + success: DelegatedCredentialsResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }), + ) + .add( + HttpApiEndpoint.get("listDelegationTargets", "/account/delegated-credentials/targets", { + success: DelegatedTargetsResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }), + ) + .add( + HttpApiEndpoint.get( + "listDelegationScopeOptions", + "/account/delegated-credentials/scope-options", + { + query: DelegationScopeOptionsQuery, + success: DelegationScopeOptionsResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.get( + "listDelegationNotifications", + "/account/delegated-credentials/notifications", + { + success: DelegationNotificationsResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "markDelegationNotificationRead", + "/account/delegated-credentials/notifications/:notificationId/read", + { + params: DelegationNotificationParams, + success: SuccessResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post("createDelegatedKey", "/account/delegated-credentials/keys", { + payload: CreateDelegatedKeyBody, + success: CreatedDelegatedKeyResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }), + ) + .add( + HttpApiEndpoint.post("startDelegationChallenge", "/account/delegated-credentials/challenges", { + payload: StartDelegationChallengeBody, + success: DelegationChallengeResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }), + ) + .add( + HttpApiEndpoint.get( + "getDelegationChallenge", + "/account/delegated-credentials/challenges/:challengeId", + { + params: DelegationChallengeParams, + success: DelegationChallengeResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "completeDelegationProof", + "/account/delegated-credentials/challenges/:challengeId/proof", + { + params: DelegationChallengeParams, + payload: CompleteDelegationProofBody, + success: DelegationChallengeResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "consentDelegationChallenge", + "/account/delegated-credentials/challenges/:challengeId/consent", + { + params: DelegationChallengeParams, + payload: ConsentDelegationBody, + success: DelegationChallengeResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "consentDelegatedCredential", + "/account/delegated-credentials/:credentialId/consent", + { + params: DelegationCredentialParams, + payload: ConsentDelegationBody, + success: DelegatedCredentialSummary, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "rotateDelegatedKey", + "/account/delegated-credentials/:credentialId/rotate", + { + params: DelegationCredentialParams, + success: RotateDelegatedKeyResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.delete( + "revokeDelegatedCredential", + "/account/delegated-credentials/:credentialId", + { + params: DelegationCredentialParams, + success: SuccessResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.post( + "revokeAllDelegatedCredentials", + "/account/delegated-credentials/revoke-all", + { + payload: RevokeAllDelegatedCredentialsBody, + success: RevokeAllDelegatedCredentialsResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }, + ), + ) + .add( + HttpApiEndpoint.get("listDelegationAudit", "/account/delegated-credentials/audit", { + query: DelegationAuditQuery, + success: DelegationAuditResponse, + error: [ + AccountError, + AccountUnauthorized, + AccountForbidden, + AccountNoOrganization, + AccountBadRequest, + AccountConflict, + ], + }), ); /** diff --git a/packages/core/api/src/account/delegation-provider.test.ts b/packages/core/api/src/account/delegation-provider.test.ts new file mode 100644 index 0000000000..431156a710 --- /dev/null +++ b/packages/core/api/src/account/delegation-provider.test.ts @@ -0,0 +1,568 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; + +import { AccountBadRequest, AccountError, AccountForbidden } from "./api"; +import { DelegationProvider, type DelegationRequestContext } from "./service"; +import { + collectTables, + ConnectionAddress, + makeDelegationService, + Tenant, + ToolAddress, + type DelegationService, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { + makeDelegationProvider, + type DelegationAccountKeyStore, + type DelegationAccountHost, + type DelegationHuman, +} from "./delegation-provider"; + +type DelegationScope = typeof import("./api").DelegationScope.Type; + +const REQUEST: DelegationRequestContext = { + headers: { cookie: "session" }, + requestOrigin: "https://executor.test", + browserOrigin: "https://executor.test", +}; +const TENANT = "tenant-provider-test"; +const SELF = "human-self"; +const ADMIN = "human-admin"; +const OTHER = "human-other"; +const TOOL = ToolAddress.make("tools.example.connection.main"); +const CONNECTION = ConnectionAddress.make("example.connection.main"); +const SCOPE: DelegationScope = { + scopes: ["discover", "invoke"], + toolkitIds: ["toolkit-main"], + connectionIds: [String(CONNECTION)], +}; +const CORE_SCOPE = { + actions: ["discover", "invoke"] as const, + toolkitMembership: [ + { + toolkitId: "toolkit-main", + revision: "revision-1", + toolAddresses: [TOOL], + connectionAddresses: [CONNECTION], + }, + ], + toolAddresses: [TOOL], + connectionAddresses: [CONNECTION], +}; +const CREDENTIAL: VerifiedDelegatedCredential = { + kind: "external", + adapter: "test-adapter", + issuer: "https://issuer.test", + audience: TENANT, + subject: "machine-public-subject", + credentialRef: "machine-reference", +}; + +let providerKeySequence = 0; + +const providerKeyStore = (options?: { + readonly bindFails?: boolean; + readonly revokeBoundFails?: boolean; + readonly revoked?: string[]; + readonly issued?: VerifiedDelegatedCredential[]; +}): DelegationAccountKeyStore => ({ + issue: (_request, _input) => + Effect.sync(() => { + providerKeySequence += 1; + const id = `provider-key-${providerKeySequence}`; + const credential: VerifiedDelegatedCredential = { + kind: "issued-key" as const, + adapter: "test-provider", + issuer: "https://provider.test", + audience: TENANT, + subject: id, + credentialRef: id, + }; + options?.issued?.push(credential); + return { + credential, + actorSubject: `machine:${id}`, + secret: `${id}.secret`, + }; + }), + bind: () => + options?.bindFails + ? Effect.fail(new AccountError({ message: "provider binding failed" })) + : Effect.void, + rotate: (_request, _input) => + Effect.sync(() => { + providerKeySequence += 1; + const id = `provider-key-${providerKeySequence}`; + const credential: VerifiedDelegatedCredential = { + kind: "issued-key" as const, + adapter: "test-provider", + issuer: "https://provider.test", + audience: TENANT, + subject: id, + credentialRef: id, + }; + options?.issued?.push(credential); + return { + credential, + actorSubject: `machine:${id}`, + secret: `${id}.secret`, + }; + }), + revoke: (_request, input) => + Effect.sync(() => { + options?.revoked?.push(input.credential.credentialRef); + }), + revokeBound: () => + options?.revokeBoundFails + ? Effect.fail(new AccountError({ message: "provider revoke failed" })) + : Effect.void, +}); + +const target = (id: string, current: string): typeof import("./api").DelegationTarget.Type => ({ + id, + email: `${id}@example.test`, + name: id, + isCurrentUser: id === current, + status: "verified", +}); + +const withDb = (body: (db: SqliteTestFumaDb) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + body, + (db) => Effect.promise(() => db.close()), + ); + +const makeHost = ( + db: SqliteTestFumaDb, + human: DelegationHuman, + proof: VerifiedDelegatedCredential = CREDENTIAL, + keys: DelegationAccountKeyStore = providerKeyStore(), +): DelegationAccountHost => { + const service: DelegationService = makeDelegationService({ + db: db.db, + tenant: Tenant.make(TENANT), + issuanceEnabled: true, + isVerifiedHuman: (subject) => + Effect.succeed([SELF, ADMIN, OTHER, "machine:provider-key"].includes(String(subject))), + isCurrentlyAuthorized: () => Effect.succeed(true), + isManagementAuthorized: (actor, targetSubject) => + Effect.succeed(String(actor) === ADMIN || String(actor) === String(targetSubject ?? actor)), + verifyProof: () => Effect.succeed(true), + verifyProviderCredential: () => Effect.succeed(true), + }); + const targets = [ + target(SELF, human.subject), + target(ADMIN, human.subject), + target(OTHER, human.subject), + ]; + return { + human: () => Effect.succeed(human), + service: () => Effect.succeed(service), + targets: () => Effect.succeed(targets), + target: (_request, subject) => + Effect.succeed(targets.find((item) => item.id === subject) ?? null), + scope: () => Effect.succeed(CORE_SCOPE), + proof: () => Effect.succeed({ credential: proof, machineActor: "machine:provider-key" }), + providerKeys: keys, + externalAdapters: ["test-adapter"], + localKeysEnabled: true, + issuanceEnabled: true, + }; +}; + +const providerFor = (host: DelegationAccountHost) => + Effect.scoped( + Layer.build(makeDelegationProvider(host)).pipe( + Effect.map((context) => Context.get(context, DelegationProvider)), + ), + ); + +describe("delegation account provider", () => { + it.effect("keeps ordinary revoke-all eligible for future consent", () => + withDb((db) => + Effect.gen(function* () { + let disabled = false; + const host: DelegationAccountHost = { + ...makeHost(db, { + subject: ADMIN, + organizationId: TENANT, + recent: true, + canFilterTargets: true, + }), + onRevokeAll: () => + Effect.sync(() => { + disabled = true; + }), + }; + const provider = yield* providerFor(host); + yield* provider.revokeAllDelegatedCredentials(REQUEST, { + target: { kind: "self" }, + }); + expect(disabled).toBe(false); + }), + ), + ); + + it.effect("limits self users to their own target and grants admins cross-target visibility", () => + withDb((db) => + Effect.gen(function* () { + const selfProvider = yield* providerFor( + makeHost(db, { subject: SELF, organizationId: TENANT, recent: true }), + ); + const body = { + label: "Self key", + target: { kind: "self" as const }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }; + yield* selfProvider.createDelegatedKey(REQUEST, body); + const denied = yield* selfProvider + .createDelegatedKey(REQUEST, { ...body, target: { kind: "verified-human", id: OTHER } }) + .pipe(Effect.flip); + expect(denied).toBeInstanceOf(AccountForbidden); + const selfRows = yield* selfProvider.listDelegatedCredentials(REQUEST, {}); + expect(selfRows.credentials).toHaveLength(1); + expect(selfRows.credentials[0]?.target.id).toBe(SELF); + const foreignRows = yield* selfProvider + .listDelegatedCredentials(REQUEST, { targetId: OTHER }) + .pipe(Effect.flip); + expect(foreignRows).toBeInstanceOf(AccountForbidden); + + const adminProvider = yield* providerFor( + makeHost(db, { + subject: ADMIN, + organizationId: TENANT, + recent: true, + canFilterTargets: true, + }), + ); + yield* adminProvider.createDelegatedKey(REQUEST, { + ...body, + target: { kind: "verified-human", id: OTHER }, + label: "Other key", + }); + yield* adminProvider.createDelegatedKey(REQUEST, { + ...body, + target: { kind: "verified-human", id: SELF }, + label: "Admin key for self", + }); + const otherRows = yield* adminProvider.listDelegatedCredentials(REQUEST, { + targetId: OTHER, + includeRevoked: "true", + }); + expect(otherRows.credentials).toHaveLength(1); + expect(otherRows.credentials[0]?.target.id).toBe(OTHER); + expect(otherRows.credentials[0]?.createdBy).toMatchObject({ kind: "human", id: ADMIN }); + const audit = yield* adminProvider.listDelegationAudit(REQUEST, { + credentialId: otherRows.credentials[0]?.id, + }); + const targetAudit = yield* adminProvider.listDelegationAudit(REQUEST, { + targetId: OTHER, + }); + expect(targetAudit.events.length).toBeGreaterThan(0); + expect(audit.events.length).toBeGreaterThan(0); + expect( + audit.events.every((event) => event.credentialId === otherRows.credentials[0]?.id), + ).toBe(true); + expect(audit.events[0]?.actor.kind).toBe("human"); + const selfAudit = yield* selfProvider.listDelegationAudit(REQUEST, {}); + expect( + selfAudit.events.some( + (event) => event.actor.kind === "human" && event.actor.id === ADMIN, + ), + ).toBe(true); + }), + ), + ); + + it.effect("requires the challenge nonce and preserves the proven machine reference", () => + withDb((db) => + Effect.gen(function* () { + const provider = yield* providerFor( + makeHost(db, { subject: SELF, organizationId: TENANT, recent: true }), + ); + const expiresAt = new Date(Date.now() + 86_400_000).toISOString(); + const challenge = yield* provider.startDelegationChallenge(REQUEST, { + adapter: "test-adapter", + label: "External agent", + target: { kind: "self" }, + scope: SCOPE, + expiresAt, + }); + const badProof = yield* provider + .completeDelegationProof(REQUEST, challenge.id, { nonce: "wrong", proofType: "bearer" }) + .pipe(Effect.flip); + expect(badProof).toBeInstanceOf(AccountBadRequest); + const proven = yield* provider.completeDelegationProof(REQUEST, challenge.id, { + nonce: challenge.nonce, + proofType: "bearer", + }); + expect(proven.status).toBe("proof-complete"); + expect(proven.provenCredential).toMatchObject({ + kind: "external", + reference: expect.stringContaining("…"), + }); + const consented = yield* provider.consentDelegationChallenge(REQUEST, challenge.id, { + scope: SCOPE, + expiresAt, + }); + expect(consented.status).toBe("consented"); + }), + ), + ); + + it.effect("requires recent human authentication for local key mutation", () => + withDb((db) => + Effect.gen(function* () { + const provider = yield* providerFor( + makeHost(db, { subject: SELF, organizationId: TENANT, recent: false }), + ); + const denied = yield* provider + .createDelegatedKey(REQUEST, { + label: "Recent auth key", + target: { kind: "self" }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }) + .pipe(Effect.flip); + expect(denied).toBeInstanceOf(AccountForbidden); + }), + ), + ); + + it.effect("fails closed when the host has no provider key authority", () => + withDb((db) => + Effect.gen(function* () { + const host = makeHost(db, { subject: SELF, organizationId: TENANT, recent: true }); + const provider = yield* providerFor({ ...host, providerKeys: undefined }); + const listed = yield* provider.listDelegatedCredentials(REQUEST, {}); + expect(listed.capabilities.localKeysEnabled).toBe(false); + const denied = yield* provider + .createDelegatedKey(REQUEST, { + label: "Unavailable key", + target: { kind: "self" }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }) + .pipe(Effect.flip); + expect(denied).toBeInstanceOf(AccountForbidden); + }), + ), + ); + + it.effect("rotates, revokes, and records durable target notices", () => + withDb((db) => + Effect.gen(function* () { + const adminProvider = yield* providerFor( + makeHost( + db, + { subject: ADMIN, organizationId: TENANT, recent: true, canFilterTargets: true }, + CREDENTIAL, + ), + ); + const created = yield* adminProvider.createDelegatedKey(REQUEST, { + label: "Rotatable", + target: { kind: "verified-human", id: OTHER }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }); + const targetProvider = yield* providerFor( + makeHost(db, { subject: OTHER, organizationId: TENANT, recent: true }, CREDENTIAL), + ); + const visible = yield* targetProvider.listDelegationNotifications(REQUEST); + expect(visible.notifications).toHaveLength(1); + const notificationId = visible.notifications[0]?.id; + expect(notificationId).toBeDefined(); + yield* targetProvider.markDelegationNotificationRead(REQUEST, notificationId!); + const read = yield* targetProvider.listDelegationNotifications(REQUEST); + expect(read.notifications[0]?.readAt).not.toBeNull(); + + const rotated = yield* adminProvider.rotateDelegatedKey(REQUEST, created.credential.id); + expect(rotated.value).not.toBe(created.value); + const all = yield* adminProvider.listDelegatedCredentials(REQUEST, { + targetId: OTHER, + includeRevoked: "true", + }); + expect(all.credentials).toHaveLength(2); + expect(all.credentials.filter((item) => item.lifecycle.status === "revoked")).toHaveLength( + 1, + ); + yield* adminProvider.revokeDelegatedCredential(REQUEST, rotated.credential.id); + const revoked = yield* adminProvider.listDelegatedCredentials(REQUEST, { + targetId: OTHER, + includeRevoked: "true", + }); + expect(revoked.credentials.every((item) => item.lifecycle.status === "revoked")).toBe(true); + }), + ), + ); + + it.effect("lets administrators revoke grants for removed targets", () => + withDb((db) => + Effect.gen(function* () { + const issued: VerifiedDelegatedCredential[] = []; + const adminHost = makeHost( + db, + { subject: ADMIN, organizationId: TENANT, recent: true, canFilterTargets: true }, + CREDENTIAL, + providerKeyStore({ issued }), + ); + const adminProvider = yield* providerFor(adminHost); + yield* adminProvider.createDelegatedKey(REQUEST, { + label: "Offboarding key", + target: { kind: "verified-human", id: OTHER }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }); + const providerCredential = issued[0]; + expect(providerCredential).toBeDefined(); + const service = yield* adminHost.service(REQUEST); + expect((yield* service.resolve(providerCredential!)).effectiveSubject).toBe(OTHER); + + let disabledSubject: string | null = null; + const removedTargetHost: DelegationAccountHost = { + ...adminHost, + // Revocation is an emergency authority-removal operation. An + // otherwise valid admin session may use it after the short MFA + // freshness window has elapsed. + human: () => + Effect.succeed({ + subject: ADMIN, + organizationId: TENANT, + recent: false, + canFilterTargets: true, + }), + targets: () => Effect.succeed([target(ADMIN, ADMIN)]), + target: () => Effect.succeed(null), + onRevokeAll: (_request, subject) => + Effect.sync(() => { + disabledSubject = subject; + }), + }; + const removedTargetProvider = yield* providerFor(removedTargetHost); + const historical = yield* removedTargetProvider.listDelegatedCredentials(REQUEST, { + targetId: OTHER, + includeRevoked: "true", + }); + expect(historical.credentials[0]?.target).toMatchObject({ + id: OTHER, + email: "", + status: "removed", + }); + const revoked = yield* removedTargetProvider.revokeAllDelegatedCredentials(REQUEST, { + target: { kind: "verified-human", id: OTHER }, + disableTarget: true, + }); + expect(revoked.revokedCount).toBe(1); + expect(disabledSubject).toBe(OTHER); + const denied = yield* service.resolve(providerCredential!).pipe(Effect.flip); + expect(denied).toMatchObject({ reason: "revoked" }); + + const memberProvider = yield* providerFor( + makeHost(db, { subject: SELF, organizationId: TENANT, recent: true }), + ); + const forbidden = yield* memberProvider + .revokeAllDelegatedCredentials(REQUEST, { + target: { kind: "verified-human", id: OTHER }, + disableTarget: true, + }) + .pipe(Effect.flip); + expect(forbidden).toBeInstanceOf(AccountForbidden); + }), + ), + ); + + it.effect("keeps a grant denied when upstream revocation is unavailable", () => + withDb((db) => + Effect.gen(function* () { + const issued: VerifiedDelegatedCredential[] = []; + const host = makeHost( + db, + { subject: ADMIN, organizationId: TENANT, recent: false, canFilterTargets: true }, + CREDENTIAL, + providerKeyStore({ issued, revokeBoundFails: true }), + ); + // Creation uses a fresh human in production; seed the grant through a + // sibling provider and then exercise stale-session emergency revoke. + const creator = yield* providerFor({ + ...host, + human: () => + Effect.succeed({ + subject: ADMIN, + organizationId: TENANT, + recent: true, + canFilterTargets: true, + }), + }); + const created = yield* creator.createDelegatedKey(REQUEST, { + label: "Upstream outage", + target: { kind: "verified-human", id: OTHER }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }); + const provider = yield* providerFor(host); + const failed = yield* provider + .revokeDelegatedCredential(REQUEST, created.credential.id) + .pipe(Effect.flip); + expect(failed).toBeInstanceOf(AccountError); + const service = yield* host.service(REQUEST); + const denied = yield* service.resolve(issued[0]!).pipe(Effect.flip); + expect(denied).toMatchObject({ reason: "revoked" }); + }), + ), + ); + + it.effect("propagates the host same-origin/session gate before mutation", () => + withDb((db) => + Effect.gen(function* () { + const host = makeHost(db, { subject: SELF, organizationId: TENANT, recent: true }); + const gatedHost: DelegationAccountHost = { + ...host, + human: () => + Effect.fail(new AccountForbidden({ message: "same-origin browser session required" })), + }; + const provider = yield* providerFor(gatedHost); + const denied = yield* provider + .createDelegatedKey(REQUEST, { + label: "Origin gate", + target: { kind: "self" }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }) + .pipe(Effect.flip); + expect(denied).toBeInstanceOf(AccountForbidden); + }), + ), + ); + + it.effect("compensates a provider key when core binding fails", () => + withDb((db) => + Effect.gen(function* () { + const revoked: string[] = []; + const host = makeHost( + db, + { subject: SELF, organizationId: TENANT, recent: true }, + CREDENTIAL, + providerKeyStore({ bindFails: true, revoked }), + ); + const provider = yield* providerFor(host); + const denied = yield* provider + .createDelegatedKey(REQUEST, { + label: "Compensated key", + target: { kind: "self" }, + scope: SCOPE, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }) + .pipe(Effect.flip); + expect(denied).toBeInstanceOf(AccountError); + expect(revoked).toHaveLength(1); + const service = yield* host.service(REQUEST); + expect(yield* service.list()).toHaveLength(0); + }), + ), + ); +}); diff --git a/packages/core/api/src/account/delegation-provider.ts b/packages/core/api/src/account/delegation-provider.ts new file mode 100644 index 0000000000..18bcdc123f --- /dev/null +++ b/packages/core/api/src/account/delegation-provider.ts @@ -0,0 +1,971 @@ +import { Effect, Layer, Match, Predicate, Schema } from "effect"; + +import { + DelegationProvider, + type DelegationProviderShape, + type DelegationRequestContext, +} from "./service"; +import { + AccountBadRequest, + AccountConflict, + AccountError, + AccountForbidden, + AccountUnauthorized, +} from "./api"; +import { + CredentialId, + DelegationDeniedError, + Subject, + type DelegationGrantView, + type DelegationService, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; + +/** The host supplies the live provider authority. The callbacks are invoked + * for every account request, so a Better Auth/WorkOS membership change takes + * effect without waiting for a cached registry or a process restart. */ +export interface DelegationAccountHost { + readonly service: ( + request: DelegationRequestContext, + human?: DelegationHuman, + ) => Effect.Effect; + readonly human: ( + request: DelegationRequestContext, + ) => Effect.Effect; + readonly targets: ( + request: DelegationRequestContext, + ) => Effect.Effect; + readonly target: ( + request: DelegationRequestContext, + subject: string, + ) => Effect.Effect; + /** Return human-readable, exact-snapshot choices for a verified target. + * Hosts must derive these from the same live catalog used by `scope`; IDs + * remain the only values accepted when a grant is created. */ + readonly scopeOptions?: ( + request: DelegationRequestContext, + targetSubject: string, + ) => Effect.Effect; + /** Convert the UI's toolkit ids into the exact frozen tool/connection + * snapshot stored by core. A host must use its live executor catalog here; + * accepting ids as wildcard prefixes would make later edits widen a grant. */ + readonly scope: ( + request: DelegationRequestContext, + targetSubject: string, + scope: ApiDelegationScope, + ) => Effect.Effect; + /** Disable a target's verified-human status after all of its grants have + * been revoked. Hosts use this for durable offboarding tombstones; the + * callback runs only after the grant revocation has committed, so a failure + * cannot leave active credentials behind. */ + readonly onRevokeAll?: ( + request: DelegationRequestContext, + targetSubject: string, + disableTarget: boolean, + ) => Effect.Effect; + /** Verify possession on the authenticated machine request. The callback + * must return a stable tuple and never a raw bearer value. */ + readonly proof: ( + request: DelegationRequestContext, + adapter: string, + ) => Effect.Effect; + /** Host/provider key authority for local credentials. The secret is returned + * exactly once; core persists only the verified provider tuple through its + * provider-issued binding operation. This seam is intentionally separate + * from the SDK-local fallback so a host can reuse its existing bearer-key + * verifier and compensate an unbound provider key. */ + readonly providerKeys?: DelegationAccountKeyStore; + readonly externalAdapters: readonly string[]; + readonly localKeysEnabled: boolean; + readonly issuanceEnabled: boolean; +} + +export interface DelegationProviderKey { + readonly credential: VerifiedDelegatedCredential; + readonly actorSubject: string; + readonly secret: string; +} + +export interface DelegationAccountKeyStore { + readonly issue: ( + request: DelegationRequestContext, + input: { + readonly targetSubject: string; + readonly label: string; + readonly expiresAt: string; + }, + ) => Effect.Effect; + /** Persist the SDK credential id to provider key id mapping. This is what + * lets a later rotate/revoke request address the provider key without + * exposing the full credential tuple through the HTTP projection. */ + readonly bind: ( + request: DelegationRequestContext, + input: { + readonly credentialId: string; + readonly targetSubject: string; + readonly credential: VerifiedDelegatedCredential; + }, + ) => Effect.Effect; + readonly rotate: ( + request: DelegationRequestContext, + input: { + readonly credentialId: string; + readonly targetSubject: string; + readonly label: string; + readonly expiresAt: string; + }, + ) => Effect.Effect; + /** Revoke a bound provider key by the stable SDK credential id. The host + * maintains the provider-id mapping established by `issue`/`rotate`. */ + readonly revokeBound: ( + request: DelegationRequestContext, + input: { + readonly credentialId: string; + /** Exact provider reference retained by the server-side grant view. */ + readonly credentialRef: string; + readonly targetSubject?: string; + }, + ) => Effect.Effect; + readonly revoke: ( + request: DelegationRequestContext, + input: { readonly credential: VerifiedDelegatedCredential }, + ) => Effect.Effect; +} + +export interface DelegationHuman { + readonly subject: string; + readonly organizationId: string; + readonly recent: boolean; + /** Cross-target reads are reserved for organization administrators. */ + readonly canFilterTargets?: boolean; +} + +export interface DelegationProof { + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: string; +} + +type DelegationAccountFailure = + | AccountError + | AccountUnauthorized + | AccountForbidden + | AccountBadRequest + | AccountConflict; + +type ApiDelegationScope = typeof import("./api").DelegationScope.Type; +type ApiTargetSelector = typeof import("./api").DelegationTargetSelector.Type; +type ApiCredentialSummary = typeof import("./api").DelegatedCredentialSummary.Type; +type ApiChallenge = typeof import("./api").DelegationChallengeResponse.Type; +type ApiTarget = typeof import("./api").DelegationTarget.Type; +type DelegationScopeOptions = typeof import("./api").DelegationScopeOptionsResponse.Type; +type ApiDelivery = typeof import("./api").DelegationDelivery.Type; +type ApiNotification = typeof import("./api").DelegationNotification.Type; + +type DelegationDeniedShape = { readonly reason: DelegationDeniedError["reason"] }; + +const isDelegationDenied = (error: unknown): error is DelegationDeniedShape => + Predicate.isTagged(error, "DelegationDeniedError") && + Predicate.hasProperty("reason")(error) && + typeof error.reason === "string"; + +const isAccountError = Schema.is(AccountError); +const isAccountUnauthorized = Schema.is(AccountUnauthorized); +const isAccountForbidden = Schema.is(AccountForbidden); +const isAccountBadRequest = Schema.is(AccountBadRequest); +const isAccountConflict = Schema.is(AccountConflict); + +const deniedError = (error: DelegationDeniedShape): DelegationAccountFailure => { + const message = `Delegation request denied (${error.reason}).`; + return Match.value(error.reason).pipe( + Match.when("unmapped", () => new AccountUnauthorized()), + Match.when("invalid", () => new AccountBadRequest({ message })), + Match.when("proof", () => new AccountBadRequest({ message })), + Match.when("scope", () => new AccountBadRequest({ message })), + Match.when("unauthorized", () => new AccountForbidden({ message })), + Match.when("management", () => new AccountForbidden({ message })), + Match.when("expired", () => new AccountForbidden({ message })), + Match.when("revoked", () => new AccountForbidden({ message })), + Match.exhaustive, + ); +}; + +const mapFailure = (error: unknown): DelegationAccountFailure => { + if (isDelegationDenied(error)) return deniedError(error); + if (isAccountError(error)) return error; + if (isAccountUnauthorized(error)) return error; + if (isAccountForbidden(error)) return error; + if (isAccountBadRequest(error)) return error; + if (isAccountConflict(error)) return error; + return new AccountError({ message: "Delegation request failed." }); +}; + +const run = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe(Effect.mapError(mapFailure)); + +const iso = (value: number | null): string | null => + value === null ? null : new Date(value).toISOString(); + +const maskedReference = (value: string): string => + value.length <= 8 ? "••••" : `${value.slice(0, 4)}…${value.slice(-4)}`; + +const selectorFor = (human: DelegationHuman, subject: string): ApiTargetSelector => + subject === human.subject ? { kind: "self" } : { kind: "verified-human", id: subject }; + +const actorFor = ( + subject: string, + lookup: (subject: string) => ApiTarget | null, +): typeof import("./api").DelegationActor.Type => { + const target = lookup(subject); + if (target) + return { + kind: "human", + id: target.id, + email: target.email, + name: target.name, + }; + return { kind: "machine", adapter: "delegated", reference: maskedReference(subject) }; +}; + +const referenceFor = ( + view: DelegationGrantView, +): typeof import("./api").DelegationCredentialReference.Type => + view.credentialKind === "issued-key" + ? { kind: "local-key", prefix: view.credentialRefMasked } + : { kind: "external", adapter: view.adapter, reference: view.credentialRefMasked }; + +const scopeFor = (scope: import("@executor-js/sdk").DelegationScope): ApiDelegationScope => ({ + scopes: scope.actions, + toolkitIds: scope.toolkitMembership.map((entry) => entry.toolkitId), + ...(scope.connectionAddresses === undefined + ? {} + : { connectionIds: scope.connectionAddresses.map(String) }), +}); + +const targetSelectorFor = (human: DelegationHuman, subject: string): ApiTargetSelector => + selectorFor(human, subject); + +const lifecycleFor = ( + view: DelegationGrantView, +): typeof import("@executor-js/api").DelegationLifecycle.Type => { + const revoked = view.revokedAt !== null; + return { + status: revoked ? "revoked" : view.expiresAt <= Date.now() ? "expired" : "active", + expiresAt: new Date(view.expiresAt).toISOString(), + revokedAt: iso(view.revokedAt), + revokeReason: revoked ? "revoked" : null, + }; +}; + +const deliveryUnavailable: ApiDelivery = { + status: "unavailable", + channel: null, + occurredAt: null, +}; + +const summaryFor = ( + human: DelegationHuman, + view: DelegationGrantView, + target: ApiTarget, + lookup: (subject: string) => ApiTarget | null, +): ApiCredentialSummary => ({ + id: String(view.credentialId), + grantId: String(view.grantId), + label: view.label, + credentialKind: view.credentialKind === "issued-key" ? "local-key" : "external", + reference: referenceFor(view), + target, + scope: scopeFor(view.scope), + lifecycle: lifecycleFor(view), + createdAt: new Date(view.createdAt).toISOString(), + createdBy: actorFor(String(view.createdBy), lookup), + lastUsedAt: iso(view.lastUsedAt), + supportsRotation: view.credentialKind === "issued-key", + notification: view.credentialKind === "external" ? deliveryUnavailable : null, +}); + +const notificationFor = ( + notification: import("@executor-js/sdk").DelegationNotificationRecord, + target: ApiTarget, +): ApiNotification => ({ + id: notification.id, + target, + title: "Delegated credential created", + message: `${notification.label} was created for your account by ${String(notification.createdBy)}.`, + createdAt: new Date(notification.createdAt).toISOString(), + readAt: iso(notification.readAt), +}); + +const challengeStatus = ( + challenge: import("@executor-js/sdk").DelegationChallengeView, +): ApiChallenge["status"] => + challenge.consentConsumedAt !== null + ? "consented" + : challenge.proofConsumedAt !== null + ? "proof-complete" + : challenge.expiresAt <= Date.now() + ? "expired" + : "awaiting-proof"; + +const challengeFor = ( + challenge: import("@executor-js/sdk").DelegationChallengeView, + target: ApiTargetSelector, + credential: ApiCredentialSummary | null, + nonce = "nonce" in challenge && typeof challenge.nonce === "string" ? challenge.nonce : "", +): ApiChallenge => ({ + id: challenge.challengeId, + // The nonce is returned by the core challenge creation operation. It is not + // persisted in the projection and is intentionally unavailable on a later + // GET; callers retain the create response for machine completion. + nonce, + status: challengeStatus(challenge), + expiresAt: new Date(challenge.expiresAt).toISOString(), + adapter: challenge.adapter, + target, + scope: scopeFor(challenge.scope), + label: challenge.label, + instructions: null, + provenCredential: + challenge.provenCredentialKind === null || challenge.provenCredentialRefMasked === null + ? null + : challenge.provenCredentialKind === "issued-key" + ? { kind: "local-key", prefix: challenge.provenCredentialRefMasked } + : { + kind: "external", + adapter: challenge.adapter, + reference: challenge.provenCredentialRefMasked, + }, + credential, +}); + +const sameStrings = (left: readonly unknown[], right: readonly unknown[]): boolean => + left.length === right.length && + left.every((value, index) => String(value) === String(right[index])); + +const sameScope = ( + left: import("@executor-js/sdk").DelegationScope, + right: import("@executor-js/sdk").DelegationScope, +): boolean => + sameStrings(left.actions, right.actions) && + sameStrings(left.toolAddresses, right.toolAddresses) && + (left.connectionAddresses === undefined + ? right.connectionAddresses === undefined + : right.connectionAddresses !== undefined && + sameStrings(left.connectionAddresses, right.connectionAddresses)) && + left.toolkitMembership.length === right.toolkitMembership.length && + left.toolkitMembership.every((toolkit, index) => { + const other = right.toolkitMembership[index]; + return ( + other !== undefined && + toolkit.toolkitId === other.toolkitId && + toolkit.revision === other.revision && + sameStrings(toolkit.toolAddresses, other.toolAddresses) && + sameStrings(toolkit.connectionAddresses, other.connectionAddresses) + ); + }); + +/** Build the account-plane provider used by all hosts. Every host-specific + * decision (session freshness, same-origin, membership, provider proof, and + * exact toolkit snapshot) stays in `DelegationAccountHost`; this module only + * translates the neutral HTTP contract to the carrier-neutral SDK service. */ +export const makeDelegationProvider = ( + host: DelegationAccountHost, +): Layer.Layer => + Layer.succeed(DelegationProvider, { + listDelegatedCredentials: (request, query) => + Effect.gen(function* () { + const human = yield* host.human(request); + const targetSubject = + query.targetId === undefined + ? human.subject + : yield* targetSubjectForHistorical(host, request, human, { + kind: "verified-human", + id: query.targetId, + }); + const service = yield* host.service(request, human); + const views = yield* run( + service.list({ + effectiveSubject: Subject.make(targetSubject), + includeRevoked: query.includeRevoked === "true", + }), + ); + const targets = yield* host.targets(request); + const visibleTargets = + human.canFilterTargets === true + ? targets + : targets.filter((target) => target.id === human.subject); + const bySubject = new Map(visibleTargets.map((target) => [target.id, target])); + const actorBySubject = new Map(targets.map((target) => [target.id, target])); + const credentials = views.map((view) => { + const subject = String(view.effectiveSubject); + const target = bySubject.get(subject) ?? historicalTargetFor(human, subject); + return summaryFor( + human, + view, + target, + (actorSubject) => actorBySubject.get(actorSubject) ?? null, + ); + }); + const notificationRecords = yield* run( + service.listNotifications({ reader: Subject.make(human.subject) }), + ); + const notifications = notificationRecords.map((notification) => + notificationFor( + notification, + bySubject.get(String(notification.targetSubject)) ?? + historicalTargetFor(human, String(notification.targetSubject)), + ), + ); + return { + credentials, + nextCursor: null, + capabilities: { + issuanceEnabled: host.issuanceEnabled, + localKeysEnabled: host.localKeysEnabled && host.providerKeys !== undefined, + externalAdapters: [...host.externalAdapters], + canFilterTargets: human.canFilterTargets === true, + }, + notifications, + }; + }).pipe(Effect.mapError(mapFailure)), + + listDelegationTargets: (request) => + Effect.gen(function* () { + const human = yield* host.human(request); + const targets = yield* host.targets(request); + return { + targets: + human.canFilterTargets === true + ? targets + : targets.filter((target) => target.id === human.subject), + }; + }), + + listDelegationScopeOptions: (request, query) => + Effect.gen(function* () { + const human = yield* host.human(request); + const targetSubject = + query.targetId === undefined + ? human.subject + : yield* targetSubjectForHistorical(host, request, human, { + kind: "verified-human", + id: query.targetId, + }); + if (!host.scopeOptions) + return yield* new AccountForbidden({ + message: "This host does not expose delegation scope choices.", + }); + return yield* run(host.scopeOptions(request, targetSubject)); + }), + + listDelegationNotifications: (request) => + Effect.gen(function* () { + const human = yield* host.human(request); + const service = yield* host.service(request, human); + const records = yield* run( + service.listNotifications({ reader: Subject.make(human.subject) }), + ); + const targets = yield* host.targets(request); + const bySubject = new Map(targets.map((target) => [target.id, target])); + const notifications = records.map((notification) => + notificationFor( + notification, + bySubject.get(String(notification.targetSubject)) ?? + historicalTargetFor(human, String(notification.targetSubject)), + ), + ); + return { notifications }; + }), + markDelegationNotificationRead: (request, notificationId) => + Effect.gen(function* () { + const human = yield* host.human(request); + const service = yield* host.service(request, human); + yield* run( + service.markNotificationRead({ + notificationId, + reader: Subject.make(human.subject), + }), + ); + return { success: true }; + }), + + createDelegatedKey: (request, body) => + Effect.gen(function* () { + const human = yield* host.human(request); + if (!human.recent) + return yield* new AccountForbidden({ + message: "Recent human authentication is required.", + }); + const targetSubject = yield* targetSubjectFor(host, request, human, body.target); + const coreScope = yield* host.scope(request, targetSubject, body.scope); + const service = yield* host.service(request, human); + const keyStore = host.providerKeys; + if (!keyStore) + return yield* new AccountForbidden({ + message: "This host does not provide delegated local keys.", + }); + const issued = yield* keyStore.issue(request, { + targetSubject, + label: body.label, + expiresAt: body.expiresAt, + }); + const grant = yield* run( + service.registerProviderIssuedKey({ + createdBy: Subject.make(human.subject), + effectiveSubject: Subject.make(targetSubject), + actorSubject: Subject.make(issued.actorSubject), + label: body.label, + scope: coreScope, + credential: issued.credential, + expiresAt: Date.parse(body.expiresAt), + }), + ).pipe( + Effect.catch((error: DelegationAccountFailure) => + keyStore.revoke(request, { credential: issued.credential }).pipe( + Effect.catch(() => Effect.void), + Effect.andThen(Effect.fail(error)), + ), + ), + ); + yield* keyStore + .bind(request, { + credentialId: String(grant.credentialId), + targetSubject, + credential: issued.credential, + }) + .pipe( + Effect.catch((error: DelegationAccountFailure) => + Effect.gen(function* () { + yield* run( + service.revoke({ + grantId: grant.grantId, + actorSubject: Subject.make(human.subject), + reason: "provider-binding-failed", + }), + ).pipe(Effect.ignore); + yield* keyStore + .revoke(request, { credential: issued.credential }) + .pipe(Effect.ignore); + return yield* error; + }), + ), + ); + const target = yield* host.target(request, targetSubject); + if (!target) + return yield* new AccountBadRequest({ message: "Delegation target is unavailable." }); + const view = yield* run(service.get(grant.grantId)); + if (!view) return yield* new AccountError({ message: "Delegated key was not persisted." }); + const actors = yield* host.targets(request); + const actorBySubject = new Map(actors.map((actor) => [actor.id, actor])); + return { + credential: summaryFor( + human, + view, + target, + (subject) => actorBySubject.get(subject) ?? null, + ), + value: issued.secret, + }; + }).pipe(Effect.mapError(mapFailure)), + + startDelegationChallenge: (request, body) => + Effect.gen(function* () { + const human = yield* host.human(request); + if (!human.recent) + return yield* new AccountForbidden({ + message: "Recent human authentication is required.", + }); + const targetSubject = yield* targetSubjectFor(host, request, human, body.target); + const coreScope = yield* host.scope(request, targetSubject, body.scope); + const service = yield* host.service(request, human); + const challenge = yield* run( + service.issueProofChallenge({ + adapter: body.adapter, + createdBy: Subject.make(human.subject), + effectiveSubject: Subject.make(targetSubject), + label: body.label, + scope: coreScope, + grantExpiresAt: Date.parse(body.expiresAt), + }), + ); + const target = yield* host.target(request, targetSubject); + if (!target) + return yield* new AccountBadRequest({ message: "Delegation target is unavailable." }); + const view = yield* run(service.getChallenge(challenge.challengeId)); + if (!view) + return yield* new AccountError({ message: "Delegation challenge was not persisted." }); + return challengeFor(view, targetSelectorFor(human, targetSubject), null, challenge.nonce); + }).pipe(Effect.mapError(mapFailure)), + + getDelegationChallenge: (request, challengeId) => + Effect.gen(function* () { + const human = yield* host.human(request); + const service = yield* host.service(request, human); + const challenge = yield* run(service.getChallenge(challengeId)); + if (!challenge) + return yield* new AccountBadRequest({ message: "Delegation challenge not found." }); + const target = yield* host.target(request, String(challenge.effectiveSubject)); + if (!target) + return yield* new AccountForbidden({ message: "Delegation target is unavailable." }); + return challengeFor( + challenge, + targetSelectorFor(human, String(challenge.effectiveSubject)), + null, + ); + }).pipe(Effect.mapError(mapFailure)), + + completeDelegationProof: (request, challengeId, body) => + Effect.gen(function* () { + const service = yield* host.service(request); + const challenge = yield* run(service.getChallenge(challengeId)); + if (!challenge) + return yield* new AccountBadRequest({ message: "Delegation challenge not found." }); + const proof = yield* host.proof(request, challenge.adapter); + const completion = yield* run( + service.completeProofChallenge({ + challengeId, + credential: proof.credential, + machineActor: Subject.make(proof.machineActor), + nonce: body.nonce, + }), + ); + const updated = yield* run(service.getChallenge(challengeId)); + if (!updated) + return yield* new AccountError({ message: "Delegation challenge disappeared." }); + const target = yield* host.target(request, String(completion.effectiveSubject)); + if (!target) + return yield* new AccountForbidden({ message: "Delegation target is unavailable." }); + return challengeFor( + updated, + { kind: "verified-human", id: String(completion.effectiveSubject) }, + null, + ); + }).pipe(Effect.mapError(mapFailure)), + + consentDelegationChallenge: (request, challengeId, body) => + Effect.gen(function* () { + const human = yield* host.human(request); + if (!human.recent) + return yield* new AccountForbidden({ + message: "Recent human authentication is required.", + }); + const service = yield* host.service(request, human); + const challenge = yield* run(service.getChallenge(challengeId)); + if (!challenge) + return yield* new AccountBadRequest({ message: "Delegation challenge not found." }); + const targetSubject = String(challenge.effectiveSubject); + const requested = yield* host.scope(request, targetSubject, body.scope); + if ( + !sameScope(requested, challenge.scope) || + Date.parse(body.expiresAt) !== challenge.grantExpiresAt + ) { + return yield* new AccountConflict({ + message: "Consent cannot change the challenged scope or expiry.", + }); + } + const grant = yield* run( + service.create({ + proofChallengeId: challengeId, + consentedBy: Subject.make(human.subject), + }), + ); + const view = yield* run(service.get(grant.grantId)); + const target = yield* host.target(request, targetSubject); + if (!view || !target) + return yield* new AccountError({ message: "Delegated credential was not persisted." }); + const actors = yield* host.targets(request); + const actorBySubject = new Map(actors.map((actor) => [actor.id, actor])); + const updated = yield* run(service.getChallenge(challengeId)); + if (!updated) + return yield* new AccountError({ message: "Delegation challenge disappeared." }); + return challengeFor( + updated, + targetSelectorFor(human, targetSubject), + summaryFor(human, view, target, (subject) => actorBySubject.get(subject) ?? null), + ); + }).pipe(Effect.mapError(mapFailure)), + + consentDelegatedCredential: () => + Effect.fail( + new AccountBadRequest({ message: "External credentials require a proof challenge." }), + ), + + rotateDelegatedKey: (request, credentialId) => + Effect.gen(function* () { + const human = yield* host.human(request); + if (!human.recent) + return yield* new AccountForbidden({ + message: "Recent human authentication is required.", + }); + const keyStore = host.providerKeys; + if (!keyStore) + return yield* new AccountForbidden({ + message: "This host does not provide delegated local keys.", + }); + const service = yield* host.service(request, human); + const previous = yield* run( + service.list({ credentialId: CredentialId.make(credentialId), includeRevoked: true }), + ); + const previousView = previous[0]; + if (!previousView) + return yield* new AccountBadRequest({ message: "Delegated credential not found." }); + if (previousView.credentialKind !== "issued-key") + return yield* new AccountBadRequest({ message: "Only local keys can rotate." }); + const issued = yield* keyStore.rotate(request, { + credentialId, + targetSubject: String(previousView.effectiveSubject), + label: previousView.label, + expiresAt: new Date(previousView.expiresAt).toISOString(), + }); + const grant = yield* run( + service.rotateProviderIssuedKey({ + credentialId: CredentialId.make(credentialId), + managedBy: Subject.make(human.subject), + actorSubject: Subject.make(issued.actorSubject), + credential: issued.credential, + expiresAt: previousView.expiresAt, + }), + ).pipe( + Effect.catch((error: DelegationAccountFailure) => + keyStore.revoke(request, { credential: issued.credential }).pipe( + Effect.catch(() => Effect.void), + Effect.andThen(Effect.fail(error)), + ), + ), + ); + yield* keyStore + .bind(request, { + credentialId: String(grant.credentialId), + targetSubject: String(grant.effectiveSubject), + credential: issued.credential, + }) + .pipe( + Effect.catch((error: DelegationAccountFailure) => + Effect.gen(function* () { + yield* run( + service.revoke({ + grantId: grant.grantId, + actorSubject: Subject.make(human.subject), + reason: "provider-binding-failed", + }), + ).pipe(Effect.ignore); + yield* keyStore + .revoke(request, { credential: issued.credential }) + .pipe(Effect.ignore); + return yield* error; + }), + ), + ); + const view = yield* run(service.get(grant.grantId)); + const target = yield* host.target(request, String(grant.effectiveSubject)); + if (!view || !target) + return yield* new AccountError({ message: "Rotated credential was not persisted." }); + const actors = yield* host.targets(request); + const actorBySubject = new Map(actors.map((actor) => [actor.id, actor])); + return { + credential: summaryFor( + human, + view, + target, + (subject) => actorBySubject.get(subject) ?? null, + ), + value: issued.secret, + }; + }).pipe(Effect.mapError(mapFailure)), + + revokeDelegatedCredential: (request, credentialId) => + Effect.gen(function* () { + const human = yield* host.human(request); + const service = yield* host.service(request, human); + const view = yield* run( + service.list({ credentialId: CredentialId.make(credentialId), includeRevoked: true }), + ); + const grant = view[0]; + if (!grant) + return yield* new AccountBadRequest({ message: "Delegated credential not found." }); + if (grant.credentialKind === "issued-key") { + const keyStore = host.providerKeys; + if (!keyStore) + return yield* new AccountForbidden({ + message: "This host does not provide delegated local keys.", + }); + // The local grant tombstone is authoritative. Revoke it before + // contacting the upstream provider so an outage cannot leave a + // still-usable grant behind; provider cleanup is retryable and + // idempotent after the local deny is durable. + yield* run( + service.revoke({ grantId: grant.grantId, actorSubject: Subject.make(human.subject) }), + ); + yield* keyStore.revokeBound(request, { + credentialId, + credentialRef: grant.credentialRef, + targetSubject: String(grant.effectiveSubject), + }); + return { success: true }; + } + yield* run( + service.revoke({ grantId: grant.grantId, actorSubject: Subject.make(human.subject) }), + ); + return { success: true }; + }).pipe(Effect.mapError(mapFailure)), + + revokeAllDelegatedCredentials: (request, body) => + Effect.gen(function* () { + const human = yield* host.human(request); + if (body.disableTarget === true && human.canFilterTargets !== true) + return yield* new AccountForbidden({ + message: "Only an organization administrator can block future delegations.", + }); + if (body.disableTarget === true && host.onRevokeAll === undefined) + return yield* new AccountForbidden({ + message: "This host cannot block future delegations.", + }); + const targetSubject = yield* targetSubjectForHistorical(host, request, human, body.target); + const service = yield* host.service(request, human); + const grants = yield* run(service.list({ effectiveSubject: Subject.make(targetSubject) })); + const providerGrants = grants.filter((grant) => grant.credentialKind === "issued-key"); + const keyStore = host.providerKeys; + if (providerGrants.length > 0 && !keyStore) + return yield* new AccountForbidden({ + message: "This host does not provide delegated local keys.", + }); + const revokedCount = yield* run( + service.revokeAll({ + effectiveSubject: Subject.make(targetSubject), + actorSubject: Subject.make(human.subject), + }), + ); + if (keyStore) + yield* Effect.forEach(providerGrants, (grant) => + keyStore.revokeBound(request, { + credentialId: String(grant.credentialId), + credentialRef: grant.credentialRef, + targetSubject: String(grant.effectiveSubject), + }), + ); + if (body.disableTarget === true && host.onRevokeAll !== undefined) { + yield* host.onRevokeAll(request, targetSubject, true); + } + return { revokedCount }; + }).pipe(Effect.mapError(mapFailure)), + + listDelegationAudit: (request, query) => + Effect.gen(function* () { + const human = yield* host.human(request); + const targetSubject = + query.targetId === undefined + ? human.subject + : yield* targetSubjectForHistorical(host, request, human, { + kind: "verified-human", + id: query.targetId, + }); + const service = yield* host.service(request, human); + const credentialViews = + query.credentialId === undefined + ? [] + : yield* run( + service.list({ + credentialId: CredentialId.make(query.credentialId), + includeRevoked: true, + }), + ); + const matchingCredential = + query.credentialId === undefined + ? undefined + : credentialViews.find((view) => + human.canFilterTargets === true && query.targetId === undefined + ? true + : String(view.effectiveSubject) === targetSubject, + ); + const events = + query.credentialId !== undefined && matchingCredential === undefined + ? [] + : yield* run( + matchingCredential + ? service.listAudit({ grantId: matchingCredential.grantId }) + : service.listAudit({ effectiveSubject: Subject.make(targetSubject) }), + ); + const targets = yield* host.targets(request); + const visibleTargets = + human.canFilterTargets === true + ? targets + : targets.filter((target) => target.id === human.subject); + const bySubject = new Map(visibleTargets.map((target) => [target.id, target])); + const actorBySubject = new Map(targets.map((target) => [target.id, target])); + return { + events: events.map((event) => ({ + id: event.id, + grantId: event.grantId === null ? null : String(event.grantId), + credentialId: event.credentialId === null ? null : String(event.credentialId), + actor: actorFor( + String(event.actorSubject), + (subject) => actorBySubject.get(subject) ?? null, + ), + effectiveTarget: bySubject.get(String(event.effectiveSubject)) ?? { + id: String(event.effectiveSubject), + email: "", + name: null, + isCurrentUser: false, + status: "removed", + }, + action: event.action, + outcome: event.outcome, + tool: event.tool === null ? null : String(event.tool), + requestId: event.requestId, + executionId: event.executionId, + occurredAt: new Date(event.occurredAt).toISOString(), + })), + nextCursor: null, + }; + }).pipe(Effect.mapError(mapFailure)), + } satisfies DelegationProviderShape); + +const targetSubjectFor = ( + host: DelegationAccountHost, + request: DelegationRequestContext, + human: DelegationHuman, + selector: ApiTargetSelector, +): Effect.Effect => + selector.kind === "self" + ? Effect.succeed(human.subject) + : human.canFilterTargets !== true && selector.id !== human.subject + ? Effect.fail( + new AccountForbidden({ message: "Only administrators may select another target." }), + ) + : host + .target(request, selector.id) + .pipe( + Effect.flatMap((target) => + target?.status === "verified" + ? Effect.succeed(target.id) + : Effect.fail( + new AccountForbidden({ message: "Delegation target is unavailable." }), + ), + ), + ); + +/** Resolve a target for reads and revocation. An administrator may address a + * persisted historical subject after its live directory membership has been + * removed or suspended. Issuance and expansion continue to use + * `targetSubjectFor`, which requires an active verified target. */ +const targetSubjectForHistorical = ( + _host: DelegationAccountHost, + _request: DelegationRequestContext, + human: DelegationHuman, + selector: ApiTargetSelector, +): Effect.Effect => + selector.kind === "self" + ? Effect.succeed(human.subject) + : human.canFilterTargets !== true && selector.id !== human.subject + ? Effect.fail( + new AccountForbidden({ message: "Only administrators may select another target." }), + ) + : Effect.succeed(selector.id); + +const historicalTargetFor = (human: DelegationHuman, subject: string): ApiTarget => ({ + id: subject, + email: "", + name: null, + isCurrentUser: subject === human.subject, + status: "removed", +}); diff --git a/packages/core/api/src/account/handlers.test.ts b/packages/core/api/src/account/handlers.test.ts new file mode 100644 index 0000000000..5f2d0ece2c --- /dev/null +++ b/packages/core/api/src/account/handlers.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import { AccountHttpApi, AccountUnauthorized, DelegatedCredentialsResponse } from "./api"; +import { AccountHandlers } from "./handlers"; +import { + AccountProvider, + DelegationProvider, + type AccountProviderShape, + type DelegationProviderShape, + type DelegationRequestContext, +} from "./service"; + +const deny = (..._args: ReadonlyArray) => Effect.fail(new AccountUnauthorized()); + +const accountProvider: AccountProviderShape = { + me: deny, + listApiKeys: deny, + createApiKey: deny, + revokeApiKey: deny, + listOrgApiKeys: deny, + createOrgApiKey: deny, + revokeOrgApiKey: deny, + listMembers: deny, + listRoles: deny, + inviteMember: deny, + removeMember: deny, + updateMemberRole: deny, + updateOrgName: deny, +}; + +const emptyDelegatedCredentials: typeof DelegatedCredentialsResponse.Type = { + credentials: [], + nextCursor: null, + capabilities: { + issuanceEnabled: false, + localKeysEnabled: false, + externalAdapters: [], + canFilterTargets: false, + }, + notifications: [], +}; + +describe("account request origin boundary", () => { + it("keeps the actual request origin separate from a forged browser Origin", async () => { + const seen: DelegationRequestContext[] = []; + const delegationProvider: DelegationProviderShape = { + listDelegatedCredentials: (request) => { + seen.push(request); + return Effect.succeed(emptyDelegatedCredentials); + }, + listDelegationTargets: deny, + listDelegationScopeOptions: deny, + listDelegationNotifications: deny, + markDelegationNotificationRead: deny, + createDelegatedKey: deny, + startDelegationChallenge: deny, + getDelegationChallenge: deny, + completeDelegationProof: deny, + consentDelegationChallenge: deny, + consentDelegatedCredential: deny, + rotateDelegatedKey: deny, + revokeDelegatedCredential: deny, + revokeAllDelegatedCredentials: deny, + listDelegationAudit: deny, + }; + const app = HttpApiBuilder.layer(AccountHttpApi).pipe( + Layer.provide(AccountHandlers), + HttpRouter.provideRequest( + Layer.merge( + Layer.succeed(AccountProvider)(accountProvider), + Layer.succeed(DelegationProvider)(delegationProvider), + ), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ); + const web = HttpRouter.toWebHandler(app, { disableLogger: true }); + const handlerContext = Context.make(AccountProvider, accountProvider).pipe( + Context.add(DelegationProvider, delegationProvider), + ); + const response = await web.handler( + new Request("https://actual.example/account/delegated-credentials", { + headers: { origin: "https://evil.example" }, + }), + handlerContext, + ); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + requestOrigin: "https://actual.example", + browserOrigin: "https://evil.example", + }); + + await web.handler( + new Request("https://actual.example/account/delegated-credentials"), + handlerContext, + ); + expect(seen[1]).toMatchObject({ + requestOrigin: "https://actual.example", + browserOrigin: null, + }); + await web.dispose(); + }); +}); diff --git a/packages/core/api/src/account/handlers.ts b/packages/core/api/src/account/handlers.ts index 21abf7d104..ed85763d53 100644 --- a/packages/core/api/src/account/handlers.ts +++ b/packages/core/api/src/account/handlers.ts @@ -2,8 +2,13 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; -import { AccountHttpApi } from "./api"; -import { AccountProvider, type AccountHeaders } from "./service"; +import { AccountError, AccountHttpApi } from "./api"; +import { + AccountProvider, + DelegationProvider, + type AccountHeaders, + type DelegationRequestContext, +} from "./service"; // --------------------------------------------------------------------------- // Shared, provider-neutral handlers for the Account API. They do nothing but @@ -18,6 +23,24 @@ const requestHeaders = Effect.map( (req): AccountHeaders => ({ ...req.headers }), ); +const delegationRequest = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + // The incoming Request URL is the authority the server actually received. + // Never copy the caller-controlled Origin header into this field: doing so + // would let a forged Origin equal the comparison value when a host has no + // configured public origin. The browser Origin remains separate so hosts can + // enforce same-origin CSRF checks. + const webRequest = yield* HttpServerRequest.toWeb(req).pipe( + Effect.mapError(() => new AccountError({ message: "Failed to read request origin." })), + ); + return { + headers: { ...req.headers }, + requestOrigin: new URL(webRequest.url).origin, + browserOrigin: req.headers.origin ?? null, + method: req.method, + } satisfies DelegationRequestContext; +}); + export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", (handlers) => handlers .handle("me", () => @@ -101,5 +124,116 @@ export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", ( const headers = yield* requestHeaders; return yield* (yield* AccountProvider).updateOrgName(headers, payload.name); }), + ) + .handle("listDelegatedCredentials", ({ query }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).listDelegatedCredentials(request, query); + }), + ) + .handle("listDelegationTargets", () => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).listDelegationTargets(request); + }), + ) + .handle("listDelegationScopeOptions", ({ query }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).listDelegationScopeOptions(request, query); + }), + ) + .handle("listDelegationNotifications", () => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).listDelegationNotifications(request); + }), + ) + .handle("markDelegationNotificationRead", ({ params }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).markDelegationNotificationRead( + request, + params.notificationId, + ); + }), + ) + .handle("createDelegatedKey", ({ payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).createDelegatedKey(request, payload); + }), + ) + .handle("startDelegationChallenge", ({ payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).startDelegationChallenge(request, payload); + }), + ) + .handle("getDelegationChallenge", ({ params }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).getDelegationChallenge( + request, + params.challengeId, + ); + }), + ) + .handle("completeDelegationProof", ({ params, payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).completeDelegationProof( + request, + params.challengeId, + payload, + ); + }), + ) + .handle("consentDelegationChallenge", ({ params, payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).consentDelegationChallenge( + request, + params.challengeId, + payload, + ); + }), + ) + .handle("consentDelegatedCredential", ({ params, payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).consentDelegatedCredential( + request, + params.credentialId, + payload, + ); + }), + ) + .handle("rotateDelegatedKey", ({ params }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).rotateDelegatedKey(request, params.credentialId); + }), + ) + .handle("revokeDelegatedCredential", ({ params }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).revokeDelegatedCredential( + request, + params.credentialId, + ); + }), + ) + .handle("revokeAllDelegatedCredentials", ({ payload }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).revokeAllDelegatedCredentials(request, payload); + }), + ) + .handle("listDelegationAudit", ({ query }) => + Effect.gen(function* () { + const request = yield* delegationRequest; + return yield* (yield* DelegationProvider).listDelegationAudit(request, query); + }), ), ); diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index 5b83758805..81e2377404 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -58,6 +58,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "resume", "plugins", "api-keys", + "delegated-credentials", "billing", "create-org", "setup-mcp", diff --git a/packages/core/api/src/account/service.ts b/packages/core/api/src/account/service.ts index 7978fb3ae2..e2c783767b 100644 --- a/packages/core/api/src/account/service.ts +++ b/packages/core/api/src/account/service.ts @@ -1,6 +1,8 @@ import { Context, type Effect } from "effect"; import { + type AccountBadRequest, + type AccountConflict, type AccountError, type AccountForbidden, type AccountNoOrganization, @@ -15,6 +17,24 @@ import { InviteMemberBody, SuccessResponse, UpdateOrgNameResponse, + DelegatedCredentialsResponse, + DelegatedTargetsResponse, + DelegationScopeOptionsResponse, + CreateDelegatedKeyBody, + CreatedDelegatedKeyResponse, + StartDelegationChallengeBody, + DelegationChallengeResponse, + CompleteDelegationProofBody, + ConsentDelegationBody, + DelegatedCredentialSummary, + RotateDelegatedKeyResponse, + RevokeAllDelegatedCredentialsBody, + RevokeAllDelegatedCredentialsResponse, + DelegationAuditResponse, + DelegationNotificationsResponse, + DelegatedCredentialsQuery, + DelegationAuditQuery, + DelegationScopeOptionsQuery, } from "./api"; // --------------------------------------------------------------------------- @@ -32,6 +52,16 @@ import { export type AccountHeaders = Record; +/** Request metadata needed by delegated-credential policy checks. */ +export interface DelegationRequestContext { + readonly headers: AccountHeaders; + readonly requestOrigin: string; + readonly browserOrigin: string | null; + /** HTTP verb for host CSRF policy. Origin-less browser reads are allowed; + * mutations still require an explicit same-origin Origin header. */ + readonly method?: string; +} + type Me = typeof AccountMeResponse.Type; type ApiKeys = typeof ApiKeysResponse.Type; type OrgApiKeys = typeof OrgApiKeysResponse.Type; @@ -42,6 +72,24 @@ type Invite = typeof InviteMemberResponse.Type; type InviteBody = typeof InviteMemberBody.Type; type Success = typeof SuccessResponse.Type; type OrgName = typeof UpdateOrgNameResponse.Type; +type DelegatedCredentials = typeof DelegatedCredentialsResponse.Type; +type DelegatedTargets = typeof DelegatedTargetsResponse.Type; +type DelegationScopeOptions = typeof DelegationScopeOptionsResponse.Type; +type CreateDelegatedKey = typeof CreateDelegatedKeyBody.Type; +type CreatedDelegatedKey = typeof CreatedDelegatedKeyResponse.Type; +type StartDelegationChallenge = typeof StartDelegationChallengeBody.Type; +type DelegationChallenge = typeof DelegationChallengeResponse.Type; +type CompleteDelegationProof = typeof CompleteDelegationProofBody.Type; +type ConsentDelegation = typeof ConsentDelegationBody.Type; +type DelegatedCredential = typeof DelegatedCredentialSummary.Type; +type RotateDelegatedKey = typeof RotateDelegatedKeyResponse.Type; +type RevokeAllDelegatedCredentials = typeof RevokeAllDelegatedCredentialsBody.Type; +type RevokeAllDelegatedCredentialsResult = typeof RevokeAllDelegatedCredentialsResponse.Type; +type DelegationAudit = typeof DelegationAuditResponse.Type; +type DelegationNotifications = typeof DelegationNotificationsResponse.Type; +type DelegatedCredentialsQueryInput = typeof DelegatedCredentialsQuery.Type; +type DelegationAuditQueryInput = typeof DelegationAuditQuery.Type; +type DelegationScopeOptionsQueryInput = typeof DelegationScopeOptionsQuery.Type; type Authed = Effect.Effect; type OrgScoped = Authed; @@ -82,6 +130,86 @@ export interface AccountProviderShape { ) => OrgScoped; } +type DelegationFailure = + | AccountError + | AccountUnauthorized + | AccountForbidden + | AccountNoOrganization + | AccountBadRequest + | AccountConflict; + +type DelegationAuthed = Effect.Effect; + +/** Separate provider seam for delegated credentials. */ +export interface DelegationProviderShape { + readonly listDelegatedCredentials: ( + request: DelegationRequestContext, + query: DelegatedCredentialsQueryInput, + ) => DelegationAuthed; + readonly listDelegationTargets: ( + request: DelegationRequestContext, + ) => DelegationAuthed; + readonly listDelegationScopeOptions: ( + request: DelegationRequestContext, + query: DelegationScopeOptionsQueryInput, + ) => DelegationAuthed; + readonly listDelegationNotifications: ( + request: DelegationRequestContext, + ) => DelegationAuthed; + readonly markDelegationNotificationRead: ( + request: DelegationRequestContext, + notificationId: string, + ) => DelegationAuthed; + readonly createDelegatedKey: ( + request: DelegationRequestContext, + body: CreateDelegatedKey, + ) => DelegationAuthed; + readonly startDelegationChallenge: ( + request: DelegationRequestContext, + body: StartDelegationChallenge, + ) => DelegationAuthed; + readonly getDelegationChallenge: ( + request: DelegationRequestContext, + challengeId: string, + ) => DelegationAuthed; + readonly completeDelegationProof: ( + request: DelegationRequestContext, + challengeId: string, + body: CompleteDelegationProof, + ) => DelegationAuthed; + readonly consentDelegationChallenge: ( + request: DelegationRequestContext, + challengeId: string, + body: ConsentDelegation, + ) => DelegationAuthed; + readonly consentDelegatedCredential: ( + request: DelegationRequestContext, + credentialId: string, + body: ConsentDelegation, + ) => DelegationAuthed; + readonly rotateDelegatedKey: ( + request: DelegationRequestContext, + credentialId: string, + ) => DelegationAuthed; + readonly revokeDelegatedCredential: ( + request: DelegationRequestContext, + credentialId: string, + ) => DelegationAuthed; + readonly revokeAllDelegatedCredentials: ( + request: DelegationRequestContext, + body: RevokeAllDelegatedCredentials, + ) => DelegationAuthed; + readonly listDelegationAudit: ( + request: DelegationRequestContext, + query: DelegationAuditQueryInput, + ) => DelegationAuthed; +} + export class AccountProvider extends Context.Service()( "@executor-js/api/AccountProvider", ) {} + +export class DelegationProvider extends Context.Service< + DelegationProvider, + DelegationProviderShape +>()("@executor-js/api/DelegationProvider") {} diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index a46ec456fe..f22597a89b 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -10,9 +10,42 @@ export { AccountApi, AccountHttpApi, AccountError, + AccountBadRequest, + AccountConflict, AccountForbidden, AccountNoOrganization, AccountUnauthorized, + DelegationScope, + DelegationTarget, + DelegationTargetSelector, + DelegationCredentialReference, + DelegationLifecycle, + DelegationActor, + DelegationNotification, + DelegationDelivery, + DelegatedCredentialSummary, + DelegationCapabilities, + DelegatedCredentialsResponse, + DelegatedTargetsResponse, + DelegationScopeConnectionOption, + DelegationScopeToolkitOption, + DelegationScopeOptionsResponse, + CreateDelegatedKeyBody, + CreatedDelegatedKeyResponse, + StartDelegationChallengeBody, + DelegationChallengeResponse, + CompleteDelegationProofBody, + ConsentDelegationBody, + RotateDelegatedKeyResponse, + RevokeAllDelegatedCredentialsBody, + RevokeAllDelegatedCredentialsResponse, + DelegationAuditEvent, + DelegationAuditResponse, + DelegatedCredentialsQuery, + DelegationScopeOptionsQuery, + DelegationAuditQuery, + DelegationNotificationsResponse, + DelegationNotificationParams, } from "./account/api"; export { AdminUsersApi, diff --git a/packages/core/api/src/handlers/executions.test.ts b/packages/core/api/src/handlers/executions.test.ts new file mode 100644 index 0000000000..59ce38816d --- /dev/null +++ b/packages/core/api/src/handlers/executions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import { ExecutorApi } from "../api"; +import { CoreHandlers } from "."; +import { AuthContext } from "../server/identity"; +import { ExecutionEngineService, ExecutorService } from "../services"; + +describe("execution HTTP handler authorization", () => { + it("does not let a delegated caller waive a required approval", async () => { + let receivedAutoApprove: boolean | undefined; + const engine: ExecutionEngineService["Service"] = { + execute: () => Effect.die("unused"), + executeWithPause: (_code, options) => + Effect.sync(() => { + receivedAutoApprove = options?.autoApprove; + return { + status: "completed" as const, + result: { result: { ok: true }, logs: [] }, + }; + }), + resume: () => Effect.die("unused"), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed(""), + shutdown: Effect.void, + }; + const app = HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(Layer.succeed(ExecutorService, {} as ExecutorService["Service"])), + Layer.provide(Layer.succeed(ExecutionEngineService, engine)), + Layer.provide( + Layer.succeed(AuthContext, { + kind: "delegated", + accountId: "target", + organizationId: "tenant", + email: "", + name: null, + avatarUrl: null, + roles: [], + }), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ); + const web = HttpRouter.toWebHandler(app, { disableLogger: true }); + const handlerContext = Context.make(ExecutorService, {} as ExecutorService["Service"]).pipe( + Context.add(ExecutionEngineService, engine), + ); + const response = await web.handler( + new Request("http://test.local/executions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "return 1", autoApprove: true }), + }), + handlerContext, + ); + expect(response.status).toBe(200); + expect(receivedAutoApprove).toBe(false); + await web.dispose(); + }); +}); diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index a6c3f137dc..6e2ef6c733 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -1,13 +1,22 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Schema } from "effect"; import { ExecutorApi } from "../api"; -import { formatExecuteResult, formatPausedExecution } from "@executor-js/execution"; +import { + formatExecuteResult, + formatPausedExecution, + type ExecutionResult, +} from "@executor-js/execution"; import { resolveArtifactAction } from "@executor-js/host-mcp/artifact-action"; import { TOOL_CALL_CONTRACT_MESSAGE } from "@executor-js/host-mcp/tool-call-code"; -import { PENDING_APPROVAL_TTL_MS } from "@executor-js/sdk"; -import { ExecutionEngineService, ExecutorService } from "../services"; +import { + PENDING_APPROVAL_TTL_MS, + type DelegationBinding, + type PendingApprovalRecord as PendingApproval, +} from "@executor-js/sdk"; +import { DelegatedExecutionResumer, ExecutionEngineService, ExecutorService } from "../services"; +import { AuthContext } from "../server/identity"; import { capture, captureEngineError } from "@executor-js/api"; class ExecutionNotFoundError extends Schema.TaggedErrorClass()( @@ -111,14 +120,77 @@ const recordPendingApproval = (approval: { readonly artifactId: string; readonly code: string; readonly address: string; + readonly delegationBinding?: DelegationBinding; }): Effect.Effect => Effect.gen(function* () { const executor = yield* ExecutorService; yield* executor.pendingApprovals - .put({ ...approval, expiresAt: Date.now() + PENDING_APPROVAL_TTL_MS }) + .put({ + ...approval, + expiresAt: Date.now() + PENDING_APPROVAL_TTL_MS, + ...(approval.delegationBinding === undefined + ? {} + : { delegationBinding: approval.delegationBinding }), + }) .pipe(Effect.catchCause(() => Effect.void)); }); +const sameDelegationBinding = (left: DelegationBinding, right: DelegationBinding): boolean => + String(left.tenant) === String(right.tenant) && + String(left.grantId) === String(right.grantId) && + String(left.credentialId) === String(right.credentialId) && + left.credentialRef === right.credentialRef && + String(left.actorSubject) === String(right.actorSubject) && + String(left.effectiveSubject) === String(right.effectiveSubject) && + String(left.scopeVersion) === String(right.scopeVersion); + +const bindingFromAuth = (auth: AuthContext["Service"] | null): DelegationBinding | undefined => { + if (auth === null) return undefined; + if (auth.kind !== "delegated" || auth.delegation === undefined) return undefined; + return { + tenant: auth.delegation.tenant, + grantId: auth.delegation.grantId, + credentialId: auth.delegation.credentialId, + credentialRef: auth.delegation.credentialRef, + actorSubject: auth.delegation.actorSubject, + effectiveSubject: auth.delegation.effectiveSubject, + scopeVersion: auth.delegation.scopeVersion, + }; +}; + +/** The pending record is in the effective subject's owner partition. A + * delegated machine must match every persisted grant field; a human target or + * admin may approve a grant through the host resumer, which rebuilds the + * original scoped engine. */ +const canResumePending = ( + approval: PendingApproval, + auth: AuthContext["Service"] | null, +): boolean => { + const binding = approval.delegationBinding; + if (binding === undefined) { + return auth === null || (auth.kind !== "delegated" && auth.accountId !== null); + } + if (auth === null) return false; + const current = bindingFromAuth(auth); + if (current !== undefined) return sameDelegationBinding(current, binding); + return ( + auth.kind === "member" && + auth.accountId !== null && + (auth.accountId === String(binding.effectiveSubject) || auth.roles.includes("admin")) + ); +}; + +const samePendingRecord = (left: PendingApproval, right: PendingApproval): boolean => + left.executionId === right.executionId && + left.artifactId === right.artifactId && + left.code === right.code && + left.address === right.address && + left.expiresAt === right.expiresAt && + (left.delegationBinding === undefined + ? right.delegationBinding === undefined + : right.delegationBinding !== undefined && + sameDelegationBinding(left.delegationBinding, right.delegationBinding)); + /** * Honour an approval whose paused fiber is no longer reachable. * @@ -134,9 +206,42 @@ const resumeFromPendingApproval = (executionId: string, action: "accept" | "decl Effect.gen(function* () { const executor = yield* ExecutorService; const engine = yield* ExecutionEngineService; + const auth = yield* Effect.serviceOption(AuthContext).pipe( + Effect.map((value) => Option.getOrElse(value, () => null)), + ); + const resumer = yield* DelegatedExecutionResumer; + + const pending = yield* executor.pendingApprovals + .get(executionId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!pending || !canResumePending(pending, auth)) return null; + + const binding = pending.delegationBinding; + if (binding !== undefined && auth?.kind === "member") { + if (action === "accept" && resumer === null) return null; + if (resumer !== null) { + const authorized = yield* captureEngineError( + resumer.authorize({ + binding, + executionId, + approver: { + kind: "member", + accountId: auth.accountId, + organizationId: auth.organizationId, + roles: auth.roles, + }, + }), + ); + if (!authorized) return null; + } + } else if (binding !== undefined && auth?.kind === "delegated" && auth.delegation) { + // Revalidate before consuming the one-shot record. A revoked grant must + // not lose its approval record and must never be dispatched. + yield* auth.delegation.revalidate({ operation: "approval", executionId }); + } const approval = yield* executor.pendingApprovals - .consume(executionId) + .consume(executionId, (candidate) => samePendingRecord(candidate, pending)) .pipe(Effect.catchCause(() => Effect.succeed(null))); if (!approval) return null; @@ -149,32 +254,57 @@ const resumeFromPendingApproval = (executionId: string, action: "accept" | "decl }; } - // The human approved, so run the recorded call with the gate satisfied. + if (binding !== undefined && auth?.kind === "member") { + if (resumer === null) return null; + const outcome = yield* captureEngineError( + resumer.resume({ + code: approval.code, + binding, + executionId, + approver: { + kind: "member", + accountId: auth.accountId, + organizationId: auth.organizationId, + roles: auth.roles, + }, + }), + ); + if (outcome === null) return null; + return formatResumedOutcome(outcome); + } + + // The exact delegated caller approved, so run the recorded call with the + // gate satisfied on the already grant-scoped engine. A human never reaches + // this branch: rebuilding their engine is the resumer path above. // `code` is the address the server itself resolved at pause time, so this // runs exactly the call that was described in the approval prompt. const outcome = yield* captureEngineError( engine.executeWithPause(approval.code, { autoApprove: true }), ); - if (outcome.status === "completed") { - const formatted = formatExecuteResult(outcome.result); - return { - status: "completed" as const, - text: formatted.text, - structured: formatted.structured, - isError: formatted.isError, - }; - } + return formatResumedOutcome(outcome); + }); - // `autoApprove` accepts every gate inline, so a second pause is not - // reachable here; surface it rather than silently dropping the call. - const formatted = formatPausedExecution(outcome.execution); +const formatResumedOutcome = (outcome: ExecutionResult) => { + if (outcome.status === "completed") { + const formatted = formatExecuteResult(outcome.result); return { - status: "paused" as const, + status: "completed" as const, text: formatted.text, structured: formatted.structured, + isError: formatted.isError, }; - }); + } + + // `autoApprove` accepts every gate inline, so a second pause is not + // reachable here; surface it rather than silently dropping the call. + const formatted = formatPausedExecution(outcome.execution); + return { + status: "paused" as const, + text: formatted.text, + structured: formatted.structured, + }; +}; export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => handlers @@ -196,6 +326,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" capture( Effect.gen(function* () { const engine = yield* ExecutionEngineService; + const auth = yield* Effect.serviceOption(AuthContext).pipe( + Effect.map((value) => Option.getOrElse(value, () => null)), + ); + const delegationBinding = bindingFromAuth(auth); // An artifact-originated request is not arbitrary code. It is parsed // against the shell proxy's one grammar and rewritten through the // artifact's connection bindings, exactly as `execute-action` does in @@ -206,7 +340,14 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" ? payload.code : yield* resolveArtifactCode(payload.code, payload.artifactId); const outcome = yield* captureEngineError( - engine.executeWithPause(code, { autoApprove: payload.autoApprove }), + engine.executeWithPause(code, { + // `autoApprove` means the caller is a verified human approver. + // A delegated credential is a machine actor and may never turn + // an approval policy into an unconditional execution through a + // request body flag. Durable human approval has its own + // grant-bound resumer path above. + autoApprove: auth?.kind === "delegated" ? false : payload.autoApprove, + }), ); if (outcome.status === "completed") { @@ -231,6 +372,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" artifactId: payload.artifactId, code, address: String(outcome.execution.elicitationContext.address), + ...(delegationBinding === undefined ? {} : { delegationBinding }), }); } @@ -248,11 +390,16 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" Effect.gen(function* () { const engine = yield* ExecutionEngineService; const result = yield* captureEngineError( - engine.resume(path.executionId, { - action: payload.action, - content: payload.content as Record | undefined, - ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }), - }), + engine + .resume(path.executionId, { + action: payload.action, + content: payload.content as Record | undefined, + ...(payload.persist === undefined ? {} : { meta: { persist: payload.persist } }), + }) + // A human may be answering a live delegated pause. Let the + // durable path resolve the original binding instead of turning + // the denial into an opaque engine error or running as human. + .pipe(Effect.catchTag("DelegationDeniedError", () => Effect.succeed(null))), ); // No live pause: either this resume landed on a different engine diff --git a/packages/core/api/src/handlers/tools.ts b/packages/core/api/src/handlers/tools.ts index 8e5818d7c1..496148816f 100644 --- a/packages/core/api/src/handlers/tools.ts +++ b/packages/core/api/src/handlers/tools.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { Effect } from "effect"; -import { ToolNotFoundError, type Tool } from "@executor-js/sdk"; +import { DelegationForbiddenError, ToolNotFoundError, type Tool } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; @@ -26,14 +26,24 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const tools = yield* executor.tools.list({ - integration: query.integration, - owner: query.owner, - connection: query.connection, - query: query.query, - includeAnnotations: query.includeAnnotations === "true", - includeBlocked: query.includeBlocked !== "false", - }); + const tools = yield* executor.tools + .list({ + integration: query.integration, + owner: query.owner, + connection: query.connection, + query: query.query, + includeAnnotations: query.includeAnnotations === "true", + includeBlocked: query.includeBlocked !== "false", + }) + .pipe( + Effect.catchTag("DelegationDeniedError", () => + Effect.fail( + new DelegationForbiddenError({ + message: "Delegated credential is not authorized for this operation.", + }), + ), + ), + ); return tools.map(toMetadata); }), ), @@ -42,7 +52,15 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const schema = yield* executor.tools.schema(query.address); + const schema = yield* executor.tools.schema(query.address).pipe( + Effect.catchTag("DelegationDeniedError", () => + Effect.fail( + new DelegationForbiddenError({ + message: "Delegated credential is not authorized for this operation.", + }), + ), + ), + ); if (schema === null) { return yield* new ToolNotFoundError({ address: query.address }); } diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 2b7f8ea2e1..8833f04aa1 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -45,6 +45,8 @@ export { AccountForbidden, AccountNoOrganization, AccountUnauthorized, + AccountBadRequest, + AccountConflict, AccountUser, AccountOrganization, AccountMeResponse, @@ -64,6 +66,37 @@ export { UpdateOrgNameBody, UpdateOrgNameResponse, SuccessResponse, + DelegationScope, + DelegationTarget, + DelegationTargetSelector, + DelegationCredentialReference, + DelegationLifecycle, + DelegationActor, + DelegationNotification, + DelegationDelivery, + DelegatedCredentialSummary, + DelegationCapabilities, + DelegatedCredentialsResponse, + DelegatedTargetsResponse, + DelegationScopeConnectionOption, + DelegationScopeToolkitOption, + DelegationScopeOptionsResponse, + CreateDelegatedKeyBody, + CreatedDelegatedKeyResponse, + StartDelegationChallengeBody, + DelegationChallengeResponse, + CompleteDelegationProofBody, + ConsentDelegationBody, + RotateDelegatedKeyResponse, + RevokeAllDelegatedCredentialsBody, + RevokeAllDelegatedCredentialsResponse, + DelegationAuditEvent, + DelegationAuditResponse, + DelegatedCredentialsQuery, + DelegationScopeOptionsQuery, + DelegationAuditQuery, + DelegationNotificationsResponse, + DelegationNotificationParams, } from "./account/api"; export { AdminUsersApi, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index bba228e10f..059ad3d74b 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -1,8 +1,11 @@ export { ArtifactUsageObserver, + DelegatedExecutionResumer, ExecutorService, ExecutionEngineService, type ArtifactUsageAction, + type DelegatedExecutionApprover, + type DelegatedExecutionResumer as DelegatedExecutionResumerShape, } from "./services"; export { CoreHandlers, @@ -22,7 +25,22 @@ export { providePluginExtensions, type PluginExtensionServices, } from "./plugin-routes"; -export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service"; +export { + AccountProvider, + DelegationProvider, + type AccountProviderShape, + type AccountHeaders, + type DelegationProviderShape, + type DelegationRequestContext, +} from "./account/service"; +export { + makeDelegationProvider, + type DelegationAccountHost, + type DelegationAccountKeyStore, + type DelegationProviderKey, + type DelegationHuman, + type DelegationProof, +} from "./account/delegation-provider"; export { AccountHandlers } from "./account/handlers"; export { AdminUsersProvider, @@ -100,8 +118,10 @@ export { ReadOnlyCredential, authContextFromPrincipal, authContextFromPlatform, + isDelegatedPrincipal, isPlatformPrincipal, type Principal, + type DelegatedPrincipal, type PlatformPrincipal, type ResolvedPrincipal, type IdentityProviderShape, @@ -120,6 +140,8 @@ export { export { adminUserDirectoryFromMembers } from "./admin/member-directory"; export { makeExecutionStackMiddleware, + isDelegatedManagementPath, + isDelegatedExecutionPath, textFailureStrategy, type FailureRenderingStrategy, type MakeExecutionStackMiddlewareOptions, diff --git a/packages/core/api/src/server/delegated-middleware.test.ts b/packages/core/api/src/server/delegated-middleware.test.ts new file mode 100644 index 0000000000..b04b6ca659 --- /dev/null +++ b/packages/core/api/src/server/delegated-middleware.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; + +import { + collectTables, + ConnectionAddress, + CredentialId, + DelegationDeniedError, + DelegationId, + DelegationScopeVersion, + Subject, + Tenant, + ToolAddress, + type DelegationCapability, + type DelegationContext, +} from "@executor-js/sdk"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { ExecutorApi } from "../api"; +import { CoreHandlers } from "../handlers"; +import { textFailureStrategy, makeExecutionStackMiddleware } from "./execution-stack-middleware"; +import { CodeExecutorProvider, EngineDecoratorNoop } from "./execution-stack"; +import { DbProvider } from "./executor-fuma-db"; +import { HostConfig, PluginsProvider } from "./scoped-executor"; +import type { CodeExecutor } from "./execution-stack"; +import type { DelegatedPrincipal, IdentityFailure } from "./identity"; + +const TENANT = "delegation-middleware-tenant"; +const TARGET = "verified-human-target"; +const MACHINE = "machine-actor"; +const ALLOWED_TOOL = ToolAddress.make("tools.example.user.main.allowed"); +const ALLOWED_CONNECTION = ConnectionAddress.make("tools.example.user.main"); + +const makeDelegation = ( + calls: DelegationCapability[], + actions: readonly ("discover" | "invoke")[] = ["discover", "invoke"], +): DelegationContext => ({ + kind: "delegated", + tenant: Tenant.make(TENANT), + grantId: DelegationId.make("grant_middleware"), + credentialId: CredentialId.make("credential_middleware"), + actorSubject: Subject.make(MACHINE), + effectiveSubject: Subject.make(TARGET), + scopeVersion: DelegationScopeVersion.make("scope_middleware"), + credentialRef: "machine-reference", + expiresAt: Date.now() + 60_000, + scope: { + actions, + toolkitMembership: [], + toolAddresses: [ALLOWED_TOOL], + connectionAddresses: [ALLOWED_CONNECTION], + }, + revalidate: (capability) => + Effect.gen(function* () { + calls.push(capability); + if (capability.operation === "management") { + return yield* new DelegationDeniedError({ + reason: "management", + message: "Delegated credentials cannot manage accounts.", + }); + } + if ( + (capability.operation === "catalog" || capability.operation === "schema") && + !actions.includes("discover") + ) { + return yield* new DelegationDeniedError({ + reason: "scope", + message: "Delegation action is outside the consented scope.", + }); + } + if (capability.operation === "invoke" && !actions.includes("invoke")) { + return yield* new DelegationDeniedError({ + reason: "scope", + message: "Delegation action is outside the consented scope.", + }); + } + if (capability.tool !== undefined && String(capability.tool) !== String(ALLOWED_TOOL)) { + return yield* new DelegationDeniedError({ + reason: "scope", + message: "Tool is outside the grant.", + }); + } + if ( + capability.connection !== undefined && + String(capability.connection) !== String(ALLOWED_CONNECTION) + ) { + return yield* new DelegationDeniedError({ + reason: "scope", + message: "Connection is outside the grant.", + }); + } + }), +}); + +const makePrincipal = ( + delegation: DelegationContext, + includeContext = true, +): DelegatedPrincipal => ({ + kind: "delegated", + accountId: TARGET, + organizationId: TENANT, + organizationName: "Delegation test tenant", + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: TENANT, + grantId: String(delegation.grantId), + credentialId: String(delegation.credentialId), + credentialRef: delegation.credentialRef, + actorSubject: MACHINE, + effectiveSubject: TARGET, + scopeVersion: String(delegation.scopeVersion), + ...(includeContext ? { delegation } : {}), +}); + +const codeExecutor: CodeExecutor = { + execute: (_code, invoker) => + invoker.invoke({ path: "example.user.main.allowed", args: {} }).pipe( + Effect.map((result) => ({ result, logs: [] })), + Effect.catchCause(() => Effect.succeed({ result: null, logs: [] })), + ), +}; + +const managementProbe = (hits: string[]) => + HttpRouter.addAll([ + HttpRouter.route( + "GET", + "/account/api-keys", + Effect.sync(() => { + hits.push("account"); + return HttpServerResponse.empty(); + }), + ), + HttpRouter.route( + "GET", + "/admin/users", + Effect.sync(() => { + hits.push("admin"); + return HttpServerResponse.empty(); + }), + ), + HttpRouter.route( + "*", + "/*", + Effect.sync(() => { + hits.push("fallback"); + return HttpServerResponse.empty(); + }), + ), + ]); + +const makeHandler = async ( + calls: DelegationCapability[], + hits: string[], + includeContext = true, + actions: readonly ("discover" | "invoke")[] = ["discover", "invoke"], +) => { + const db = await createSqliteTestFumaDb({ tables: collectTables() }); + const delegation = makeDelegation(calls, actions); + const ExecutionMiddleware = makeExecutionStackMiddleware< + readonly [], + IdentityFailure, + never, + never, + never, + never + >({ + plugins: [], + authenticate: () => Effect.succeed(makePrincipal(delegation, includeContext)), + strategy: textFailureStrategy, + stackLayer: Layer.mergeAll( + Layer.succeed(DbProvider)({ db: db.db, fuma: db.fuma, close: db.close }), + Layer.succeed(PluginsProvider)({ plugins: () => [] }), + Layer.succeed(HostConfig)({ allowLocalNetwork: false, oauthCallbackPath: "/oauth/callback" }), + Layer.succeed(CodeExecutorProvider)(codeExecutor), + EngineDecoratorNoop, + ), + }); + const api = HttpApiBuilder.layer(ExecutorApi).pipe(Layer.provide(CoreHandlers)); + const app = Layer.merge(api, managementProbe(hits)).pipe( + Layer.provide(ExecutionMiddleware.layer), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ); + const web = HttpRouter.toWebHandler(app, { disableLogger: true }); + return { + handler: web.handler, + dispose: async () => { + await web.dispose(); + await db.close(); + }, + }; +}; + +describe("delegated HTTP execution middleware", () => { + it("rejects management and account/admin paths before handlers or storage", async () => { + const calls: DelegationCapability[] = []; + const hits: string[] = []; + const web = await makeHandler(calls, hits); + await (async () => { + const requests: readonly [string, string, string?][] = [ + ["GET", "/account/api-keys", "account"], + ["GET", "/admin/users", "admin"], + ["POST", "/connections", "connections"], + ["GET", "/integrations", "integrations"], + ["POST", "/oauth/clients", "oauth"], + ["GET", "/providers", "providers"], + ["POST", "/policies", "policies"], + ["POST", "/artifacts", "artifacts"], + ["POST", "/api/%61dmin/users"], + ["GET", "/api%2Faccount%2Fapi-keys"], + ["GET", "/api/organizations/org_1/tools"], + ]; + for (const [method, path] of requests) { + const response = await web.handler( + new Request(`http://test.local${path}`, { + method, + headers: { "content-type": "application/json" }, + body: method === "POST" ? "{}" : undefined, + }), + Context.empty(), + ); + expect(response.status, `${method} ${path}`).toBe(403); + } + expect(hits).toEqual([]); + expect(calls).toEqual([]); + })().finally(() => web.dispose()); + }); + + it("rejects delegated identity without live context before ordinary tools", async () => { + const calls: DelegationCapability[] = []; + const hits: string[] = []; + const web = await makeHandler(calls, hits, false); + const response = await web.handler(new Request("http://test.local/tools"), Context.empty()); + expect(response.status).toBe(403); + expect(calls).toEqual([]); + expect(hits).toEqual([]); + await web.dispose(); + }); + + it("passes tool discovery, schema and execution through the scoped executor", async () => { + const calls: DelegationCapability[] = []; + const hits: string[] = []; + const web = await makeHandler(calls, hits); + await (async () => { + const list = await web.handler(new Request("http://test.local/tools"), Context.empty()); + expect(list.status).toBe(200); + + const schema = await web.handler( + new Request( + `http://test.local/tools/schema?address=${encodeURIComponent(String(ALLOWED_TOOL))}`, + ), + Context.empty(), + ); + expect(schema.status).toBe(404); + + const execution = await web.handler( + new Request("http://test.local/executions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "return await tools.example.user.main.allowed({})" }), + }), + Context.empty(), + ); + expect(execution.status).not.toBe(403); + expect(calls.map((call) => call.operation)).toEqual( + expect.arrayContaining(["catalog", "schema", "invoke"]), + ); + expect( + calls.some( + (call) => + call.operation === "schema" && String(call.connection) === String(ALLOWED_CONNECTION), + ), + ).toBe(true); + expect( + calls.some( + (call) => + call.operation === "invoke" && String(call.connection) === String(ALLOWED_CONNECTION), + ), + ).toBe(true); + + const resume = await web.handler( + new Request("http://test.local/executions/unknown/resume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "decline" }), + }), + Context.empty(), + ); + expect(resume.status).toBe(410); + })().finally(() => web.dispose()); + }); + + it("renders an invoke-only catalog denial as a safe HTTP 403", async () => { + const calls: DelegationCapability[] = []; + const hits: string[] = []; + const web = await makeHandler(calls, hits, true, ["invoke"]); + await (async () => { + const response = await web.handler(new Request("http://test.local/tools"), Context.empty()); + expect(response.status).toBe(403); + const body = await response.text(); + expect(body).not.toContain("DelegationDeniedError"); + expect(body).not.toContain("Delegation action is outside the consented scope."); + expect(calls).toHaveLength(1); + expect(calls[0]?.operation).toBe("catalog"); + expect(hits).toEqual([]); + })().finally(() => web.dispose()); + }); +}); diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index df3b0d9341..782691d5b6 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -35,7 +35,7 @@ // --------------------------------------------------------------------------- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Context, Data, Effect, Layer } from "effect"; +import { Context, Data, Effect, Layer, Option } from "effect"; import type * as Cause from "effect/Cause"; import type { AnyPlugin } from "@executor-js/sdk"; @@ -48,7 +48,12 @@ import { type HostConfig, type PluginsProvider, } from "./scoped-executor"; -import { ExecutionEngineService, ExecutorService } from "../services"; +import { + DelegatedExecutionResumer as DelegatedExecutionResumerTag, + ExecutionEngineService, + ExecutorService, +} from "../services"; +import type { DelegatedExecutionResumer } from "../services"; import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; import { authContextFromPlatform, @@ -141,6 +146,10 @@ export interface MakeExecutionStackMiddlewareOptions< never, RStack >; + /** Optional host callback used when a human approves a durable delegated + * artifact pause. It must resolve the persisted binding and rebuild the + * original grant-scoped stack before executing. */ + readonly delegatedExecutionResumer?: DelegatedExecutionResumer; } /** @@ -208,6 +217,28 @@ export const makeExecutionStackMiddleware = < message: "Organization API keys are read-only", }), ), + Effect.filterOrFail( + (principal) => principal.kind !== "delegated" || principal.delegation !== undefined, + () => + // A serialized MCP binding is identity metadata only. The + // live grant context is required before any API executor is + // built; otherwise a delegated principal could fall through + // to an unrestricted member executor. + new ReadOnlyCredential({ + code: "delegated_context_missing", + message: "Delegated credential context is unavailable", + }), + ), + Effect.filterOrFail( + (principal) => + principal.kind !== "delegated" || + isDelegatedExecutionPath(webRequest.method, new URL(webRequest.url).pathname), + () => + new ReadOnlyCredential({ + code: "delegated_route_denied", + message: "Delegated credentials cannot access this route", + }), + ), ), ); // The strategy recovered the failure into a Response — return it. @@ -251,6 +282,9 @@ export const makeExecutionStackMiddleware = < resolved.orgRoleModel === "none" || resolved.orgRole === "admin" ? "allowed" : "denied", + ...(resolved.kind === "delegated" && resolved.delegation + ? { delegation: resolved.delegation } + : {}), }, ).pipe( Effect.provide(options.stackLayer, { local: true }), @@ -267,7 +301,16 @@ export const makeExecutionStackMiddleware = < Effect.withSpan("executor.stack.http.resolve"), ) : stack.pipe(Effect.withSpan("executor.stack.http.resolve")); - return yield* httpEffect.pipe( + const withDelegatedResumer = + options.delegatedExecutionResumer === undefined + ? httpEffect + : httpEffect.pipe( + Effect.provideService( + DelegatedExecutionResumerTag, + options.delegatedExecutionResumer, + ), + ); + return yield* withDelegatedResumer.pipe( Effect.provideService(AuthContext, auth), Effect.provideService(ExecutorService, executor), Effect.provideService(ExecutionEngineService, engine), @@ -325,6 +368,60 @@ const isPrincipal = ( const isPlatformSafeRequest = (method: string, pathname: string): boolean => (method === "GET" || method === "HEAD") && !pathname.endsWith("/oauth/callback"); +/** Management and account routes always require a human session. Executor + * catalog/invocation routes remain governed by the delegated scope snapshot. */ +const DELEGATED_MANAGEMENT_ROOTS = new Set([ + "account", + "admin", + "api-keys", + "connections", + "integrations", + "oauth", + "policies", + "providers", + "org", + "org-api-keys", + "secrets", + "toolkits", + "users", + "artifacts", +]); + +const decodedPathSegments = (pathname: string): readonly string[] | undefined => { + const decoded = Option.getOrElse(Option.liftThrowable(decodeURIComponent)(pathname), () => null); + return decoded === null ? undefined : decoded.split("/").filter(Boolean); +}; + +export const isDelegatedManagementPath = (pathname: string): boolean => { + const segments = decodedPathSegments(pathname); + // A malformed escape cannot be classified safely. Treat it as a management + // path so a delegated credential never gets a parser-specific route through + // a host or router that normalizes it differently. + if (segments === undefined) return true; + const first = segments[0]?.toLowerCase() === "api" ? segments[1] : segments[0]; + return DELEGATED_MANAGEMENT_ROOTS.has(first?.toLowerCase() ?? ""); +}; + +/** The only product-plane routes a delegated credential may reach. New API + * routes stay denied by default until they are deliberately added here. */ +export const isDelegatedExecutionPath = (method: string, pathname: string): boolean => { + const segments = decodedPathSegments(pathname); + if (segments === undefined) return false; + const offset = segments[0]?.toLowerCase() === "api" ? 1 : 0; + const route = segments.slice(offset).map((segment) => segment.toLowerCase()); + if (route[0] === "tools") { + return ( + method === "GET" && (route.length === 1 || (route.length === 2 && route[1] === "schema")) + ); + } + if (route[0] !== "executions") return false; + return ( + (method === "POST" && route.length === 1) || + (method === "GET" && route.length === 2) || + (method === "POST" && route.length === 3 && route[2] === "resume") + ); +}; + /** Reaching an engine member the platform branch cannot honestly serve is a * wiring bug (the safe-request gate keeps those paths unreachable), so it * dies as a tagged defect rather than failing with a typed error a client diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index 3aa0dd6b8d..9c1c55b675 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -27,7 +27,13 @@ import { Context, Effect, Layer } from "effect"; import type * as Cause from "effect/Cause"; import type { McpResource } from "@executor-js/host-mcp"; -import type { AnyPlugin, Executor, ExecutorConfig, StorageFailure } from "@executor-js/sdk"; +import type { + AnyPlugin, + DelegationContext, + Executor, + ExecutorConfig, + StorageFailure, +} from "@executor-js/sdk"; import { createExecutionEngine, type ExecutionEngine, @@ -117,6 +123,8 @@ export const makeExecutionStack = < /** Workspace-settings permission for this binding (see * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** Host-verified delegated grant, retained through executor and engine. */ + readonly delegation?: DelegationContext; }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, @@ -131,6 +139,7 @@ export const makeExecutionStack = < { plugins: { mcpResource: options?.mcpResource }, ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + ...(options?.delegation === undefined ? {} : { delegation: options.delegation }), }, ).pipe(Effect.withSpan("executor.stack.scoped_executor")); const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 382e72efe1..8de21c578f 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -82,6 +82,7 @@ import { type ApiHandler, } from "./host-foundation"; import { makeOAuthClientIdMetadataRoute } from "./oauth-client-metadata"; +import type { DelegatedExecutionResumer } from "../services"; // A fully-resolved route/app Layer with its channels erased. Used at the // assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's @@ -174,6 +175,10 @@ export interface CommonProviders { * contravariant, so `never` accepts any middleware-marker layer). */ readonly account?: Layer.Layer; + /** Optional provider-neutral delegated-credential account middleware. The + * layer may carry HttpRouter request markers in addition to the service tag + * because hosts construct it with requestScopedMiddleware. */ + readonly delegation?: Layer.Layer; /** The MCP serving seams; omit to serve no `/mcp` envelope. */ readonly mcp?: McpProviders; /** The `ErrorCapture` seam (console vs Sentry) — the core resolves it. */ @@ -324,6 +329,10 @@ export interface ExecutorAppOptions< readonly boot: Layer.Layer; /** Optional per-request Layer (cloud's request-scoped postgres socket). */ readonly requestScoped?: Layer.Layer; + /** Optional durable delegated approval fallback. Scoped hosts provide a + * fresh grant-bound engine when a paused HTTP execution outlives its first + * request. */ + readonly delegatedExecutionResumer?: DelegatedExecutionResumer; } export interface ExecutorApp { @@ -503,6 +512,7 @@ export const make = < never, RDb >, + delegatedExecutionResumer: options.delegatedExecutionResumer, }); // ---- (3) the protected (plugin) API, wrapped by the middleware --------- @@ -538,10 +548,10 @@ export const make = < const apiLive: AppRouteLayer = providers.account ? Layer.merge( pluginApiLive, - makeAccountApiLayer( - providers.account as Layer.Layer, - prefixedRouter ? { router: prefixedRouter } : {}, - ) as AppRouteLayer, + makeAccountApiLayer(providers.account as Layer.Layer, { + ...(prefixedRouter ? { router: prefixedRouter } : {}), + ...(providers.delegation ? { delegationProviderMiddleware: providers.delegation } : {}), + }) as AppRouteLayer, ) : pluginApiLive; diff --git a/packages/core/api/src/server/fixed-execution-middleware.ts b/packages/core/api/src/server/fixed-execution-middleware.ts index 5fad065cfd..144a1c8e99 100644 --- a/packages/core/api/src/server/fixed-execution-middleware.ts +++ b/packages/core/api/src/server/fixed-execution-middleware.ts @@ -123,7 +123,8 @@ export const makeFixedExecutionMiddleware = < // platform view to serve an org credential, so it is refused // rather than silently acting as the boot subject. Effect.filterOrFail( - (principal): principal is Principal => !isPlatformPrincipal(principal), + (principal): principal is Principal => + !isPlatformPrincipal(principal) && principal.kind !== "delegated", () => new ReadOnlyCredential({ code: "read_only_credential", diff --git a/packages/core/api/src/server/host-foundation.ts b/packages/core/api/src/server/host-foundation.ts index 580402bba7..397e4b306d 100644 --- a/packages/core/api/src/server/host-foundation.ts +++ b/packages/core/api/src/server/host-foundation.ts @@ -33,13 +33,18 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import type { AnyPlugin } from "@executor-js/sdk"; import { observabilityMiddleware, type ErrorCapture } from "../observability"; import { AccountHttpApi } from "../account/api"; import { AccountHandlers } from "../account/handlers"; -import type { AccountProvider } from "../account/service"; +import { + DelegationProvider, + type AccountProvider, + type DelegationProviderShape, +} from "../account/service"; +import { AccountForbidden } from "../account/api"; import { AdminUsersHttpApi } from "../admin/api"; import { AdminUsersHandlers } from "../admin/handlers"; import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes"; @@ -130,8 +135,60 @@ export interface MakeAccountApiLayerOptions { * the account routes register on the same `/api`-prefixed router. */ readonly router?: RouterLayer; + /** Optional per-request delegated-credential provider. Hosts without a + * delegation adapter receive the disabled, fail-closed provider below. */ + /** Hosts may supply a request middleware layer; its output includes the + * router's request markers alongside DelegationProvider. */ + readonly delegationProviderMiddleware?: Layer.Layer; } +const delegationDisabled = (): Effect.Effect => + Effect.fail( + new AccountForbidden({ + message: "Delegated credential issuance is disabled on this host.", + }), + ); + +const disabledDelegationProvider: DelegationProviderShape = { + listDelegatedCredentials: () => + Effect.succeed({ + credentials: [], + nextCursor: null, + notifications: [], + capabilities: { + issuanceEnabled: false, + localKeysEnabled: false, + externalAdapters: [], + canFilterTargets: false, + }, + }), + listDelegationTargets: () => Effect.succeed({ targets: [] }), + listDelegationScopeOptions: () => + Effect.succeed({ + actions: ["discover", "invoke"] as const, + toolkits: [], + connections: [], + maxExpiresAt: new Date().toISOString(), + }), + listDelegationNotifications: () => Effect.succeed({ notifications: [] }), + markDelegationNotificationRead: () => Effect.succeed({ success: true }), + createDelegatedKey: () => delegationDisabled(), + startDelegationChallenge: () => delegationDisabled(), + getDelegationChallenge: () => delegationDisabled(), + completeDelegationProof: () => delegationDisabled(), + consentDelegationChallenge: () => delegationDisabled(), + consentDelegatedCredential: () => delegationDisabled(), + rotateDelegatedKey: () => delegationDisabled(), + revokeDelegatedCredential: () => delegationDisabled(), + revokeAllDelegatedCredentials: () => delegationDisabled(), + listDelegationAudit: () => Effect.succeed({ events: [], nextCursor: null }), +}; + +const disabledDelegationProviderLayer = Layer.succeed( + DelegationProvider, + disabledDelegationProvider, +); + /** * Mount the shared, provider-neutral `AccountHandlers` (me / API keys / org) * behind a per-request `AccountProvider`: @@ -166,7 +223,12 @@ export const makeAccountApiLayer = ( ) => { const base = HttpApiBuilder.layer(AccountHttpApi).pipe( Layer.provide(AccountHandlers), - Layer.provide(accountProviderMiddleware), + Layer.provide( + Layer.merge( + accountProviderMiddleware, + options.delegationProviderMiddleware ?? disabledDelegationProviderLayer, + ), + ), ); return options.router ? base.pipe(Layer.provide(options.router)) : base; }; diff --git a/packages/core/api/src/server/identity.test.ts b/packages/core/api/src/server/identity.test.ts new file mode 100644 index 0000000000..806f4ecc4b --- /dev/null +++ b/packages/core/api/src/server/identity.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + authContextFromPrincipal, + isDelegatedPrincipal, + type DelegatedPrincipal, +} from "./identity"; +import { isDelegatedExecutionPath, isDelegatedManagementPath } from "./execution-stack-middleware"; + +const delegated: DelegatedPrincipal = { + kind: "delegated", + accountId: "human_1", + organizationId: "org_1", + organizationName: "Example", + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: "org_1", + grantId: "grant_1", + credentialId: "credential_1", + credentialRef: "ref_1", + actorSubject: "machine_1", + effectiveSubject: "human_1", + scopeVersion: "scope_1", +}; + +describe("delegated API identity", () => { + it("keeps grant and actor binding in AuthContext without inheriting target roles", () => { + expect(isDelegatedPrincipal(delegated)).toBe(true); + expect(authContextFromPrincipal(delegated)).toMatchObject({ + kind: "delegated", + accountId: "human_1", + grantId: "grant_1", + credentialId: "credential_1", + credentialRef: "ref_1", + actorSubject: "machine_1", + effectiveSubject: "human_1", + scopeVersion: "scope_1", + roles: [], + }); + }); +}); + +describe("delegated HTTP route gate", () => { + it("classifies account and admin surfaces as management routes", () => { + expect(isDelegatedManagementPath("/api/account/delegated-credentials")).toBe(true); + expect(isDelegatedManagementPath("/admin/users")).toBe(true); + expect(isDelegatedManagementPath("/api/%61dmin/users")).toBe(true); + expect(isDelegatedManagementPath("/api%2Faccount%2Fapi-keys")).toBe(true); + expect(isDelegatedManagementPath("/api/%")).toBe(true); + expect(isDelegatedManagementPath("/apiary/admin/users")).toBe(false); + expect(isDelegatedManagementPath("/api/tools")).toBe(false); + expect(isDelegatedManagementPath("/api/executions")).toBe(false); + expect(isDelegatedManagementPath("/api/artifacts")).toBe(true); + expect(isDelegatedExecutionPath("GET", "/api/tools")).toBe(true); + expect(isDelegatedExecutionPath("GET", "/api/tools/schema")).toBe(true); + expect(isDelegatedExecutionPath("POST", "/api/executions")).toBe(true); + expect(isDelegatedExecutionPath("POST", "/api/executions/id/resume")).toBe(true); + expect(isDelegatedExecutionPath("GET", "/api/executions/id")).toBe(true); + expect(isDelegatedExecutionPath("GET", "/api/organizations/org/tools")).toBe(false); + expect(isDelegatedExecutionPath("GET", "/api/%")).toBe(false); + }); +}); diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 4c88040a96..f97d68cdd5 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -21,6 +21,7 @@ // --------------------------------------------------------------------------- import { Context, Effect, Schema } from "effect"; +import type { DelegationContext } from "@executor-js/sdk"; /** * The provider-neutral resolved identity. Both self-host's AuthProvider impls @@ -50,30 +51,58 @@ interface PrincipalBase { readonly roles: readonly string[]; } +/** A host-verified delegated caller. Delegated requests retain the actor and + * grant binding while deliberately carrying no target email, roles, or org + * write authority. The live SDK context is request-scoped and is optional at + * this identity boundary; MCP/session adapters rebuild it authoritatively. */ +export interface DelegatedPrincipal { + readonly kind: "delegated"; + readonly accountId: string; + readonly organizationId: string; + readonly organizationName: string; + readonly organizationSlug?: string; + readonly email: ""; + readonly name: null; + readonly avatarUrl: null; + readonly roles: readonly []; + readonly orgRoleModel: "organization"; + readonly orgRole: "member"; + readonly tenant: string; + readonly grantId: string; + readonly credentialId: string; + readonly credentialRef: string; + readonly actorSubject: string; + readonly effectiveSubject: string; + readonly scopeVersion: string; + readonly delegation?: DelegationContext; +} + /** * The provider-neutral resolved member identity. The role-model discriminant * makes it impossible for a role-less host to carry an authorizing org role. */ -export type Principal = PrincipalBase & - ( - | { - readonly orgRoleModel: "organization"; - /** - * The member's NORMALIZED workspace role for an `"organization"` model: - * `"admin"` may configure workspace-level state (org-owned rows, the - * integration catalog), `"member"` may only use it. Cloud maps its WorkOS - * membership role (`admin` / `member`); self-host maps Better Auth's org - * membership role (`owner` and `admin` → `"admin"`). It may be absent only - * at a legacy serialized boundary; an organization role model then denies - * workspace writes. A host without roles declares `orgRoleModel: "none"`. - */ - readonly orgRole?: "admin" | "member"; - } - | { - readonly orgRoleModel: "none"; - readonly orgRole?: never; - } - ); +export type Principal = + | (PrincipalBase & + ( + | { + readonly orgRoleModel: "organization"; + /** + * The member's NORMALIZED workspace role for an `"organization"` model: + * `"admin"` may configure workspace-level state (org-owned rows, the + * integration catalog), `"member"` may only use it. Cloud maps its WorkOS + * membership role (`admin` / `member`); self-host maps Better Auth's org + * membership role (`owner` and `admin` → `"admin"`). It may be absent only + * at a legacy serialized boundary; an organization role model then denies + * workspace writes. A host without roles declares `orgRoleModel: "none"`. + */ + readonly orgRole?: "admin" | "member"; + } + | { + readonly orgRoleModel: "none"; + readonly orgRole?: never; + } + )) + | DelegatedPrincipal; /** * An ORG-level credential (cloud's org-scoped API key), resolved. Deliberately @@ -102,6 +131,9 @@ export interface PlatformPrincipal { * credential. */ export type ResolvedPrincipal = Principal | PlatformPrincipal; +export const isDelegatedPrincipal = (value: ResolvedPrincipal): value is DelegatedPrincipal => + value.kind === "delegated"; + export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal => value.kind === "platform"; @@ -120,27 +152,50 @@ export const isPlatformPrincipal = (value: ResolvedPrincipal): value is Platform export class AuthContext extends Context.Service< AuthContext, { + readonly kind?: "member" | "delegated" | "platform"; readonly accountId: string | null; readonly organizationId: string; readonly email: string | null; readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + readonly tenant?: string; + readonly grantId?: string; + readonly credentialId?: string; + readonly credentialRef?: string; + readonly actorSubject?: string; + readonly effectiveSubject?: string; + readonly scopeVersion?: string; + readonly delegation?: DelegationContext; } >()("@executor-js/api/AuthContext") {} /** Build the shared `AuthContext` value from a resolved `Principal`. */ export const authContextFromPrincipal = (principal: Principal): AuthContext["Service"] => ({ + kind: principal.kind, accountId: principal.accountId, organizationId: principal.organizationId, email: principal.email, name: principal.name, avatarUrl: principal.avatarUrl, roles: principal.roles, + ...(principal.kind === "delegated" + ? { + tenant: principal.tenant, + grantId: principal.grantId, + credentialId: principal.credentialId, + credentialRef: principal.credentialRef, + actorSubject: principal.actorSubject, + effectiveSubject: principal.effectiveSubject, + scopeVersion: principal.scopeVersion, + ...(principal.delegation ? { delegation: principal.delegation } : {}), + } + : {}), }); /** The platform credential's `AuthContext`: org identity, no member fields. */ export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({ + kind: "platform", accountId: null, organizationId: principal.organizationId, email: null, diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3e..dd3e668f9e 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -42,6 +42,7 @@ import { type Executor, type ExecutorConfig, type FirstPartyOAuthClientConfig, + type DelegationContext, type StorageFailure, } from "@executor-js/sdk"; import { @@ -264,6 +265,8 @@ export const makeScopedExecutor = < * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's * role; omitted -> allowed (hosts with no role model). */ readonly orgWrites?: ExecutorConfig["orgWrites"]; + /** Host-verified delegated grant bound to this effective subject. */ + readonly delegation?: DelegationContext; }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { @@ -337,6 +340,7 @@ export const makeScopedExecutor = < ...(waitUntil !== undefined ? { waitUntil } : {}), onElicitation: "accept-all", ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + ...(options?.delegation === undefined ? {} : { delegation: options.delegation }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, diff --git a/packages/core/api/src/services.ts b/packages/core/api/src/services.ts index 248b185237..1a3a1a3494 100644 --- a/packages/core/api/src/services.ts +++ b/packages/core/api/src/services.ts @@ -1,7 +1,7 @@ import { Context, type Effect } from "effect"; import type * as Cause from "effect/Cause"; -import type { Executor } from "@executor-js/sdk"; -import type { ExecutionEngine } from "@executor-js/execution"; +import type { DelegationBinding, Executor } from "@executor-js/sdk"; +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; export class ExecutorService extends Context.Service()( "ExecutorService", @@ -33,3 +33,48 @@ export class ExecutionEngineService extends Context.Service< ExecutionEngineService, ExecutionEngine >()("ExecutionEngineService") {} + +/** + * Host seam for the durable approval fallback. A request that contains the + * human approver's session has an ordinary execution engine, so it cannot + * safely re-run a recorded delegated call itself. Hosts that support durable + * delegated approvals provide this optional callback; it resolves the exact + * binding against durable state, rebuilds the original scoped executor and + * engine, and records the approver independently from the machine actor. + * + * The reference is optional to preserve existing hosts. If it is absent, a + * delegated pending approval fails closed instead of falling back to the + * approver's unrestricted engine. + */ +export interface DelegatedExecutionApprover { + readonly kind: "member" | "delegated"; + readonly accountId: string | null; + readonly organizationId: string; + /** The live role set from the authenticated request. Hosts must re-check + * membership; this is an input to the host resumer, never grant evidence. */ + readonly roles?: readonly string[]; + /** Human account id is the approver identity; machine actor is retained on + * the binding and must never be replaced by this field. */ + readonly actorSubject?: string; +} + +export interface DelegatedExecutionResumer { + /** Check the target/admin human and resolve the exact binding before the + * one-shot pending record is consumed. */ + readonly authorize: (input: { + readonly binding: DelegationBinding; + readonly executionId: string; + readonly approver: DelegatedExecutionApprover; + }) => Effect.Effect; + readonly resume: (input: { + readonly code: string; + readonly binding: DelegationBinding; + readonly executionId: string; + readonly approver: DelegatedExecutionApprover; + }) => Effect.Effect; +} + +export const DelegatedExecutionResumer = Context.Reference( + "@executor-js/api/DelegatedExecutionResumer", + { defaultValue: () => null }, +); diff --git a/packages/core/api/src/tools/api.ts b/packages/core/api/src/tools/api.ts index 767c6609d0..7678f5d59f 100644 --- a/packages/core/api/src/tools/api.ts +++ b/packages/core/api/src/tools/api.ts @@ -12,6 +12,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { ConnectionName, + DelegationForbiddenError, IntegrationSlug, InternalError, Owner, @@ -73,13 +74,13 @@ export const ToolsApi = HttpApiGroup.make("tools") HttpApiEndpoint.get("list", "/tools", { query: ListToolsQuery, success: Schema.Array(ToolMetadataResponse), - error: InternalError, + error: [InternalError, DelegationForbiddenError], }), ) .add( HttpApiEndpoint.get("schema", "/tools/schema", { query: SchemaQuery, success: ToolSchemaView, - error: [InternalError, ToolNotFound], + error: [InternalError, DelegationForbiddenError, ToolNotFound], }), ); diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index 365f115585..22d642014d 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -4,16 +4,25 @@ import { Effect } from "effect"; import { AuthTemplateSlug, ConnectionName, + CredentialId, + DelegationId, + DelegationScopeVersion, IntegrationSlug, ProviderItemId, ProviderKey, + Subject, + Tenant, + ToolAddress, createExecutor, definePlugin, + tool, type CredentialProvider, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { buildExecuteDescription, parseIntegrationInventory } from "./description"; +import { createExecutionEngine } from "./engine"; const memoryProvider = (): CredentialProvider => { const store = new Map(); @@ -36,6 +45,9 @@ const memoryProvider = (): CredentialProvider => { const GITHUB = IntegrationSlug.make("github"); const SLACK = IntegrationSlug.make("slack"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const DELEGATED_TENANT = Tenant.make("delegated-description-tenant"); +const DELEGATED_SUBJECT = Subject.make("delegated-description-subject"); +const DELEGATED_TOOL = ToolAddress.make("github.allowed"); // The execute description lists the top-level integrations the user has // connected: one bare line per integration slug, deduped across connections, @@ -68,6 +80,37 @@ const slackPlugin = definePlugin(() => ({ }), }))(); +const delegatedCatalogPlugin = definePlugin(() => ({ + id: "delegated-catalog-plugin" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "github", + kind: "test", + name: "GitHub", + tools: [ + tool({ + name: "allowed", + description: "Allowed discovery tool", + execute: () => Effect.void, + }), + ], + }, + { + id: "slack", + kind: "test", + name: "Slack", + tools: [ + tool({ + name: "hidden", + description: "Hidden discovery tool", + execute: () => Effect.void, + }), + ], + }, + ], +}))(); + const occurrences = (haystack: string, needle: string): number => haystack.split(needle).length - 1; describe("buildExecuteDescription", () => { @@ -189,6 +232,46 @@ describe("buildExecuteDescription", () => { expect(description).not.toContain("## Available integrations"); }), ); + + it.effect("uses only the delegated discovery catalog", () => + Effect.gen(function* () { + const executor = yield* createExecutor({ + ...makeTestConfig({ + tenant: String(DELEGATED_TENANT), + subject: String(DELEGATED_SUBJECT), + plugins: [delegatedCatalogPlugin] as const, + }), + delegation: { + kind: "delegated" as const, + tenant: DELEGATED_TENANT, + grantId: DelegationId.make("delegated-description-grant"), + credentialId: CredentialId.make("delegated-description-credential"), + actorSubject: Subject.make("delegated-description-machine"), + effectiveSubject: DELEGATED_SUBJECT, + scopeVersion: DelegationScopeVersion.make("delegated-description-scope"), + credentialRef: "delegated-description-ref", + expiresAt: Date.now() + 60_000, + scope: { + actions: ["discover"] as const, + toolkitMembership: [], + toolAddresses: [DELEGATED_TOOL], + connectionAddresses: [], + }, + revalidate: () => Effect.void, + }, + }); + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); + + const description = yield* engine.getDescription; + + expect(description).toContain("- `github`"); + expect(description).not.toContain("- `slack`"); + expect(description).not.toContain("Hidden discovery tool"); + }), + ); }); describe("parseIntegrationInventory", () => { diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index dec2876fd1..e6e84cbe43 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -19,11 +19,27 @@ export const INTEGRATION_INVENTORY_HEADER = "## Available integrations"; export const buildExecuteDescription = (executor: Executor): Effect.Effect => Effect.gen(function* () { - const connections: readonly Connection[] = yield* executor.connections.list().pipe( - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ExecutionEngine.getDescription currently exposes no error channel; engine typed-error widening is covered separately - Effect.orDie, - Effect.withSpan("executor.connections.list"), - ); + // Delegated executors cannot enumerate connections: that is a management + // surface, and the grant's live scope intentionally denies it. Discovery + // still has a narrow catalog projection, so derive the inventory from the + // already grant-filtered tools list. A grant without discovery receives the + // generic description and never probes either catalog surface. + const delegatedDiscovery = + executor.delegation !== undefined && executor.delegation.scope.actions.includes("discover"); + const connections: readonly Connection[] = executor.delegation + ? [] + : yield* executor.connections.list().pipe( + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ExecutionEngine.getDescription currently exposes no error channel; engine typed-error widening is covered separately + Effect.orDie, + Effect.withSpan("executor.connections.list"), + ); + const delegatedTools = delegatedDiscovery + ? yield* executor.tools.list().pipe( + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ExecutionEngine.getDescription currently exposes no error channel; engine typed-error widening is covered separately + Effect.orDie, + Effect.withSpan("executor.tools.list"), + ) + : []; const description = yield* Effect.sync(() => { const lines = [ @@ -31,7 +47,9 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect 0) { lines.push(""); lines.push(inventory); @@ -96,9 +114,11 @@ export const parseIntegrationInventory = (description: string): readonly string[ return slugs; }; -const formatIntegrationInventory = (connections: readonly Connection[]): string => { - const slugs = [...new Set(connections.map((connection) => String(connection.integration)))].sort( - (a, b) => a.localeCompare(b), +const formatIntegrationInventory = ( + entries: readonly { readonly integration: unknown }[], +): string => { + const slugs = [...new Set(entries.map((entry) => String(entry.integration)))].sort((a, b) => + a.localeCompare(b), ); if (slugs.length === 0) return ""; const shown = slugs.slice(0, INVENTORY_LIMIT); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index dad73e2025..dca9f7a253 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -11,8 +11,13 @@ import type { ElicitationContext, } from "@executor-js/sdk/core"; import { + assertDelegationCapability, CurrentOrgWriteAccess, + DelegationDeniedError, offeredPersistence, + type DelegatedPrincipal, + type DelegationContext, + type StorageFailure, type OrgWriteAccessState, } from "@executor-js/sdk/core"; import { CodeExecutionError } from "@executor-js/codemode-core"; @@ -36,6 +41,7 @@ export type ExecutionEngineConfig; readonly toolDiscoveryProvider?: ToolDiscoveryProvider; + readonly delegation?: DelegationContext; }; export type ExecutionResult = @@ -45,6 +51,7 @@ export type ExecutionResult = export type PausedExecution = { readonly id: string; readonly elicitationContext: ElicitationContext; + readonly delegation?: DelegatedPrincipal; }; export type PausedExecutionDeadline = { @@ -58,6 +65,7 @@ type InternalPausedExecution = PausedExecution & { readonly orgWriteAccess: OrgWriteAccessState; readonly fiber: Fiber.Fiber; readonly pauseQueue: Queue.Queue>; + readonly delegationContext?: DelegationContext; }; export type ResumeResponse = { @@ -68,6 +76,11 @@ export type ResumeResponse = { readonly meta?: ElicitationResponseMeta; }; +type SettledOutcome = { + readonly outcome: Exit.Exit; + readonly delegation?: DelegationContext; +}; + // Auto-accept every elicitation. Used by the `autoApprove` path where the // caller is itself the human approver (the operator-facing Run/Test panel). const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" }); @@ -529,7 +542,7 @@ export type ExecutionEngine readonly resume: ( executionId: string, response: ResumeResponse, - ) => Effect.Effect; + ) => Effect.Effect; /** * True when the engine remembers that an executionId has already settled, even @@ -574,6 +587,7 @@ export const createExecutionEngine = , ): ExecutionEngine => { const { executor, codeExecutor, toolDiscoveryProvider = defaultToolDiscoveryProvider } = config; + const delegation = config.delegation ?? executor.delegation; const pausedExecutions = new Map>(); // Every sandbox fiber `startPausableExecution` still has in flight. // @@ -592,7 +606,7 @@ export const createExecutionEngine = >(); + const settledOutcomes = new Map>(); const SETTLED_OUTCOME_LIMIT = 64; const settledExecutionIds = new Set(); const SETTLED_EXECUTION_ID_LIMIT = 1024; @@ -603,20 +617,36 @@ export const createExecutionEngine = ; readonly orgWriteAccess: OrgWriteAccessState; + readonly delegation?: DelegationContext; } >(); // Exits (not just successes) so a replayed failure re-fails through the // typed channel — hosts render engine failures opaquely, and a replay must // not bypass that by flattening the cause into result text. - const recordSettledOutcome = (executionId: string, exit: Exit.Exit): void => { + const sameDelegationIdentity = (left: DelegatedPrincipal, right: DelegatedPrincipal): boolean => + String(left.tenant) === String(right.tenant) && + String(left.grantId) === String(right.grantId) && + String(left.credentialId) === String(right.credentialId) && + String(left.actorSubject) === String(right.actorSubject) && + String(left.effectiveSubject) === String(right.effectiveSubject) && + String(left.scopeVersion) === String(right.scopeVersion); + + const recordSettledOutcome = ( + executionId: string, + exit: Exit.Exit, + retainedDelegation?: DelegationContext, + ): void => { settledExecutionIds.add(executionId); while (settledExecutionIds.size > SETTLED_EXECUTION_ID_LIMIT) { const oldest = settledExecutionIds.keys().next().value; if (oldest === undefined) break; settledExecutionIds.delete(oldest); } - settledOutcomes.set(executionId, exit); + settledOutcomes.set(executionId, { + outcome: exit, + ...(retainedDelegation === undefined ? {} : { delegation: retainedDelegation }), + }); while (settledOutcomes.size > SETTLED_OUTCOME_LIMIT) { const oldest = settledOutcomes.keys().next().value; if (oldest === undefined) break; @@ -707,6 +737,9 @@ export const createExecutionEngine = = { id, elicitationContext: ctx, + ...(delegation + ? { delegation: delegation as DelegatedPrincipal, delegationContext: delegation } + : {}), response: responseDeferred, orgWriteAccess, fiber: fiber!, @@ -758,7 +791,7 @@ export const createExecutionEngine = (); pendingResumes.set(executionId, { outcome: inflight, orgWriteAccess: paused.orgWriteAccess, + delegation: paused.delegationContext, }); // The detached sandbox inherited the starter's request context. Replace @@ -835,7 +908,7 @@ export const createExecutionEngine = Effect.gen(function* () { - recordSettledOutcome(executionId, exit); + recordSettledOutcome(executionId, exit, paused.delegationContext); pendingResumes.delete(executionId); yield* Deferred.done(inflight, exit); }), diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index a2aa222d17..0561b38dfd 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Fiber, Schema } from "effect"; +import { Data, Effect, Fiber, Result, Schema } from "effect"; import { AuthTemplateSlug, ConnectionName, + CredentialId, + DelegationDeniedError, + DelegationId, + DelegationScopeVersion, ElicitationResponse, FormElicitation, IntegrationSlug, @@ -13,6 +17,8 @@ import { ProviderKey, ToolName, ToolResult, + Subject, + Tenant, createExecutor, definePlugin, type AnyPlugin, @@ -2030,3 +2036,84 @@ describe("pause/resume with multiple elicitations", () => { }), ); }); + +describe("delegated pause/resume authorization", () => { + const makeElicitingExecutor = () => + Effect.gen(function* () { + const executor = yield* makeExecutorWith([apiPlugin] as const); + yield* provision(executor as never, [{ pluginId: "api-test", integration: "api" }]); + return executor; + }); + + it.effect("rechecks the retained grant before waking a paused execution", () => + Effect.gen(function* () { + const executor = yield* makeElicitingExecutor(); + let allowed = true; + const delegation = { + kind: "delegated" as const, + tenant: Tenant.make("test-tenant"), + grantId: DelegationId.make("grant_execution_test"), + credentialId: CredentialId.make("credential_execution_test"), + actorSubject: Subject.make("machine"), + effectiveSubject: Subject.make("test-subject"), + scopeVersion: DelegationScopeVersion.make("scope_execution_test"), + credentialRef: "machine-ref", + expiresAt: Date.now() + 60_000, + scope: { + actions: ["discover", "invoke"] as const, + toolkitMembership: [], + toolAddresses: ["tools.api.org.main.singleApproval"], + connectionAddresses: ["tools.api.org.main"], + }, + revalidate: () => + allowed + ? Effect.void + : Effect.fail( + new DelegationDeniedError({ + reason: "revoked", + message: "Delegation revoked.", + }), + ), + }; + const engine = createExecutionEngine({ executor, codeExecutor, delegation }); + const paused = yield* engine.executeWithPause( + "return await tools.api.org.main.singleApproval({});", + ); + expect(paused.status).toBe("paused"); + if (paused.status !== "paused") return; + allowed = false; + const resumed = yield* Effect.result( + engine.resume(paused.execution.id, { action: "accept" }), + ); + expect(Result.isFailure(resumed)).toBe(true); + expect( + Result.match(resumed, { + onFailure: (failure) => failure, + onSuccess: () => undefined, + }), + ).toBeInstanceOf(DelegationDeniedError); + + allowed = true; + const completedPause = yield* engine.executeWithPause( + "return await tools.api.org.main.singleApproval({});", + ); + expect(completedPause.status).toBe("paused"); + if (completedPause.status !== "paused") return; + const completed = yield* engine.resume(completedPause.execution.id, { + action: "accept", + }); + expect(completed?.status).toBe("completed"); + allowed = false; + const replayed = yield* Effect.result( + engine.resume(completedPause.execution.id, { action: "accept" }), + ); + expect(Result.isFailure(replayed)).toBe(true); + expect( + Result.match(replayed, { + onFailure: (failure) => failure, + onSuccess: () => undefined, + }), + ).toBeInstanceOf(DelegationDeniedError); + }), + ); +}); diff --git a/packages/core/sdk/src/api-errors.ts b/packages/core/sdk/src/api-errors.ts index d866c762e0..70bc937693 100644 --- a/packages/core/sdk/src/api-errors.ts +++ b/packages/core/sdk/src/api-errors.ts @@ -21,3 +21,10 @@ export class InternalError extends Schema.TaggedErrorClass()( }, { httpApiStatus: 500 }, ) {} + +/** A valid delegated credential requested an action outside its consent. */ +export class DelegationForbiddenError extends Schema.TaggedErrorClass()( + "DelegationForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} diff --git a/packages/core/sdk/src/blob.ts b/packages/core/sdk/src/blob.ts index c98286611d..aca0882859 100644 --- a/packages/core/sdk/src/blob.ts +++ b/packages/core/sdk/src/blob.ts @@ -36,6 +36,17 @@ export interface BlobStore { key: string, value: string, ) => Effect.Effect; + /** + * Create a value exactly once. Backends must make the existence check and + * insert atomic; a false result means another writer already claimed the + * key. This is deliberately separate from `put`, whose overwrite semantics + * are needed by ordinary plugin storage. + */ + readonly claim: ( + namespace: string, + key: string, + value: string, + ) => Effect.Effect; readonly delete: (namespace: string, key: string) => Effect.Effect; readonly has: (namespace: string, key: string) => Effect.Effect; } @@ -149,6 +160,13 @@ export const makeInMemoryBlobStore = (): BlobStore => { Effect.sync(() => { store.set(k(ns, key), value); }), + claim: (ns, key, value) => + Effect.sync(() => { + const id = k(ns, key); + if (store.has(id)) return false; + store.set(id, value); + return true; + }), delete: (ns, key) => Effect.sync(() => { store.delete(k(ns, key)); @@ -232,6 +250,24 @@ export const makeFumaBlobStore = (fuma: IFumaClient): BlobStore => ({ (cause) => new StorageError({ message: "FumaDB blob operation failed", cause }), ), ), + claim: (namespace, key, value) => + fuma + .use("blob.claim", (db) => + db + .create("blob", { + id: blobId(namespace, key), + namespace, + key, + value, + }) + .then(() => true), + ) + .pipe( + Effect.catchTag("UniqueViolationError", () => Effect.succeed(false)), + Effect.mapError( + (cause) => new StorageError({ message: "FumaDB blob operation failed", cause }), + ), + ), delete: (namespace, key) => fuma .use("blob.delete", (db) => diff --git a/packages/core/sdk/src/delegated-executor.test.ts b/packages/core/sdk/src/delegated-executor.test.ts new file mode 100644 index 0000000000..f3606756ef --- /dev/null +++ b/packages/core/sdk/src/delegated-executor.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Result } from "effect"; + +import { + makeDelegationService, + type DelegationContext, + type DelegationScope, + type DelegationService, +} from "./delegation"; +import { collectTables, createExecutor, type Executor } from "./executor"; +import { DelegationDeniedError } from "./fuma-runtime"; +import { ConnectionAddress, ProviderKey, Subject, Tenant, ToolAddress } from "./ids"; +import { definePlugin, tool } from "./plugin"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; +import { makeTestConfig, memoryCredentialsPlugin } from "./testing"; + +const TENANT = "delegated-executor-tenant"; +const CREATOR = Subject.make("administrator-creator"); +const TARGET = Subject.make("member-target"); +const ALLOWED = ToolAddress.make("delegation-fixture.allowed"); +const LATER = ToolAddress.make("delegation-fixture.later"); +const CORE_CONNECTIONS_LIST = ToolAddress.make("executor.coreTools.connections.list"); +const CORE_PROVIDER_LIST = ToolAddress.make("executor.coreTools.providers.list"); +const CORE_OAUTH_CLIENTS_LIST = ToolAddress.make("executor.coreTools.oauth.clients.list"); + +const toolkit = (toolAddresses: readonly ToolAddress[]) => ({ + toolkitId: "fixture-toolkit", + revision: "revision-1", + toolAddresses, + connectionAddresses: [], +}); + +const scopeFor = ( + toolAddresses: readonly ToolAddress[], + connectionAddresses?: readonly string[], +): DelegationScope => ({ + actions: ["discover", "invoke"], + toolkitMembership: [toolkit(toolAddresses)], + toolAddresses, + ...(connectionAddresses === undefined ? {} : { connectionAddresses }), +}); + +const fixturePlugin = (calls: string[]) => + definePlugin(() => ({ + id: "delegation-fixture" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "delegation-fixture", + kind: "test", + name: "Delegation fixture", + tools: [ + tool({ + name: "allowed", + description: "A tool included in the consent snapshot.", + execute: () => Effect.sync(() => void calls.push("allowed")), + }), + tool({ + name: "later", + description: "A tool added after the consent snapshot.", + execute: () => Effect.sync(() => void calls.push("later")), + }), + ], + }, + ], + }))(); + +interface DelegationFixture { + readonly db: SqliteTestFumaDb; + readonly service: DelegationService; + readonly grant: DelegationContext; + readonly executor: Executor; +} + +const withDb = (body: (db: SqliteTestFumaDb) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + body, + (db) => Effect.promise(() => db.close()), + ); + +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); + }), + ); + +const makeService = ( + db: SqliteTestFumaDb, + tenant: string, + controls: { + readonly verified?: () => boolean; + readonly authorized?: () => boolean; + readonly now?: () => number; + } = {}, +): DelegationService => + makeDelegationService({ + db: db.db, + tenant: Tenant.make(tenant), + issuanceEnabled: true, + now: controls.now, + isVerifiedHuman: () => Effect.succeed(controls.verified?.() ?? true), + isCurrentlyAuthorized: () => Effect.succeed(controls.authorized?.() ?? true), + isManagementAuthorized: () => Effect.succeed(true), + verifyProof: () => Effect.succeed(true), + }); + +const issueExecutor = ( + db: SqliteTestFumaDb, + service: DelegationService, + calls: string[], + input: { + readonly scope: DelegationScope; + readonly tenant?: string; + readonly subject?: Subject; + readonly coreTools?: boolean; + }, +): Effect.Effect => + Effect.gen(function* () { + const issued = yield* service.createIssuedKey({ + createdBy: CREATOR, + effectiveSubject: input.subject ?? TARGET, + label: "Deterministic fixture key", + scope: input.scope, + }); + const grant = yield* service.resolveIssuedKey(issued.secret); + const plugins = [fixturePlugin(calls), memoryCredentialsPlugin()] as const; + const config = makeTestConfig({ + tenant: input.tenant ?? TENANT, + subject: input.subject ?? TARGET, + plugins, + ...(input.coreTools === true ? { coreTools: { includeProviders: true } } : {}), + }); + const executor = yield* createExecutor({ + ...config, + db: db.db, + delegation: grant, + }); + return { db, service, grant, executor }; + }); + +describe("delegated executor authorization", () => { + it.effect("filters catalog and schema to the frozen toolkit snapshot", () => + withDb((db) => + Effect.gen(function* () { + const calls: string[] = []; + const service = makeService(db, TENANT); + const fixture = yield* issueExecutor(db, service, calls, { + scope: scopeFor([ALLOWED]), + }); + + const listed = yield* fixture.executor.tools.list(); + expect(listed.map((tool) => String(tool.address))).toEqual([String(ALLOWED)]); + expect((yield* fixture.executor.tools.schema(ALLOWED))?.address).toBe(ALLOWED); + yield* expectDenied(fixture.executor.tools.schema(LATER), "scope"); + expect(yield* fixture.executor.execute(ALLOWED, {})).toBeUndefined(); + yield* expectDenied(fixture.executor.execute(LATER, {}), "scope"); + expect(calls).toEqual(["allowed"]); + }), + ), + ); + + it.effect("denies an empty toolkit and explicit empty connection allowlist", () => + withDb((db) => + Effect.gen(function* () { + const calls: string[] = []; + const service = makeService(db, TENANT); + const fixture = yield* issueExecutor(db, service, calls, { + scope: { + actions: ["discover", "invoke"], + toolkitMembership: [], + toolAddresses: [], + connectionAddresses: [], + }, + }); + + expect(yield* fixture.executor.tools.list()).toEqual([]); + yield* expectDenied(fixture.executor.execute(ALLOWED, {}), "scope"); + const emptyConnections = yield* issueExecutor(db, service, calls, { + scope: scopeFor([ALLOWED], []), + }); + yield* expectDenied( + emptyConnections.grant.revalidate({ + operation: "invoke", + tool: ALLOWED, + connection: ConnectionAddress.make("tools.example.user.empty"), + }), + "scope", + ); + expect(calls).toEqual([]); + }), + ), + ); + + it.effect("does not inherit creator administration or provider and secret access", () => + withDb((db) => + Effect.gen(function* () { + const calls: string[] = []; + const service = makeService(db, TENANT); + const fixture = yield* issueExecutor(db, service, calls, { + coreTools: true, + scope: scopeFor([ + ALLOWED, + CORE_CONNECTIONS_LIST, + CORE_PROVIDER_LIST, + CORE_OAUTH_CLIENTS_LIST, + ]), + }); + + expect(fixture.grant.actorSubject === CREATOR).toBe(false); + expect(fixture.grant.effectiveSubject === TARGET).toBe(true); + yield* expectDenied(fixture.executor.connections.list(), "management"); + yield* expectDenied(fixture.executor.providers.list(), "management"); + yield* expectDenied(fixture.executor.execute(CORE_CONNECTIONS_LIST, {}), "management"); + yield* expectDenied(fixture.executor.execute(CORE_PROVIDER_LIST, {}), "management"); + yield* expectDenied(fixture.executor.execute(CORE_OAUTH_CLIENTS_LIST, {}), "management"); + expect(calls).toEqual([]); + }), + ), + ); + + it.effect("rechecks revocation and membership on an existing executor", () => + withDb((db) => + Effect.gen(function* () { + let verified = true; + let authorized = true; + const calls: string[] = []; + const service = makeService(db, TENANT, { + verified: () => verified, + authorized: () => authorized, + }); + const fixture = yield* issueExecutor(db, service, calls, { + scope: scopeFor([ALLOWED]), + }); + + yield* fixture.executor.execute(ALLOWED, {}); + yield* service.revoke({ grantId: fixture.grant.grantId, actorSubject: CREATOR }); + yield* expectDenied(fixture.executor.execute(ALLOWED, {}), "revoked"); + + const second = yield* issueExecutor(db, service, calls, { + scope: scopeFor([ALLOWED]), + }); + authorized = false; + yield* expectDenied(second.executor.execute(ALLOWED, {}), "unauthorized"); + verified = false; + yield* expectDenied(second.executor.execute(ALLOWED, {}), "unauthorized"); + expect(calls).toEqual(["allowed"]); + }), + ), + ); + + it.effect("rejects tenant and executor subject mismatches", () => + withDb((db) => + Effect.gen(function* () { + const service = makeService(db, TENANT); + const issued = yield* service.createIssuedKey({ + createdBy: CREATOR, + effectiveSubject: TARGET, + label: "Tenant binding fixture", + scope: scopeFor([ALLOWED]), + }); + const otherTenant = makeService(db, "other-tenant"); + yield* expectDenied(otherTenant.resolveIssuedKey(issued.secret), "unmapped"); + + const config = makeTestConfig({ + tenant: TENANT, + subject: Subject.make("wrong-subject"), + plugins: [fixturePlugin([])] as const, + }); + const mismatch = yield* Effect.result( + createExecutor({ ...config, db: db.db, delegation: issued.grant }), + ); + expect(Result.isFailure(mismatch)).toBe(true); + }), + ), + ); + + it.effect("guards provider item discovery through the public provider surface", () => + withDb((db) => + Effect.gen(function* () { + const service = makeService(db, TENANT); + const fixture = yield* issueExecutor(db, service, [], { + scope: scopeFor([ALLOWED]), + }); + yield* expectDenied( + fixture.executor.providers.items(ProviderKey.make("memory")), + "management", + ); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2a6b823a5f..4ccff3e1fb 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -227,6 +227,7 @@ import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome, isToolResult } from "./tool-result"; import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory"; import { isUnauthorizedToolFailure } from "./auth-tool-failure"; +import { assertDelegationCapability, type DelegationContext } from "./delegation"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; @@ -369,6 +370,8 @@ interface OwnedKeys { // --------------------------------------------------------------------------- export type Executor = { + /** Verified delegated identity, when this executor is bound to one. */ + readonly delegation?: DelegationContext; readonly integrations: { readonly list: () => Effect.Effect; readonly get: (slug: IntegrationSlug) => Effect.Effect; @@ -707,6 +710,8 @@ export interface ExecutorConfig[1], + ): Effect.Effect => assertDelegationCapability(delegation, capability); + const guardDelegatedManagement = (): Effect.Effect => + guardDelegated({ operation: "management" }); const ownerBinding: OwnerBinding = { tenant: config.tenant, @@ -2084,6 +2108,29 @@ export const createExecutor = { + const value = String(address); + return ( + value.startsWith("executor.coreTools.integrations.") || + value.startsWith("executor.coreTools.connections.") || + value.startsWith("executor.coreTools.providers.") || + value.startsWith("executor.coreTools.oauth.") || + value.startsWith("executor.coreTools.policies.") + ); + }; + + // Delegated catalog reads are projections of the consent snapshot. The + // top-level catalog gate proves the grant is current; this second filter + // removes tools added after consent (including static tools, which do not + // pass through the database scope query). + const delegationAllowsTool = (tool: Tool): boolean => + delegation === undefined || + (!isProtectedStaticManagementTool(tool.address) && + delegation.scope.toolAddresses.some((address) => String(address) === String(tool.address))); + /** How long a credential provider gets to answer one call. * * A provider is frequently REMOTE — an HTTP secret store, or under sealed custody @@ -5791,6 +5838,7 @@ export const createExecutor = => - readTools(filter, { awaitStaleSync: true }); + Effect.gen(function* () { + yield* guardDelegated({ operation: "catalog" }); + return yield* readTools(filter, { awaitStaleSync: true }); + }); const toolSchema = ( address: ToolAddress, ): Effect.Effect => Effect.gen(function* () { + const parsed = parseToolAddress(String(address)); + yield* guardDelegated({ + operation: "schema", + tool: address, + ...(parsed + ? { connection: connectionAddress(parsed.owner, parsed.integration, parsed.connection) } + : {}), + }); const policyRules = yield* listActivePolicyRuleSet(); const staticEntry = staticTools.get(String(address)); if (staticEntry) { @@ -5858,15 +5918,15 @@ export const createExecutor = b.and( - byOwner(parsed.owner)(b), - b("integration", "=", String(parsed.integration)), - b("connection", "=", String(parsed.connection)), - b("name", "=", String(parsed.tool)), + byOwner(parsedAddress.owner)(b), + b("integration", "=", String(parsedAddress.integration)), + b("connection", "=", String(parsedAddress.connection)), + b("name", "=", String(parsedAddress.tool)), ), }); if (!row) return null; @@ -5908,7 +5968,7 @@ export const createExecutor = b.and( - byOwner(parsed.owner)(b), - b("integration", "=", String(parsed.integration)), - b("connection", "=", String(parsed.connection)), + byOwner(parsedAddress.owner)(b), + b("integration", "=", String(parsedAddress.integration)), + b("connection", "=", String(parsedAddress.connection)), ), }); const defs = new Map(); @@ -6447,6 +6507,20 @@ export const createExecutor = => { const handler = pickHandler(options); return Effect.gen(function* () { + const parsedAddress = parseToolAddress(String(address)); + yield* guardDelegated({ + operation: isProtectedStaticManagementTool(address) ? "management" : "invoke", + tool: address, + ...(parsedAddress + ? { + connection: connectionAddress( + parsedAddress.owner, + parsedAddress.integration, + parsedAddress.connection, + ), + } + : {}), + }); // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry const formatInvocationCauseMessage = (cause: unknown): string => { if (cause instanceof Error && cause.message.length > 0) return cause.message; @@ -6813,7 +6887,7 @@ export const createExecutor = ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + yield* guardDelegatedManagement(); + return yield* effect; + }); + const oauth = delegation + ? { + createClient: (input: Parameters[0]) => + guardManagementCall(oauthService.createClient(input)), + registerDynamicClient: ( + input: Parameters[0], + ) => guardManagementCall(oauthService.registerDynamicClient(input)), + listClients: () => guardManagementCall(oauthService.listClients()), + removeClient: (owner: Owner, slug: OAuthClientSlug) => + guardManagementCall(oauthService.removeClient(owner, slug)), + start: (input: Parameters[0]) => + guardManagementCall(oauthService.start(input)), + complete: ( + input: Parameters[0], + options?: Parameters[1], + ) => guardManagementCall(oauthService.complete(input, options)), + cancel: (state: Parameters[0]) => + guardManagementCall(oauthService.cancel(state)), + probe: (input: Parameters[0]) => + guardManagementCall(oauthService.probe(input)), + } + : oauthService; + // ------------------------------------------------------------------ // Plugin wiring — build ctx, run extension, populate static pools, // register credential providers. @@ -6908,6 +7012,7 @@ export const createExecutor = policiesUpdate(input), remove: (input) => policiesRemove(input), }, + tools: { + list: (filter) => toolsList(filter), + }, }, connections: { create: (input) => connectionsCreate(input), @@ -7267,27 +7375,37 @@ export const createExecutor = guardManagementCall(integrationsList()), + get: (slug: IntegrationSlug) => guardManagementCall(integrationsGet(slug)), + update: ( + slug: IntegrationSlug, + patch: { readonly name?: string; readonly description?: string }, + ) => guardManagementCall(integrationsUpdatePublic(slug, patch)), + remove: (slug: IntegrationSlug) => guardManagementCall(integrationsRemove(slug)), + detect: (url: string) => guardManagementCall(integrationsDetect(url)), healthCheck: { - get: integrationHealthCheckGet, - candidates: integrationHealthCheckCandidates, - set: integrationSetHealthCheck, + get: (slug: IntegrationSlug) => guardManagementCall(integrationHealthCheckGet(slug)), + candidates: (slug: IntegrationSlug) => + guardManagementCall(integrationHealthCheckCandidates(slug)), + set: (slug: IntegrationSlug, spec: HealthCheckSpec | null) => + guardManagementCall(integrationSetHealthCheck(slug, spec)), }, }, connections: { - create: connectionsCreate, - list: connectionsList, - get: connectionsGet, - update: connectionsUpdate, - remove: connectionsRemove, - refresh: connectionsRefresh, - checkHealth: connectionCheckHealth, - validate: connectionValidate, + create: (input: CreateConnectionInput) => guardManagementCall(connectionsCreate(input)), + list: (filter?: { readonly integration?: IntegrationSlug; readonly owner?: Owner }) => + guardManagementCall(connectionsList(filter)), + get: (ref: ConnectionRef) => guardManagementCall(connectionsGet(ref)), + update: (ref: ConnectionRef, input: UpdateConnectionInput) => + guardManagementCall(connectionsUpdate(ref, input)), + remove: (ref: ConnectionRef) => guardManagementCall(connectionsRemove(ref)), + refresh: (ref: ConnectionRef) => guardManagementCall(connectionsRefresh(ref)), + checkHealth: (ref: ConnectionRef, options?: { readonly ifStaleMs?: number }) => + guardManagementCall(connectionCheckHealth(ref, options)), + validate: (input: ValidateConnectionInput) => + guardManagementCall(connectionValidate(input)), }, oauth, tools: { @@ -7295,24 +7413,25 @@ export const createExecutor = guardManagementCall(providersList()), + items: (key: ProviderKey) => guardManagementCall(providersItems(key)), }, policies: { - list: policiesList, - create: policiesCreate, - update: policiesUpdate, - remove: policiesRemove, - resolve: policiesResolve, + list: () => guardManagementCall(policiesList()), + create: (input: CreateToolPolicyInput) => guardManagementCall(policiesCreate(input)), + update: (input: UpdateToolPolicyInput) => guardManagementCall(policiesUpdate(input)), + remove: (input: RemoveToolPolicyInput) => guardManagementCall(policiesRemove(input)), + resolve: (address: ToolAddress) => guardManagementCall(policiesResolve(address)), }, ...(admin ? { admin } : {}), artifacts: { - list: artifactsList, - get: artifactsGet, - save: artifactsSave, - rename: artifactsRename, - remove: artifactsRemove, - setPreview: artifactsSetPreview, + list: () => guardManagementCall(artifactsList()), + get: (id: string) => guardManagementCall(artifactsGet(id)), + save: (input: SaveArtifactInput) => guardManagementCall(artifactsSave(input)), + rename: (input: RenameArtifactInput) => guardManagementCall(artifactsRename(input)), + remove: (input: RemoveArtifactInput) => guardManagementCall(artifactsRemove(input)), + setPreview: (input: SetArtifactPreviewInput) => + guardManagementCall(artifactsSetPreview(input)), }, pendingApprovals, execute, diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 80915a7d19..271560a680 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -515,7 +515,7 @@ export { export { buildToolTypeScriptPreview } from "./schema-types"; // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. -export { InternalError } from "./api-errors"; +export { DelegationForbiddenError, InternalError } from "./api-errors"; // ToolResult — typed value-based discriminated union for tool outcomes. export { diff --git a/packages/core/sdk/src/pending-approval.test.ts b/packages/core/sdk/src/pending-approval.test.ts index e574e33624..d52f436bbf 100644 --- a/packages/core/sdk/src/pending-approval.test.ts +++ b/packages/core/sdk/src/pending-approval.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Predicate } from "effect"; -import { makeInMemoryBlobStore } from "./blob"; +import { makeFumaBlobStore, makeInMemoryBlobStore } from "./blob"; +import { collectTables } from "./executor"; +import { makeFumaClient } from "./fuma-runtime"; import { makePendingApprovalStore, PENDING_APPROVAL_TTL_MS, type PendingApproval, } from "./pending-approval"; import { CredentialId, DelegationId, DelegationScopeVersion, Subject, Tenant } from "./ids"; +import { createSqliteTestFumaDb } from "./sqlite-test-db"; import type { DelegationBinding } from "./delegation"; const delegationBinding: DelegationBinding = { @@ -105,6 +108,69 @@ describe("makePendingApprovalStore", () => { }), ); + it.effect("keeps sensitive approval payloads out of the replay tombstone", () => + Effect.gen(function* () { + const blobs = makeInMemoryBlobStore(); + const store = makePendingApprovalStore(blobs, "u:t:s"); + yield* store.put(approval({ code: 'return await tools.secret({ token: "private" })' })); + + expect(yield* store.consume("exec_1")).not.toBeNull(); + const marker = yield* blobs.get("u:t:s/@pending-approval/@claim", "exec_1"); + expect(marker).toBe("claimed"); + expect(marker).not.toContain("private"); + }), + ); + + it.effect("allows only one concurrent resume to claim a record", () => + Effect.gen(function* () { + const base = makeInMemoryBlobStore(); + let reads = 0; + let release!: () => void; + const bothReadersStarted = new Promise((resolve) => { + release = resolve; + }); + const gated = { + ...base, + get: (namespace: string, key: string) => + base.get(namespace, key).pipe( + Effect.flatMap((value) => + Effect.promise(async () => { + reads += 1; + if (reads === 2) release(); + await bothReadersStarted; + return value; + }), + ), + ), + }; + const store = makePendingApprovalStore(gated, "u:t:s"); + yield* store.put(approval()); + + const results = yield* Effect.all([store.consume("exec_1"), store.consume("exec_1")], { + concurrency: "unbounded", + }); + expect(results.filter(Predicate.isNotNull)).toHaveLength(1); + }), + ); + + it.effect("uses the database uniqueness claim across concurrent resumes", () => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + (db) => + Effect.gen(function* () { + const blobs = makeFumaBlobStore(makeFumaClient(db.db)); + const store = makePendingApprovalStore(blobs, "u:t:s"); + yield* store.put(approval()); + + const results = yield* Effect.all([store.consume("exec_1"), store.consume("exec_1")], { + concurrency: "unbounded", + }); + expect(results.filter(Predicate.isNotNull)).toHaveLength(1); + }), + (db) => Effect.promise(() => db.close()), + ), + ); + // Ownership is the partition, not a separate check that could drift from it. it.effect("is invisible to a different owner partition", () => Effect.gen(function* () { diff --git a/packages/core/sdk/src/pending-approval.ts b/packages/core/sdk/src/pending-approval.ts index a1c522ea25..3eb210aaa5 100644 --- a/packages/core/sdk/src/pending-approval.ts +++ b/packages/core/sdk/src/pending-approval.ts @@ -90,8 +90,9 @@ export interface PendingApprovalStore { readonly put: (approval: PendingApproval) => 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 + /** Atomically claim and then delete — an approval authorizes exactly one + * run, including when two isolates resume the same record concurrently. An + * optional matcher is evaluated before the durable claim. 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: ( @@ -116,6 +117,14 @@ export const makePendingApprovalStore = ( now: () => number = Date.now, ): PendingApprovalStore => { const namespace = `${partition}/@pending-approval`; + // Claims are durable tombstones in a separate namespace. Keeping the marker + // after deleting the approval is what makes a replay fail across executor + // instances, rather than only while one process happens to be alive. + const claimNamespace = `${namespace}/@claim`; + // The claim tombstone is deliberately a constant. The approval payload can + // contain code and user arguments, so retaining it after the approval row is + // consumed would unnecessarily extend the lifetime of sensitive data. + const claimMarker = "claimed"; return { put: (approval) => blobs.put(namespace, approval.executionId, encodePendingApproval(approval)), @@ -147,6 +156,8 @@ export const makePendingApprovalStore = ( // 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; + const claimed = yield* blobs.claim(claimNamespace, executionId, claimMarker); + if (!claimed) return null; yield* blobs.delete(namespace, executionId); return approval; }), diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index e079f5ab8a..cda02d677e 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -61,7 +61,7 @@ import type { ToolPolicy, UpdateToolPolicyInput, } from "./policies"; -import type { Tool, ToolAnnotations, ToolDef } from "./tool"; +import type { Tool, ToolAnnotations, ToolDef, ToolListFilter } from "./tool"; // --------------------------------------------------------------------------- // OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor @@ -230,6 +230,10 @@ export interface PluginCtx { input: RemoveToolPolicyInput, ) => Effect.Effect; }; + /** Read-only exact catalog seam used by consent snapshot producers. */ + readonly tools: { + readonly list: (filter?: ToolListFilter) => Effect.Effect; + }; }; /** Saved credentials. A connection IS the credential; resolve its value diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..abbcff43d1 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -184,7 +184,7 @@ export { } from "./oauth-client"; // Wire-level HTTP error schema for plugin HttpApiGroup definitions. -export { InternalError } from "./api-errors"; +export { DelegationForbiddenError, InternalError } from "./api-errors"; // Executor server connection contracts (browser-safe). export { diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index a04546dbd4..d7e71a1930 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -52,6 +52,7 @@ "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "miniflare": "4.20260424.0", "typescript": "catalog:", "urlpattern-polyfill": "^10.1.0", "vitest": "catalog:" diff --git a/packages/hosts/cloudflare/src/blob-store.test.ts b/packages/hosts/cloudflare/src/blob-store.test.ts index b8d595f705..41d2bf8ecf 100644 --- a/packages/hosts/cloudflare/src/blob-store.test.ts +++ b/packages/hosts/cloudflare/src/blob-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import type { R2Bucket } from "@cloudflare/workers-types"; +import { Miniflare } from "miniflare"; import { StorageError, pluginBlobStore } from "@executor-js/sdk"; @@ -74,6 +75,48 @@ describe("makeR2BlobStore", () => { }), ); + it.effect("claims a minimal marker once across two Miniflare R2 adapters", () => + Effect.acquireUseRelease( + Effect.sync( + () => + new Miniflare({ + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + r2Buckets: ["BUCKET"], + compatibilityDate: "2025-04-01", + }), + ), + (miniflare) => + Effect.gen(function* () { + // Miniflare's R2 type uses its bundled undici Headers type while the + // host adapter is typed against workers-types. Both describe the same + // runtime bucket methods; keep the cast at this test boundary. + // oxlint-disable-next-line executor/no-double-cast -- Miniflare/workers-types Headers declarations differ + const bucket = (yield* Effect.promise(() => + miniflare.getR2Bucket("BUCKET"), + )) as unknown as R2Bucket; + const first = makeR2BlobStore(bucket); + const second = makeR2BlobStore(bucket); + const results = yield* Effect.all( + [ + first.claim("u:t:s/@pending-approval/@claim", "exec_1", "claimed"), + second.claim("u:t:s/@pending-approval/@claim", "exec_1", "claimed"), + ], + { concurrency: "unbounded" }, + ); + expect(results.filter(Boolean)).toHaveLength(1); + + const marker = yield* Effect.promise(() => + bucket.get("u:t:s/@pending-approval/@claim/exec_1"), + ); + expect(marker).not.toBeNull(); + if (marker === null) return; + expect(yield* Effect.promise(() => marker.text())).toBe("claimed"); + }), + (miniflare) => Effect.promise(() => miniflare.dispose()), + ), + ); + it.effect("bucket failures surface as StorageError", () => Effect.gen(function* () { // oxlint-disable-next-line executor/no-double-cast -- test double: only the slice the store calls is implemented diff --git a/packages/hosts/cloudflare/src/blob-store.ts b/packages/hosts/cloudflare/src/blob-store.ts index 631718a3ce..831ce580b2 100644 --- a/packages/hosts/cloudflare/src/blob-store.ts +++ b/packages/hosts/cloudflare/src/blob-store.ts @@ -57,6 +57,17 @@ export const makeR2BlobStore = (bucket: R2Bucket): BlobStore => ({ }, catch: storeError("put"), }), + // R2's conditional put is the storage-level compare-and-claim primitive. + // `etagDoesNotMatch: "*"` only succeeds when the marker object is absent, + // so two isolates cannot both win the same pending-approval claim. + claim: (namespace, key, value) => + Effect.tryPromise({ + try: async () => + (await bucket.put(objectName(namespace, key), value, { + onlyIf: { etagDoesNotMatch: "*" }, + })) !== null, + catch: storeError("claim"), + }), delete: (namespace, key) => Effect.tryPromise({ try: () => bucket.delete(objectName(namespace, key)), diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 805dd14d16..3ca334e905 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Data, Deferred, Effect, Exit, Option, Schema } from "effect"; import type * as Tracer from "effect/Tracer"; import type { Connection, ConnectionContext } from "agents"; import { McpAgent } from "agents/mcp"; @@ -27,9 +27,14 @@ import { currentOrgWriteAccess, makeOrgWriteAccessState, type OrgWriteAccess, + type DelegationCapability, } from "@executor-js/sdk"; -import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; +import type { + IncomingPropagationHeaders, + McpDelegationBinding, + McpElicitationMode, +} from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; import type { McpExecutionOwnerDirectory, @@ -88,6 +93,8 @@ interface McpSessionInitBase { * `/mcp/toolkits/` toolkit), so the tool catalog is scoped to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** Serializable grant binding; live scope is rebuilt after restore. */ + readonly delegation?: McpDelegationBinding; } /** Live session initialization metadata from an authenticated principal. */ @@ -101,6 +108,10 @@ export interface McpSessionProps extends Record { export type McpApprovalOwner = { readonly accountId: string; readonly organizationId: string; + readonly delegation?: McpDelegationBinding; + /** Current human role, when this identity came from the browser approval + * route. It is never accepted from the approval body. */ + readonly orgRole?: "admin" | "member"; }; /** A model `resume` forwarded from another session of the same owner, carrying @@ -114,6 +125,33 @@ export type McpApprovalPrincipal = McpApprovalOwner & { readonly orgRole: "admin" | "member"; }; +/** A delegated session must have a host-authoritative resolver. The default is + * fail-closed so a persisted binding can never become ordinary access after a + * cold restore merely because a host forgot to wire its DB-backed service. */ +export class McpDelegationUnavailableError extends Data.TaggedError( + "McpDelegationUnavailableError", +)<{}> { + override get message(): string { + return "Delegated session authorization is unavailable"; + } +} + +const sameDelegationBinding = ( + left: McpDelegationBinding | undefined, + right: McpDelegationBinding | undefined, +): boolean => { + if (left === undefined || right === undefined) return left === right; + return ( + left.tenant === right.tenant && + left.grantId === right.grantId && + left.credentialId === right.credentialId && + left.credentialRef === right.credentialRef && + left.actorSubject === right.actorSubject && + left.effectiveSubject === right.effectiveSubject && + left.scopeVersion === right.scopeVersion + ); +}; + type McpSessionApprovalErrorResult = | { readonly status: "not_found" } | { readonly status: "forbidden" }; @@ -173,6 +211,8 @@ interface SessionMetaBase { * `buildMcpServer` scopes the tool catalog to it. */ readonly resource: McpResource; readonly webOrigin?: string; + /** Persisted grant binding used for ownership and cold-restore checks. */ + readonly delegation?: McpDelegationBinding; /** * Whether this session's client advertised MCP-Apps support at `initialize`. * @@ -385,6 +425,37 @@ export abstract class McpAgentSessionDOBase< dbHandle: TDbHandle, ): Effect.Effect; + /** Rebuild and revalidate delegated grant state from authoritative host + * storage. Implementations must read current membership and revocation state + * for every request; the serializable session binding is only an index. */ + protected revalidateDelegation( + _binding: McpDelegationBinding, + _capability: DelegationCapability, + ): Effect.Effect { + return Effect.fail(new McpDelegationUnavailableError()); + } + + /** + * Record who approved a delegated execution. A delegated session must opt + * into a host-authoritative implementation; otherwise browser approval + * remains fail-closed and cannot silently lose human attribution. + */ + protected auditBrowserApproval( + binding: McpDelegationBinding | undefined, + _approver: McpApprovalPrincipal, + _executionId: string, + _response: ResumeResponse, + ): Effect.Effect { + return binding === undefined ? Effect.void : Effect.fail(new McpDelegationUnavailableError()); + } + + private revalidateDelegationBinding( + binding: McpDelegationBinding | undefined, + capability: DelegationCapability, + ): Effect.Effect { + return binding === undefined ? Effect.void : this.revalidateDelegation(binding, capability); + } + /** * Cheap, count-only proxies for what the runtime `buildMcpServer` just built * holds — e.g. connected-integration counts — attributed alongside @@ -1631,9 +1702,14 @@ export abstract class McpAgentSessionDOBase< } const ownerMatches = identity.accountId === sessionMeta.userId && - identity.organizationId === sessionMeta.organizationId; + identity.organizationId === sessionMeta.organizationId && + sameDelegationBinding(identity.delegation, sessionMeta.delegation); const resourceMatches = mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource); - return ownerMatches && resourceMatches ? ("ok" as const) : ("forbidden" as const); + if (!ownerMatches || !resourceMatches) return "forbidden" as const; + yield* self.revalidateDelegationBinding(sessionMeta.delegation, { + operation: "invoke", + }); + return "ok" as const; }).pipe( Effect.withSpan("McpSessionDO.validateMcpSessionOwner"), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results @@ -1651,12 +1727,15 @@ export abstract class McpAgentSessionDOBase< return Effect.runPromise( Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); - const owner = yield* self.validateApprovalIdentity(identity); + const owner = yield* self.validateApprovalIdentity(identity, "approval"); if (owner !== "ok") return { status: owner } as const; const restored = yield* self.ensureRuntimeForApproval(); if (!restored || !self.engine) return { status: "not_found" } as const; + const sessionMeta = yield* self.loadSessionMeta(); + if (!sessionMeta) return { status: "not_found" } as const; + const paused = yield* self.engine.getPausedExecution(executionId); if (!paused) return { status: "not_found" } as const; @@ -1689,7 +1768,7 @@ export abstract class McpAgentSessionDOBase< return Effect.runPromise( Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); - const owner = yield* self.validateApprovalIdentity(identity); + const owner = yield* self.validateApprovalIdentity(identity, "resume"); if (owner === "forbidden") return { status: "execution_forbidden" } as const; if (owner === "not_found") { return { status: "execution_expired" as const, ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }; @@ -1757,15 +1836,19 @@ export abstract class McpAgentSessionDOBase< return Effect.runPromise( Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); - const owner = yield* self.validateApprovalIdentity(identity); + const owner = yield* self.validateApprovalIdentity(identity, "approval"); if (owner !== "ok") return { status: owner } as const; const restored = yield* self.ensureRuntimeForApproval(); if (!restored || !self.engine) return { status: "not_found" } as const; + const sessionMeta = yield* self.loadSessionMeta(); + if (!sessionMeta) return { status: "not_found" } as const; + const paused = yield* self.engine.getPausedExecution(executionId); if (!paused) return { status: "not_found" } as const; + yield* self.auditBrowserApproval(sessionMeta.delegation, identity, executionId, response); yield* self.recordApprovalResponse(executionId, { response, orgWriteAccess: identity.orgRole === "admin" ? "allowed" : "denied", @@ -1866,15 +1949,27 @@ export abstract class McpAgentSessionDOBase< private validateApprovalIdentity( identity: McpApprovalOwner, - ): Effect.Effect<"ok" | "not_found" | "forbidden"> { + operation: "approval" | "resume" = "approval", + ): Effect.Effect<"ok" | "not_found" | "forbidden", McpDelegationUnavailableError> { const self = this; return Effect.gen(function* () { const sessionMeta = yield* self.loadSessionMeta(); if (!sessionMeta) return "not_found" as const; - return identity.accountId === sessionMeta.userId && - identity.organizationId === sessionMeta.organizationId - ? ("ok" as const) - : ("forbidden" as const); + const delegatedHumanApprover = + operation === "approval" && + identity.delegation === undefined && + sessionMeta.delegation !== undefined && + identity.organizationId === sessionMeta.organizationId && + (identity.accountId === sessionMeta.delegation.effectiveSubject || + identity.orgRole === "admin"); + const ownerMatches = + identity.organizationId === sessionMeta.organizationId && + (delegatedHumanApprover || + (identity.accountId === sessionMeta.userId && + sameDelegationBinding(identity.delegation, sessionMeta.delegation))); + if (!ownerMatches) return "forbidden" as const; + yield* self.revalidateDelegationBinding(sessionMeta.delegation, { operation }); + return "ok" as const; }).pipe(Effect.withSpan("mcp.session.validate_approval_identity")); } @@ -1917,6 +2012,7 @@ export abstract class McpAgentSessionDOBase< owner: self.executionOwnerRoute(), accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, + ...(sessionMeta.delegation === undefined ? {} : { delegation: sessionMeta.delegation }), expiresAt: deadline.expiresAt, ttlMs: deadline.ttlMs, }; @@ -1975,11 +2071,13 @@ export abstract class McpAgentSessionDOBase< const identity: McpModelResumeCaller = { accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, + ...(sessionMeta.delegation === undefined ? {} : { delegation: sessionMeta.delegation }), orgWriteAccess: yield* currentOrgWriteAccess, }; if ( identity.accountId !== record.accountId || - identity.organizationId !== record.organizationId + identity.organizationId !== record.organizationId || + !sameDelegationBinding(identity.delegation, record.delegation) ) { return { status: "execution_forbidden" } as const; } diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 6690331b1b..2a00a265fe 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -18,11 +18,25 @@ import { mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; export const INTERNAL_RESOURCE_KEY_HEADER = "x-executor-mcp-resource-key"; +export const INTERNAL_DELEGATION_BINDING_HEADER = "x-executor-mcp-delegation-binding"; + +/** Serializable identity binding stamped by the authenticated worker and + * persisted by the session DO. It contains no bearer material or live scope. */ +export type McpDelegationBinding = { + readonly tenant: string; + readonly grantId: string; + readonly credentialId: string; + readonly credentialRef: string; + readonly actorSubject: string; + readonly effectiveSubject: string; + readonly scopeVersion: string; +}; /** The verified identity used to stamp the DO's internal owner headers. */ export type VerifiedTokenHeaders = { readonly accountId: string; readonly organizationId: string; + readonly delegation?: McpDelegationBinding; }; // Worker and DO run in separate isolates with independent WebSdk tracer @@ -110,6 +124,11 @@ export const withVerifiedIdentityHeaders = ( headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); headers.set(INTERNAL_RESOURCE_KEY_HEADER, mcpResourceKey(resource)); + if (token.delegation === undefined) { + headers.delete(INTERNAL_DELEGATION_BINDING_HEADER); + } else { + headers.set(INTERNAL_DELEGATION_BINDING_HEADER, JSON.stringify(token.delegation)); + } return new Request(request, { headers }); }; diff --git a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts index 54583f2e55..3a6461c519 100644 --- a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +++ b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts @@ -1,15 +1,19 @@ import { DurableObject } from "cloudflare:workers"; import { Data, Effect } from "effect"; +import type { McpDelegationBinding } from "./do-headers"; export type McpExecutionOwnerRoute = { readonly sessionId: string; }; +/** Persisted, serializable delegated binding. Live grant scopes are rebuilt + * from the authoritative service after a Durable Object restore. */ export type McpExecutionOwnerRecord = { readonly executionId: string; readonly owner: McpExecutionOwnerRoute; readonly accountId: string; readonly organizationId: string; + readonly delegation?: McpDelegationBinding; readonly expiresAt: string; readonly ttlMs: number; }; @@ -53,12 +57,23 @@ const isRecord = (value: unknown): value is Record => const isOwnerRoute = (value: unknown): value is McpExecutionOwnerRoute => isRecord(value) && typeof value.sessionId === "string"; +const isDelegationBinding = (value: unknown): value is McpDelegationBinding => + isRecord(value) && + typeof value.tenant === "string" && + typeof value.grantId === "string" && + typeof value.credentialId === "string" && + typeof value.credentialRef === "string" && + typeof value.actorSubject === "string" && + typeof value.effectiveSubject === "string" && + typeof value.scopeVersion === "string"; + const isOwnerRecord = (value: unknown): value is McpExecutionOwnerRecord => isRecord(value) && typeof value.executionId === "string" && isOwnerRoute(value.owner) && typeof value.accountId === "string" && typeof value.organizationId === "string" && + (value.delegation === undefined || isDelegationBinding(value.delegation)) && typeof value.expiresAt === "string" && typeof value.ttlMs === "number"; diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 072f98fe11..2a8fb1cf73 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -23,6 +23,7 @@ import { McpDiscoveryRoutes, McpSessionStore, orgWriteAccessForPrincipal, + principalOwns, Principal as PrincipalSchema, preInitializeMethodNotFound, type McpResource, @@ -33,6 +34,7 @@ import { const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; const TEST_PRINCIPAL: Principal = { + kind: "member", accountId: "acct_test", organizationId: "org_test", organizationName: "Test Org", @@ -60,6 +62,53 @@ it.effect("rejects a role on the no-role principal arm", () => }).pipe(Effect.flip, Effect.asVoid), ); +it("keeps legacy member ownership equivalent to explicitly tagged members", () => { + const legacy = { ...TEST_PRINCIPAL }; + delete (legacy as { kind?: "member" }).kind; + expect(principalOwns(legacy, TEST_PRINCIPAL)).toBe(true); + expect(principalOwns(TEST_PRINCIPAL, legacy)).toBe(true); +}); + +it("isolates delegated ownership by grant and strips target authority", async () => { + const delegated: Extract = { + kind: "delegated", + accountId: TEST_PRINCIPAL.accountId, + organizationId: TEST_PRINCIPAL.organizationId, + organizationName: TEST_PRINCIPAL.organizationName, + email: "", + name: null, + avatarUrl: null, + roles: [], + tenant: "tenant_test", + grantId: "grant_a", + credentialId: "credential_a", + credentialRef: "key_a", + actorSubject: "actor_test", + effectiveSubject: "target_test", + scopeVersion: "scope_1", + orgRoleModel: "organization" as const, + orgRole: "member" as const, + }; + const otherGrant = { ...delegated, grantId: "grant_b" }; + expect(principalOwns(delegated, otherGrant)).toBe(false); + expect(principalOwns(delegated, { ...delegated })).toBe(true); + expect(principalOwns(TEST_PRINCIPAL, delegated)).toBe(false); + expect(principalOwns(delegated, TEST_PRINCIPAL)).toBe(false); + expect(orgWriteAccessForPrincipal(delegated)).toBe("denied"); + + const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(PrincipalSchema)(delegated)); + expect(decoded.kind).toBe("delegated"); + + await expect( + Effect.runPromise( + Schema.decodeUnknownEffect(PrincipalSchema)({ + ...delegated, + email: "target@example.com", + }), + ), + ).rejects.toBeDefined(); +}); + /** An auth provider that authenticates everything (so dispatch is reached). */ const AuthProviderLive = Layer.succeed(McpAuthProvider)({ discoveryRoutes: [ diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index bdc51db85d..829160f773 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -15,6 +15,7 @@ import { defaultMcpResource, type Principal } from "./seams"; import { createExecutorMcpServer } from "./tool-server"; const TEST_PRINCIPAL: Principal = { + kind: "member", accountId: "acct_test", organizationId: "org_test", organizationName: "Test Org", diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 952f9bd83f..3189453c40 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -1,9 +1,13 @@ -import { Cause, Data, Effect, Layer } from "effect"; +import { Cause, Data, Effect, Exit, Layer } from "effect"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; -import type { Executor, OrgWriteAccess } from "@executor-js/sdk"; +import { + formatPausedExecution, + type ExecutionEngine, + type ResumeResponse, +} from "@executor-js/execution"; +import type { DelegationCapability, Executor, OrgWriteAccess } from "@executor-js/sdk"; import { buildResumeApprovalUrl, @@ -25,6 +29,7 @@ import { MCP_ORG_WRITE_ACCESS_HEADER, defaultMcpResource, mcpResourceKey, + McpDelegationUnavailableError, orgWriteAccessForPrincipal, principalOwns, withOrgWriteAccess, @@ -123,6 +128,19 @@ export interface McpBuildServerOptions { readonly mode?: McpToolMode; } +/** Trusted browser approval attribution supplied by the host. The approver is + * resolved from the host's current human session; it never comes from the + * approval request body. `owner` is the session principal captured at MCP + * initialization, so delegated execution remains bound to its original grant. + */ +export interface BrowserApprovalAuditInput { + readonly sessionId: string; + readonly executionId: string; + readonly owner: Principal; + readonly approver: Principal; + readonly response: ResumeResponse; +} + /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ export type McpBuildServer = ( principal: Principal, @@ -243,6 +261,16 @@ export const makeInMemoryMcpSessionStore = ( readonly sessionIdleTtlMs?: number; /** How often the sweep runs. Defaults to a quarter of the TTL. */ readonly sessionSweepIntervalMs?: number; + /** Authoritative grant and current-membership check. Delegated sessions + * fail closed when this is absent. */ + readonly revalidateDelegation?: ( + principal: Extract, + capability: DelegationCapability, + ) => Effect.Effect; + /** Record the verified human approver for a delegated browser decision. */ + readonly onBrowserApproval?: ( + input: BrowserApprovalAuditInput, + ) => Effect.Effect; } = {}, ): InMemoryMcpSessionStore => { const transports = new Map(); @@ -264,6 +292,25 @@ export const makeInMemoryMcpSessionStore = ( // flight makes "idle" mean what it says. const activeRequests = new Map(); + const revalidatePrincipal = ( + principal: Principal, + capability: DelegationCapability, + ): Effect.Effect => { + if (principal.kind !== "delegated") return Effect.void; + return options.revalidateDelegation + ? options.revalidateDelegation(principal, capability) + : Effect.fail(new McpDelegationUnavailableError()); + }; + + const revalidateForDispatch = ( + principal: Principal, + capability: DelegationCapability, + ): Effect.Effect => + revalidatePrincipal(principal, capability).pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + const idleTtlMs = options.sessionIdleTtlMs ?? DEFAULT_SESSION_IDLE_TTL_MS; const sweepIntervalMs = options.sessionSweepIntervalMs ?? Math.max(MIN_SWEEP_INTERVAL_MS, Math.floor(idleTtlMs / 4)); @@ -394,15 +441,24 @@ export const makeInMemoryMcpSessionStore = ( const owner = owners.get(sessionId); if (!transport || !owner) return Effect.succeed("not-found"); if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); - owners.set(sessionId, { principal, resource }); - touch(sessionId); - // Claim before the await, release in the finalizer — `runHandleRequest` - // already recovers every failure to a 500, but `ensuring` also covers an - // interrupt, so the counter cannot be left permanently raised (which would - // make the session immortal, the opposite leak). - beginRequest(sessionId); - return runHandleRequest(transport, request, orgWriteAccessForPrincipal(principal)).pipe( - Effect.ensuring(Effect.sync(() => endRequest(sessionId))), + return revalidateForDispatch(principal, { operation: "invoke" }).pipe( + Effect.flatMap((authorized) => { + if (!authorized) { + return Effect.succeed( + jsonRpcError(403, -32003, "Delegated session is no longer authorized"), + ); + } + owners.set(sessionId, { principal, resource }); + touch(sessionId); + // Claim before the await, release in the finalizer — `runHandleRequest` + // already recovers every failure to a 500, but `ensuring` also covers an + // interrupt, so the counter cannot be left permanently raised (which would + // make the session immortal, the opposite leak). + beginRequest(sessionId); + return runHandleRequest(transport, request, orgWriteAccessForPrincipal(principal)).pipe( + Effect.ensuring(Effect.sync(() => endRequest(sessionId))), + ); + }), ); }; @@ -451,13 +507,30 @@ export const makeInMemoryMcpSessionStore = ( resource: McpResource, request: Request, ): Effect.Effect => { + type OpenOutcome = + | { readonly kind: "built"; readonly built: BuiltMcpServer } + | { readonly kind: "denied" }; let createdSessionId: string | null = null; - return buildServer(principal, { - ...buildOptionsFor(request, () => createdSessionId), - resource, - }).pipe( - Effect.flatMap(({ mcpServer, engine, executor, close }) => - Effect.gen(function* () { + return revalidateForDispatch(principal, { operation: "catalog" }).pipe( + Effect.flatMap( + ( + authorized, + ): Effect.Effect => + authorized + ? buildServer(principal, { + ...buildOptionsFor(request, () => createdSessionId), + resource, + }).pipe(Effect.map((built): OpenOutcome => ({ kind: "built", built }))) + : Effect.succeed({ kind: "denied" }), + ), + Effect.flatMap((outcome) => { + if (outcome.kind === "denied") { + return Effect.succeed( + jsonRpcError(403, -32003, "Delegated session is no longer authorized"), + ); + } + const { mcpServer, engine, executor, close } = outcome.built; + return Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -478,16 +551,11 @@ export const makeInMemoryMcpSessionStore = ( if (sid) void dispose(sid, { server: true }); }; yield* Effect.promise(() => mcpServer.connect(transport)); - // The session id is minted on the first (initialize) request, so we - // drive `handleRequest` here; if no id results we close eagerly. return yield* runHandleRequest( transport, request, orgWriteAccessForPrincipal(principal), () => { - // Nothing was ever registered under a session id, so `dispose` has - // no entry to work from — release the handles by hand, engine - // and executor included. void ignoreClose(null, "transport", () => transport.close()); void ignoreClose(null, "server", () => mcpServer.close()); void shutdownEngine(null, engine); @@ -495,8 +563,8 @@ export const makeInMemoryMcpSessionStore = ( if (close) void ignoreClose(null, "session", close); }, ); - }), - ), + }); + }), // A build failure has nowhere typed to go in the envelope; render a 500. Effect.catchTags({ McpEngineBuildError: () => @@ -541,7 +609,13 @@ export const makeInMemoryMcpSessionStore = ( ): "allowed" | "not-found" | "forbidden" => { const owner = owners.get(sessionId); if (!owner) return "not-found"; - if (principal && !principalOwns(owner.principal, principal)) return "forbidden"; + if (!principal) return "allowed"; + const delegatedApproval = + owner.principal.kind === "delegated" && + principal.kind !== "delegated" && + owner.principal.organizationId === principal.organizationId && + (owner.principal.effectiveSubject === principal.accountId || principal.orgRole === "admin"); + if (!delegatedApproval && !principalOwns(owner.principal, principal)) return "forbidden"; return "allowed"; }; @@ -549,11 +623,17 @@ export const makeInMemoryMcpSessionStore = ( const pausedFromSession = ( sessionId: string, executionId: string, + principal?: Principal, ): Promise | null> => { const engine = engines.get(sessionId); - if (!engine) return Promise.resolve(null); + const owner = owners.get(sessionId); + if (!engine || !owner) return Promise.resolve(null); return Effect.runPromise( - engine.getPausedExecution(executionId).pipe( + (principal === undefined + ? Effect.void + : revalidatePrincipal(owner.principal, { operation: "approval", executionId }) + ).pipe( + Effect.andThen(engine.getPausedExecution(executionId)), Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), Effect.orElseSucceed(() => null), ), @@ -571,7 +651,7 @@ export const makeInMemoryMcpSessionStore = ( const access = ownerAccess(sessionId, principal); if (access === "forbidden") return json({ error: "Forbidden" }, 403); if (access === "not-found") return json({ error: "Paused execution not found" }, 404); - const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!)); + const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!), principal); if (!paused) return json({ error: "Paused execution not found" }, 404); return json({ text: paused.text, structured: paused.structured }); }; @@ -591,7 +671,7 @@ export const makeInMemoryMcpSessionStore = ( if (access === "not-found") return json({ error: "Paused execution not found" }, 404); // The session must still hold the paused execution — guards stale ids and // confirms the execution belongs to this session before recording. - const paused = await pausedFromSession(sessionId, executionId); + const paused = await pausedFromSession(sessionId, executionId, principal); if (!paused) return json({ error: "Paused execution not found" }, 404); const raw = await Effect.runPromise( @@ -602,6 +682,21 @@ export const makeInMemoryMcpSessionStore = ( const response = raw === null ? null : decodeResumeResponse(raw); if (!response) return json({ error: "Invalid approval response" }, 400); + const owner = owners.get(sessionId); + if (!owner) return json({ error: "Paused execution not found" }, 404); + if (options.onBrowserApproval) { + const audit = await Effect.runPromiseExit( + options.onBrowserApproval({ + sessionId, + executionId, + owner: owner.principal, + approver: principal ?? owner.principal, + response, + }), + ); + if (Exit.isFailure(audit)) return json({ error: "Approval unavailable" }, 503); + } + await Effect.runPromise( approvals.recordResponse(executionId, { response, diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 01d0994db0..bb10696a7d 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Schema } from "effect"; +import { Context, Data, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; import type { OrgWriteAccess } from "@executor-js/sdk"; @@ -52,29 +52,74 @@ const PrincipalFields = { roles: Schema.Array(Schema.String), } as const; +/** Serializable delegated-grant binding carried by an authenticated MCP + * principal. The live scope/revalidator never crosses the host boundary; a + * session store rebuilds it from the authoritative delegation service. */ +const DelegationFields = { + tenant: Schema.String, + grantId: Schema.String, + credentialId: Schema.String, + credentialRef: Schema.String, + actorSubject: Schema.String, + effectiveSubject: Schema.String, + scopeVersion: Schema.String, +} as const; + export const Principal = Schema.Union([ Schema.Struct({ + kind: Schema.optional(Schema.Literal("member")), ...PrincipalFields, orgRoleModel: Schema.Literal("organization"), /** Missing at a legacy boundary fails closed. */ orgRole: Schema.optional(Schema.Literals(["admin", "member"])), }), Schema.Struct({ + kind: Schema.optional(Schema.Literal("member")), ...PrincipalFields, orgRoleModel: Schema.Literal("none"), /** Reject contradictory role-bearing values on a role-less host. */ orgRole: Schema.optional(Schema.Never), }), + Schema.Struct({ + kind: Schema.Literal("delegated"), + accountId: Schema.String, + organizationId: Schema.String, + organizationName: Schema.String, + organizationSlug: Schema.optional(Schema.String), + // Delegated callers never inherit the target's email, groups, or admin + // role. The target subject is carried only in the explicit binding fields. + email: Schema.Literal(""), + name: Schema.Null, + avatarUrl: Schema.Null, + roles: Schema.Array(Schema.Never), + ...DelegationFields, + orgRoleModel: Schema.Literal("organization"), + orgRole: Schema.Literal("member"), + }), ]); export type Principal = Schema.Schema.Type; +/** A host without an authoritative grant resolver must reject delegated + * requests instead of silently treating the serialized binding as trust. */ +export class McpDelegationUnavailableError extends Data.TaggedError( + "McpDelegationUnavailableError", +)<{}> { + override get message(): string { + return "Delegated session authorization is unavailable"; + } +} + /** Internal header overwritten by a trusted session store before MCP dispatch. */ export const MCP_ORG_WRITE_ACCESS_HEADER = "x-executor-org-write-access"; /** Derive the effective workspace-write access for one authenticated request. */ export const orgWriteAccessForPrincipal = (principal: Principal): OrgWriteAccess => - principal.orgRoleModel === "none" || principal.orgRole === "admin" ? "allowed" : "denied"; + principal.kind === "delegated" + ? "denied" + : principal.orgRoleModel === "none" || principal.orgRole === "admin" + ? "allowed" + : "denied"; /** * Stamp request-bound workspace-write access for the MCP SDK request handler. @@ -87,9 +132,32 @@ export const withOrgWriteAccess = (request: Request, access: OrgWriteAccess): Re return new Request(request, { headers }); }; -/** Ownership is keyed on (accountId, organizationId) — a subset of the principal. */ -export const principalOwns = (owner: Principal, principal: Principal): boolean => - owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; +/** + * Ownership is keyed on the complete authentication binding. Two delegated + * machine credentials may target the same human subject while remaining + * isolated sessions; account/org equality alone is insufficient. + */ +export const principalOwns = (owner: Principal, principal: Principal): boolean => { + const ownerKind = owner.kind === "delegated" ? "delegated" : "member"; + const principalKind = principal.kind === "delegated" ? "delegated" : "member"; + if ( + owner.accountId !== principal.accountId || + owner.organizationId !== principal.organizationId || + ownerKind !== principalKind + ) { + return false; + } + if (owner.kind !== "delegated" || principal.kind !== "delegated") return true; + return ( + owner.tenant === principal.tenant && + owner.grantId === principal.grantId && + owner.credentialId === principal.credentialId && + owner.credentialRef === principal.credentialRef && + owner.actorSubject === principal.actorSubject && + owner.effectiveSubject === principal.effectiveSubject && + owner.scopeVersion === principal.scopeVersion + ); +}; // --------------------------------------------------------------------------- // Shared MCP resource identity. diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 91562be746..b8507e6c81 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -47,6 +47,7 @@ import type { SaveArtifactInput, ToolFileValue, Executor, + StorageFailure, ToolSchemaView, } from "@executor-js/sdk"; import type * as Tracer from "effect/Tracer"; @@ -1675,7 +1676,7 @@ export const createExecutorMcpServer = ( const executeCode = ( code: string, extra: McpRequestJoinKeys, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { yield* startMarker("mcp.host.tool.execute.start", { "mcp.tool.name": "execute", @@ -1842,7 +1843,7 @@ export const createExecutorMcpServer = ( executionId: string, response: ResumeResponse, extra: McpRequestJoinKeys, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { yield* startMarker("mcp.host.tool.resume.start", { "mcp.tool.name": "resume", @@ -1936,7 +1937,7 @@ export const createExecutorMcpServer = ( const resumeAfterBrowserApproval = ( executionId: string, extra: McpRequestJoinKeys, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { yield* startMarker("mcp.host.tool.resume.browser_approval.start", { "mcp.tool.name": "resume", diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index 3db165f6e6..2ab7eb7f4e 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -1,14 +1,46 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Result } from "effect"; -import { makeTestExecutor } from "@executor-js/sdk/testing"; +import { + AuthTemplateSlug, + ConnectionName, + definePlugin, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolName, +} from "@executor-js/sdk"; +import { makeTestExecutor, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { toolkitsPlugin } from "./server"; +const SNAPSHOT_INTEGRATION = IntegrationSlug.make("snapshot-test"); +const SNAPSHOT_CONNECTION = ConnectionName.make("main"); +const SNAPSHOT_TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const snapshotToolPlugin = definePlugin(() => ({ + id: "snapshotTool" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("read"), description: "Read a snapshot fixture" }], + }), + invokeTool: () => Effect.succeed({ ok: true }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: SNAPSHOT_INTEGRATION, + description: "Toolkit snapshot fixture", + config: {}, + }), + }), +}))(); + describe("toolkitsPlugin", () => { it.effect("creates toolkits and manages ordered policy rules", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ plugins: [toolkitsPlugin()] as const, + coreTools: {}, }); const toolkit = yield* executor.toolkits.create({ @@ -64,6 +96,93 @@ describe("toolkitsPlugin", () => { }), ); + it.effect("exports an exact membership snapshot that does not expand after edits", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + coreTools: {}, + }); + const toolkit = yield* executor.toolkits.create({ owner: "org", name: "Snapshot Kit" }); + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "executor.coreTools.*", + }); + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "executor.coreTools.*", + action: "approve", + }); + for (let index = 0; index < 8; index += 1) { + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: `executor.coreTools.connection-${index}.*`, + }); + } + for (let index = 0; index < 32; index += 1) { + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: `executor.coreTools.policy-${index}.*`, + action: index % 2 === 0 ? "approve" : "require_approval", + }); + } + + const before = yield* executor.toolkits.snapshotForSlug(toolkit.slug); + expect(before).not.toBeNull(); + if (!before) return; + expect(before.actions).toEqual(["discover", "invoke"]); + expect(before.revision).toMatch(/^[0-9a-f]{64}$/); + expect(before.toolAddresses.length).toBeGreaterThan(0); + const frozenTools = [...before.toolAddresses]; + + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "executor.coreTools.*", + action: "block", + }); + const after = yield* executor.toolkits.snapshotForSlug(toolkit.slug); + expect(after).not.toBeNull(); + expect(after?.revision).not.toBe(before.revision); + expect(before.toolAddresses).toEqual(frozenTools); + }), + ); + + it.effect("matches full tools addresses when freezing a dynamic catalog", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin(), memoryCredentialsPlugin(), snapshotToolPlugin] as const, + coreTools: {}, + }); + yield* executor.snapshotTool.seed(); + yield* executor.connections.create({ + owner: "org", + name: SNAPSHOT_CONNECTION, + integration: SNAPSHOT_INTEGRATION, + template: SNAPSHOT_TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("snapshot-test-credential"), + }, + }); + + const toolkit = yield* executor.toolkits.create({ + owner: "org", + name: "Dynamic Snapshot Kit", + }); + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "snapshot-test.org.main.*", + }); + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "snapshot-test.org.main.read", + action: "require_approval", + }); + + const snapshot = yield* executor.toolkits.snapshotForSlug(toolkit.slug); + expect(snapshot).not.toBeNull(); + expect(snapshot?.toolAddresses.map(String)).toContain("tools.snapshot-test.org.main.read"); + expect(snapshot?.connectionAddresses.map(String)).toContain("snapshot-test.org.main"); + const resolved = yield* executor.toolkits.resolvePolicyForSlug( + toolkit.slug, + "tools.snapshot-test.org.main.read", + ); + expect(resolved.action).toBe("require_approval"); + }), + ); + it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index eef15de797..bc8be54c94 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -1,5 +1,6 @@ import { Context, + ConnectionAddress, definePlugin, definePluginStorageCollection, dynamicToolScopeForPattern, @@ -8,6 +9,7 @@ import { isValidPattern, matchPattern, Schema, + sha256Hex, type DynamicToolScope, type EffectivePolicy, type Owner, @@ -19,6 +21,7 @@ import { type ToolPolicyAction, type ToolPolicyProvider, type ToolPolicyProviderRule, + ToolAddress, } from "@executor-js/sdk/core"; import { addGroup, capture } from "@executor-js/api"; import { generateKeyBetween } from "fractional-indexing"; @@ -180,16 +183,23 @@ const digestToolkitRules = ( }; }; +const canonicalToolkitToolId = (toolId: string): string => + toolId.startsWith("tools.") ? toolId.slice("tools.".length) : toolId; + const resolveToolkitPolicy = ( toolId: string, rules: ToolkitRuleSnapshot, defaultRequiresApproval?: boolean, ): EffectivePolicy => { - const connected = rules.accessPatterns.some((pattern) => matchPattern(pattern, toolId)); + // Dynamic catalog rows use the public `tools.` address prefix while toolkit + // patterns are stored as connection addresses. Keep the prefix at the + // catalog/scope boundary, and normalize only for policy matching. + const canonicalToolId = canonicalToolkitToolId(toolId); + const connected = rules.accessPatterns.some((pattern) => matchPattern(pattern, canonicalToolId)); if (!connected) return blockedPolicy(); for (const policy of rules.orderedPolicies) { - if (!matchPattern(policy.pattern, toolId)) continue; + if (!matchPattern(policy.pattern, canonicalToolId)) continue; return { action: policy.action, source: "user", @@ -237,7 +247,14 @@ const legacyConnectionPolicyIds = ( ); }; -const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; +const isPersonalDynamicToolId = (toolId: string): boolean => + canonicalToolkitToolId(toolId).split(".")[1] === "user"; + +const connectionAddressForTool = (toolId: string): ConnectionAddress | null => { + if (!toolId.startsWith("tools.")) return null; + const parts = canonicalToolkitToolId(toolId).split("."); + return parts.length >= 4 ? ConnectionAddress.make(parts.slice(0, 3).join(".")) : null; +}; const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ id: entry.data.id, @@ -585,6 +602,72 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { }; }); + /** Freeze the currently resolved catalog into exact addresses. A delegated + * grant stores this result and never re-expands these patterns after a + * toolkit edit. */ + const snapshotForSlug = (slug: string) => + Effect.gen(function* () { + const toolkit = yield* getBySlugEntry(slug); + if (!toolkit) return null; + const policies = yield* listPoliciesForRecord(toolkit.data.id); + const connections = yield* listConnectionsForRecord(toolkit.data.id); + const rules = digestToolkitRules(connections, policies); + const tools = yield* ctx.core.tools.list({ includeBlocked: true }); + const toolAddresses = tools + .filter((tool) => { + const policy = resolveToolkitPolicy( + String(tool.address), + rules, + tool.annotations?.requiresApproval, + ); + return policy.action !== "block"; + }) + .map((tool) => ToolAddress.make(String(tool.address))) + .sort((a, b) => String(a).localeCompare(String(b))); + const connectionAddresses = [ + ...new Set( + toolAddresses + .map((tool) => connectionAddressForTool(String(tool))) + .filter((address): address is ConnectionAddress => address !== null) + .map(String), + ), + ] + .sort() + .map((value) => ConnectionAddress.make(value)); + const revision = yield* sha256Hex( + JSON.stringify({ + toolkit: { + id: toolkit.data.id, + updatedAt: toolkit.data.updatedAt, + }, + connections: connections.map((connection) => ({ + id: connection.id, + pattern: connection.pattern, + position: connection.position, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + })), + policies: policies.map((policy) => ({ + id: policy.id, + pattern: policy.pattern, + action: policy.action, + position: policy.position, + createdAt: policy.createdAt, + updatedAt: policy.updatedAt, + })), + toolAddresses: toolAddresses.map(String), + connectionAddresses: connectionAddresses.map(String), + }), + ); + return { + actions: ["discover", "invoke"] as const, + toolkitId: toolkit.data.id, + revision, + toolAddresses, + connectionAddresses, + } as const; + }); + return { list, create, @@ -604,6 +687,7 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { policyRulesForSlug, resolvePolicyForSlug, preparePolicyResolverForSlug, + snapshotForSlug, }; }; From 18dd050d96e6027a9d3f803e82f41fd5e536e19a Mon Sep 17 00:00:00 2001 From: donmasakayan <1178461+donmasakayan@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:14:29 +1300 Subject: [PATCH 3/5] feat: integrate delegated credentials across hosted adapters --- apps/cloud/drizzle/0021_funny_tombstone.sql | 71 + apps/cloud/drizzle/0022_mature_exodus.sql | 17 + apps/cloud/drizzle/meta/0021_snapshot.json | 2258 ++++++++++++++++ apps/cloud/drizzle/meta/0022_snapshot.json | 2407 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 14 + apps/cloud/src/account/account-api.ts | 36 +- apps/cloud/src/account/delegation-provider.ts | 527 ++++ .../api/protected-api-key-auth.node.test.ts | 106 +- apps/cloud/src/api/protected.ts | 139 +- apps/cloud/src/api/router.ts | 19 +- apps/cloud/src/app.ts | 3 +- apps/cloud/src/auth/admin-mfa-proof.test.ts | 23 +- apps/cloud/src/auth/admin-mfa-proof.ts | 37 +- apps/cloud/src/auth/delegation.node.test.ts | 174 ++ apps/cloud/src/auth/delegation.ts | 184 ++ apps/cloud/src/auth/handlers.ts | 1 + .../auth/session-route-boundary.node.test.ts | 84 + apps/cloud/src/auth/workos-auth-provider.ts | 117 +- apps/cloud/src/db/db.schema.test.ts | 77 + apps/cloud/src/db/executor-schema.ts | 127 + apps/cloud/src/db/org-deletion.test.ts | 62 + apps/cloud/src/db/org-deletion.ts | 10 + apps/cloud/src/env-augment.d.ts | 6 + apps/cloud/src/mcp/agent-handler.ts | 39 + apps/cloud/src/mcp/auth-provider.test.ts | 79 +- apps/cloud/src/mcp/auth-provider.ts | 64 +- apps/cloud/src/mcp/auth.ts | 85 +- apps/cloud/src/mcp/jwt.ts | 2 + apps/cloud/src/mcp/session-durable-object.ts | 140 +- apps/cloud/src/mcp/session-meta.ts | 1 + .../src/account/account-provider.ts | 272 +- apps/host-cloudflare/src/app.ts | 139 +- .../src/auth/cloudflare-access.test.ts | 137 +- .../src/auth/cloudflare-access.ts | 174 +- .../src/auth/delegation.test.ts | 315 +++ apps/host-cloudflare/src/auth/delegation.ts | 130 + apps/host-cloudflare/src/auth/identity.ts | 82 + apps/host-cloudflare/src/config.test.ts | 36 + apps/host-cloudflare/src/config.ts | 64 + apps/host-cloudflare/src/mcp/agent-handler.ts | 47 +- apps/host-cloudflare/src/mcp/auth.ts | 108 +- apps/host-cloudflare/src/mcp/index.ts | 16 +- .../src/mcp/session-durable-object.ts | 133 +- .../src/worker.e2e.node.test.ts | 2 +- apps/host-selfhost/src/account/account-api.ts | 12 +- .../account/better-auth-account-provider.ts | 67 +- .../src/account/delegation-provider.ts | 742 +++++ .../src/admin/admin-users-api.ts | 93 +- apps/host-selfhost/src/admin/handlers.ts | 103 +- apps/host-selfhost/src/admin/require-admin.ts | 17 +- apps/host-selfhost/src/app.ts | 16 +- .../src/auth/better-auth.test.ts | 603 +++++ apps/host-selfhost/src/auth/better-auth.ts | 6 +- .../src/auth/delegated-boundary.ts | 100 + apps/host-selfhost/src/auth/delegated-key.ts | 24 + apps/host-selfhost/src/auth/delegation.ts | 44 + apps/host-selfhost/src/auth/identity.ts | 145 +- apps/host-selfhost/src/auth/index.ts | 22 +- .../src/auth/oauth-callback-login.ts | 2 + apps/host-selfhost/src/config.test.ts | 18 + apps/host-selfhost/src/config.ts | 37 + apps/host-selfhost/src/index.ts | 2 +- apps/host-selfhost/src/mcp/auth.ts | 1 + apps/host-selfhost/src/mcp/index.ts | 62 +- apps/host-selfhost/src/mcp/mcp.test.ts | 172 ++ apps/host-selfhost/src/mcp/session-store.ts | 11 +- packages/plugins/toolkits/src/server.test.ts | 6 +- packages/plugins/toolkits/src/server.ts | 6 +- 68 files changed, 10608 insertions(+), 267 deletions(-) create mode 100644 apps/cloud/drizzle/0021_funny_tombstone.sql create mode 100644 apps/cloud/drizzle/0022_mature_exodus.sql create mode 100644 apps/cloud/drizzle/meta/0021_snapshot.json create mode 100644 apps/cloud/drizzle/meta/0022_snapshot.json create mode 100644 apps/cloud/src/account/delegation-provider.ts create mode 100644 apps/cloud/src/auth/delegation.node.test.ts create mode 100644 apps/cloud/src/auth/delegation.ts create mode 100644 apps/cloud/src/auth/session-route-boundary.node.test.ts create mode 100644 apps/host-cloudflare/src/auth/delegation.test.ts create mode 100644 apps/host-cloudflare/src/auth/delegation.ts create mode 100644 apps/host-cloudflare/src/auth/identity.ts create mode 100644 apps/host-selfhost/src/account/delegation-provider.ts create mode 100644 apps/host-selfhost/src/auth/delegated-boundary.ts create mode 100644 apps/host-selfhost/src/auth/delegated-key.ts create mode 100644 apps/host-selfhost/src/auth/delegation.ts create mode 100644 apps/host-selfhost/src/config.test.ts diff --git a/apps/cloud/drizzle/0021_funny_tombstone.sql b/apps/cloud/drizzle/0021_funny_tombstone.sql new file mode 100644 index 0000000000..8364562c7d --- /dev/null +++ b/apps/cloud/drizzle/0021_funny_tombstone.sql @@ -0,0 +1,71 @@ +CREATE TABLE "credential_delegation" ( + "id" varchar(255) NOT NULL, + "label" varchar(255) NOT NULL, + "created_by" varchar(255) NOT NULL, + "credential_id" varchar(255) NOT NULL, + "credential_kind" varchar(255) NOT NULL, + "adapter" varchar(255) NOT NULL, + "issuer" text NOT NULL, + "audience" text NOT NULL, + "credential_subject" text NOT NULL, + "credential_ref" varchar(255) NOT NULL, + "issued_key_ref" varchar(255), + "key_hash" varchar(255), + "actor_subject" varchar(255) NOT NULL, + "effective_subject" varchar(255) NOT NULL, + "toolkit_membership" json NOT NULL, + "scope" json NOT NULL, + "scope_version" varchar(255) NOT NULL, + "expires_at" bigint NOT NULL, + "revoked_at" bigint, + "revoke_reason" text, + "created_at" timestamp NOT NULL, + "last_used_at" bigint, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "delegation_audit" ( + "id" varchar(255) NOT NULL, + "grant_id" varchar(255), + "credential_id" varchar(255), + "actor_subject" varchar(255) NOT NULL, + "effective_subject" varchar(255) NOT NULL, + "action" varchar(255) NOT NULL, + "tool" text, + "outcome" varchar(255) NOT NULL, + "request_id" text, + "execution_id" text, + "occurred_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "delegation_challenge" ( + "id" varchar(255) NOT NULL, + "created_by" varchar(255) NOT NULL, + "label" varchar(255) NOT NULL, + "credential_id" varchar(255), + "credential_kind" varchar(255), + "adapter" varchar(255) NOT NULL, + "issuer" text, + "audience" text, + "credential_subject" text, + "credential_ref" varchar(255), + "actor_subject" varchar(255), + "effective_subject" varchar(255) NOT NULL, + "scope" json NOT NULL, + "proof_hash" varchar(255) NOT NULL, + "expires_at" bigint NOT NULL, + "grant_expires_at" bigint NOT NULL, + "proof_consumed_at" bigint, + "consent_consumed_at" bigint, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "credential_delegation_uidx" ON "credential_delegation" USING btree ("tenant","id");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_delegation_binding_uidx" ON "credential_delegation" USING btree ("tenant","adapter","issuer","audience","credential_subject","credential_ref");--> statement-breakpoint +CREATE UNIQUE INDEX "delegation_audit_uidx" ON "delegation_audit" USING btree ("tenant","id");--> statement-breakpoint +CREATE UNIQUE INDEX "delegation_challenge_uidx" ON "delegation_challenge" USING btree ("tenant","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0022_mature_exodus.sql b/apps/cloud/drizzle/0022_mature_exodus.sql new file mode 100644 index 0000000000..5ea2137886 --- /dev/null +++ b/apps/cloud/drizzle/0022_mature_exodus.sql @@ -0,0 +1,17 @@ +CREATE TABLE "delegation_notification" ( + "id" varchar(255) NOT NULL, + "grant_id" varchar(255) NOT NULL, + "credential_id" varchar(255) NOT NULL, + "created_by" varchar(255) NOT NULL, + "target_subject" varchar(255) NOT NULL, + "label" varchar(255) NOT NULL, + "credential_kind" varchar(255) NOT NULL, + "created_at" timestamp NOT NULL, + "read_at" bigint, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "delegation_notification_uidx" ON "delegation_notification" USING btree ("tenant","id");--> statement-breakpoint +CREATE UNIQUE INDEX "delegation_notification_grant_target_uidx" ON "delegation_notification" USING btree ("tenant","grant_id","target_subject");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_delegation_issued_key_ref_uidx" ON "credential_delegation" USING btree ("tenant","issued_key_ref"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000000..a06c3a7243 --- /dev/null +++ b/apps/cloud/drizzle/meta/0021_snapshot.json @@ -0,0 +1,2258 @@ +{ + "id": "38ab75f8-fd2b-4232-9208-fc33614f0dc2", + "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_delegation": { + "name": "credential_delegation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_kind": { + "name": "credential_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "adapter": { + "name": "adapter", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_subject": { + "name": "credential_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issued_key_ref": { + "name": "issued_key_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "toolkit_membership": { + "name": "toolkit_membership", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "scope_version": { + "name": "scope_version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "revoke_reason": { + "name": "revoke_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "credential_delegation_uidx": { + "name": "credential_delegation_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_delegation_binding_uidx": { + "name": "credential_delegation_binding_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "audience", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delegation_audit": { + "name": "delegation_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "delegation_audit_uidx": { + "name": "delegation_audit_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delegation_challenge": { + "name": "delegation_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credential_kind": { + "name": "credential_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "adapter": { + "name": "adapter", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject": { + "name": "credential_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "proof_hash": { + "name": "proof_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "grant_expires_at": { + "name": "grant_expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "proof_consumed_at": { + "name": "proof_consumed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "consent_consumed_at": { + "name": "consent_consumed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "delegation_challenge_uidx": { + "name": "delegation_challenge_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0022_snapshot.json b/apps/cloud/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000000..8474b8f79c --- /dev/null +++ b/apps/cloud/drizzle/meta/0022_snapshot.json @@ -0,0 +1,2407 @@ +{ + "id": "4b8651b8-ca81-4171-8a41-40613fb2d6af", + "prevId": "38ab75f8-fd2b-4232-9208-fc33614f0dc2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_delegation": { + "name": "credential_delegation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_kind": { + "name": "credential_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "adapter": { + "name": "adapter", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_subject": { + "name": "credential_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issued_key_ref": { + "name": "issued_key_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "toolkit_membership": { + "name": "toolkit_membership", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "scope_version": { + "name": "scope_version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "revoke_reason": { + "name": "revoke_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "credential_delegation_uidx": { + "name": "credential_delegation_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_delegation_binding_uidx": { + "name": "credential_delegation_binding_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "audience", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_delegation_issued_key_ref_uidx": { + "name": "credential_delegation_issued_key_ref_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issued_key_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delegation_audit": { + "name": "delegation_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "delegation_audit_uidx": { + "name": "delegation_audit_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delegation_challenge": { + "name": "delegation_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credential_kind": { + "name": "credential_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "adapter": { + "name": "adapter", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject": { + "name": "credential_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_ref": { + "name": "credential_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "actor_subject": { + "name": "actor_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "effective_subject": { + "name": "effective_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "proof_hash": { + "name": "proof_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "grant_expires_at": { + "name": "grant_expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "proof_consumed_at": { + "name": "proof_consumed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "consent_consumed_at": { + "name": "consent_consumed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "delegation_challenge_uidx": { + "name": "delegation_challenge_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delegation_notification": { + "name": "delegation_notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_subject": { + "name": "target_subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_kind": { + "name": "credential_kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "delegation_notification_uidx": { + "name": "delegation_notification_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delegation_notification_grant_target_uidx": { + "name": "delegation_notification_grant_target_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 73842e4d59..285efd8906 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1792462467039, + "tag": "0021_funny_tombstone", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1792465038054, + "tag": "0022_mature_exodus", + "breakpoints": true } ] } diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 99a609877a..64fddc8b1a 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -5,9 +5,10 @@ import { Clock, Effect, Layer } from "effect"; import { AccountProvider, + DelegationProvider, makeAccountApiLayer, requestScopedMiddleware, - type MemberDirectory, + MemberDirectory, } from "@executor-js/api/server"; import { ApiKeyService } from "../auth/api-keys"; @@ -18,6 +19,7 @@ import { WorkOSClient } from "../auth/workos"; import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; +import { cloudDelegationProvider } from "./delegation-provider"; // --------------------------------------------------------------------------- // Cloud account API — the shared, provider-neutral `AccountHandlers` backed by @@ -116,6 +118,38 @@ export const workosAccountMiddleware = ( rsLive: Layer.Layer, ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; +/** Cloud's delegated account plane. It shares the account request scope but + * keeps a separate provider so ordinary cookie account routes retain their + * existing behavior. The provider is rebuilt per request over that request's + * database socket and WorkOS directory. */ +const DelegationProviderMiddleware = HttpRouter.middleware<{ + provides: DelegationProvider; +}>()( + Effect.gen(function* () { + const longLived = yield* Effect.context(); + return (httpEffect) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + // Build the WorkOS API-key facade in the request fiber, like the + // ordinary account provider, so this seam carries only the shared + // WorkOS boot dependency and does not leak an unbound ApiKeyService + // through ExecutorApp's account residual. + const apiKeys = yield* Effect.provide(ApiKeyService.asEffect(), ApiKeyService.WorkOS); + const db = yield* DbService; + const directory = yield* MemberDirectory; + const provider = yield* Effect.provide( + DelegationProvider.asEffect(), + cloudDelegationProvider({ db, directory, apiKeys, workos }), + ); + return yield* Effect.provideService(httpEffect, DelegationProvider, provider); + }).pipe(Effect.provideContext(longLived)); + }), +); + +export const workosDelegationMiddleware = ( + rsLive: Layer.Layer, +) => DelegationProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; + export const makeAccountApiLive = ( rsLive: Layer.Layer, ) => { diff --git a/apps/cloud/src/account/delegation-provider.ts b/apps/cloud/src/account/delegation-provider.ts new file mode 100644 index 0000000000..b87b7206d2 --- /dev/null +++ b/apps/cloud/src/account/delegation-provider.ts @@ -0,0 +1,527 @@ +import { env } from "cloudflare:workers"; +import { Effect, Layer, Predicate } from "effect"; + +import { + AccountError, + AccountForbidden, + AccountUnauthorized, + type DelegationTarget, +} from "@executor-js/api"; +import { + DbProvider, + makeDelegationProvider, + makeScopedExecutor, + type DelegationAccountHost, + type DelegationHuman, + type DelegationRequestContext, +} from "@executor-js/api/server"; +import type { MemberDirectoryShape } from "@executor-js/api/server"; +import { + collectTables, + ConnectionAddress, + MAX_DELEGATION_TTL_MS, + StorageError, + type DelegationService, + type DelegationScope, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; + +import { ApiKeyService } from "../auth/api-keys"; +import { makeCloudDelegationService, workosDelegatedCredential } from "../auth/delegation"; +import { ADMIN_MFA_COOKIE, readRecentAdminMfaProof } from "../auth/admin-mfa-proof"; +import { sessionFromSealed, type Session } from "../auth/middleware"; +import type { SealedSessionResult } from "../auth/middleware"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { CloudHostConfig, CloudPluginsProvider } from "../engine/execution-stack"; +import type { CloudPlugins } from "../plugins"; +import { createDrizzleFumaDb, CLOUD_NAMESPACE } from "../db/fuma"; +import { DbService } from "../db/db"; + +const issuer = (): string => env.WORKOS_API_URL ?? "https://api.workos.com"; +const publicOrigin = (): string => env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"; + +const boundedMaxTtlMs = (): number => { + const configured = Number(env.DELEGATED_MAX_TTL_MS ?? ""); + if (!Number.isFinite(configured) || configured <= 0) return MAX_DELEGATION_TTL_MS; + return Math.min(Math.floor(configured), MAX_DELEGATION_TTL_MS); +}; + +const issuanceEnabled = (): boolean => env.DELEGATED_ISSUANCE_ENABLED === "true"; + +const recentAuthMaxAgeMs = (): number | null => { + const configured = Number(env.DELEGATED_RECENT_AUTH_MAX_AGE_MS ?? "300000"); + return Number.isSafeInteger(configured) && configured > 0 ? configured : null; +}; + +const bearerValue = (request: DelegationRequestContext): string | null => { + const value = request.headers.authorization; + if (!value || !value.startsWith("Bearer ")) return null; + const token = value.slice("Bearer ".length).trim(); + return token.length === 0 ? null : token; +}; + +const storage = (operation: string, cause: unknown): StorageError => + new StorageError({ message: `Cloud delegation ${operation} failed.`, operation, cause }); + +// WorkOSClient's generated service currently leaves the environment channel of +// this worker-only method open. Normalize it once at the host boundary after +// converting failed cookie verification into an absent session. +const authenticateSealedSession = ( + workos: WorkOSClientService, + cookie: string, +): Effect.Effect<(SealedSessionResult & { readonly sessionId: string }) | null, unknown, never> => + workos.authenticateSealedSession(cookie) as Effect.Effect< + (SealedSessionResult & { readonly sessionId: string }) | null, + unknown, + never + >; + +const targetFor = ( + directory: MemberDirectoryShape, + organizationId: string, + subject: string, + currentSubject: string, +) => + directory.membership(subject, organizationId, ["active"]).pipe( + Effect.map((member): typeof DelegationTarget.Type | null => + member === null + ? null + : { + id: member.accountId, + email: member.email ?? "", + name: member.name, + isCurrentUser: member.accountId === currentSubject, + status: "verified", + }, + ), + Effect.mapError(() => new AccountError({ message: "Failed to read the member directory." })), + ); + +const targetsFor = ( + directory: MemberDirectoryShape, + organizationId: string, + currentSubject: string, +) => + directory.members(organizationId, { statuses: ["active"] }).pipe( + Effect.map((members) => + members.map((member): typeof DelegationTarget.Type => ({ + id: member.accountId, + email: member.email ?? "", + name: member.name, + isCurrentUser: member.accountId === currentSubject, + status: "verified", + })), + ), + Effect.mapError(() => new AccountError({ message: "Failed to read the member directory." })), + ); + +const serviceFor = ( + db: DbService["Service"], + directory: MemberDirectoryShape, + apiKeys: ApiKeyService["Service"], + organizationId: string, + verifyProof?: (input: { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: string; + readonly nonce: string; + }) => Effect.Effect, +): DelegationService => { + const fuma = createDrizzleFumaDb({ + db: db.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + return makeCloudDelegationService({ + db: fuma.db, + directory, + apiKeys, + tenant: organizationId, + issuanceEnabled: issuanceEnabled(), + maxTtlMs: boundedMaxTtlMs(), + verifyProof, + }); +}; + +const humanFor = ( + request: DelegationRequestContext, + workos: WorkOSClientService, + directory: MemberDirectoryShape, +): Effect.Effect< + DelegationHuman & { readonly session: Session }, + AccountError | AccountUnauthorized | AccountForbidden +> => { + const originlessRead = + request.browserOrigin === null && (request.method === "GET" || request.method === "HEAD"); + if ( + (!originlessRead && request.browserOrigin === null) || + (request.browserOrigin !== null && + (request.browserOrigin !== request.requestOrigin || request.browserOrigin !== publicOrigin())) + ) + return Effect.fail( + new AccountForbidden({ + message: "Delegation management requires a same-origin browser request.", + }), + ); + if (!request.headers.cookie || bearerValue(request) !== null) { + return Effect.fail(new AccountUnauthorized()); + } + const cookieValue = request.headers.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith("wos-session=")) + ?.slice("wos-session=".length); + if (!cookieValue) return Effect.fail(new AccountUnauthorized()); + return Effect.gen(function* () { + const resolved = yield* authenticateSealedSession(workos, cookieValue).pipe( + Effect.mapError(() => new AccountUnauthorized()), + Effect.orElseSucceed(() => null), + ); + const organizationId = resolved?.organizationId; + if (resolved === null || !organizationId) return yield* new AccountUnauthorized(); + const maxAgeMs = recentAuthMaxAgeMs(); + if (maxAgeMs === null) + return yield* new AccountForbidden({ message: "Delegation authentication is unavailable." }); + const proof = yield* readRecentAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: resolved.userId, sessionId: resolved.sessionId }, + request.headers.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${ADMIN_MFA_COOKIE}=`)) + ?.slice(`${ADMIN_MFA_COOKIE}=`.length), + Date.now(), + maxAgeMs, + ); + const member = yield* directory + .membership(resolved.userId, organizationId, ["active"]) + .pipe(Effect.mapError(() => new AccountError({ message: "Failed to read membership." }))); + if (member === null) + return yield* new AccountForbidden({ message: "The current user is no longer a member." }); + return { + subject: resolved.userId, + organizationId, + recent: proof !== null, + canFilterTargets: member.role === "admin", + session: sessionFromSealed(resolved, ""), + }; + }); +}; + +const organizationForRequest = ( + request: DelegationRequestContext, + apiKeys: ApiKeyService["Service"], + workos: WorkOSClientService, + directory: MemberDirectoryShape, + human?: DelegationHuman, +) => { + if (human) return Effect.succeed(human.organizationId); + const token = bearerValue(request); + if (token === null) + return humanFor(request, workos, directory).pipe(Effect.map((value) => value.organizationId)); + return apiKeys.validate(token).pipe( + Effect.mapError(() => new AccountUnauthorized()), + Effect.flatMap((owner) => + owner === null || owner.scope !== "user" || owner.organizationId.length === 0 + ? Effect.fail(new AccountUnauthorized()) + : Effect.succeed(owner.organizationId), + ), + ); +}; + +const scopeSnapshot = ( + db: DbService["Service"], + targetSubject: string, + organizationId: string, + organizationName: string, + scope: typeof import("@executor-js/api").DelegationScope.Type, +) => { + const fuma = createDrizzleFumaDb({ + db: db.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + const executorEffect = makeScopedExecutor( + targetSubject, + organizationId, + organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: fuma.db, fuma: fuma.fuma, close: async () => {} }), + CloudPluginsProvider, + CloudHostConfig, + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(scope.toolkitIds, (toolkitId) => { + const toolkit = toolkits.find((item) => item.id === toolkitId); + if (!toolkit) + return Effect.fail(new AccountError({ message: "Delegation toolkit is unavailable." })); + return executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe( + Effect.flatMap((snapshot) => + snapshot === null + ? Effect.fail(new AccountError({ message: "Delegation toolkit is unavailable." })) + : Effect.succeed(snapshot), + ), + ); + }); + const connections = + scope.connectionIds ?? + snapshots.flatMap((snapshot) => snapshot.connectionAddresses.map(String)); + return { + actions: scope.scopes, + toolkitMembership: snapshots, + toolAddresses: snapshots.flatMap((snapshot) => snapshot.toolAddresses), + ...(scope.connectionIds === undefined + ? {} + : { connectionAddresses: connections.map((value) => ConnectionAddress.make(value)) }), + } satisfies DelegationScope; + }).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "AccountError") + ? cause + : new AccountError({ message: "Failed to build the delegated scope snapshot." }), + ), + ); +}; + +/** Build the same target-scoped catalog used to freeze a grant, but project + * only stable IDs and display metadata for the human consent form. */ +const scopeOptions = ( + db: DbService["Service"], + targetSubject: string, + organizationId: string, + organizationName: string, +) => { + const fuma = createDrizzleFumaDb({ + db: db.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + const executorEffect = makeScopedExecutor( + targetSubject, + organizationId, + organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: fuma.db, fuma: fuma.fuma, close: async () => {} }), + CloudPluginsProvider, + CloudHostConfig, + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(toolkits, (toolkit) => + executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe(Effect.map((snapshot) => ({ toolkit, snapshot }))), + ); + const activeSnapshots = snapshots.filter( + (entry): entry is typeof entry & { readonly snapshot: NonNullable } => + entry.snapshot !== null, + ); + const allConnections = yield* executor.connections.list(); + const addressToToolkits = new Map(); + for (const { toolkit, snapshot } of activeSnapshots) { + for (const address of snapshot.connectionAddresses) { + const id = String(address); + const toolkitIds = addressToToolkits.get(id) ?? []; + toolkitIds.push(String(toolkit.id)); + addressToToolkits.set(id, toolkitIds); + } + } + const connections = allConnections + .filter((connection) => addressToToolkits.has(String(connection.address))) + .map((connection) => ({ + id: String(connection.address), + label: + connection.identityLabel ?? + `${String(connection.integration)} · ${String(connection.name)}`, + description: connection.description ?? null, + toolkitIds: addressToToolkits.get(String(connection.address)) ?? [], + })); + return { + actions: ["discover", "invoke"] as const, + toolkits: activeSnapshots.map(({ toolkit }) => ({ + id: String(toolkit.id), + slug: String(toolkit.slug), + name: String(toolkit.name), + })), + connections, + maxExpiresAt: new Date(Date.now() + boundedMaxTtlMs()).toISOString(), + }; + }).pipe( + Effect.mapError( + () => new AccountError({ message: "Failed to read delegation scope choices." }), + ), + ); +}; + +export interface CloudDelegationHostDependencies { + readonly db: DbService["Service"]; + readonly directory: MemberDirectoryShape; + readonly apiKeys: ApiKeyService["Service"]; + readonly workos: WorkOSClient["Service"]; +} + +export const cloudDelegationProvider = (deps: CloudDelegationHostDependencies) => { + const host: DelegationAccountHost = { + human: (request) => humanFor(request, deps.workos, deps.directory), + service: (request, human) => + organizationForRequest(request, deps.apiKeys, deps.workos, deps.directory, human).pipe( + Effect.map((organizationId) => + serviceFor( + deps.db, + deps.directory, + deps.apiKeys, + organizationId, + ({ credential, machineActor }) => { + const token = bearerValue(request); + if (token === null) return Effect.succeed(false); + return deps.apiKeys.validate(token).pipe( + Effect.map((owner) => { + const expected = owner ? workosDelegatedCredential(owner, issuer()) : null; + return ( + owner?.scope === "user" && + expected?.credentialRef === credential.credentialRef && + expected.subject === credential.subject && + `machine:${owner.keyId}` === machineActor + ); + }), + Effect.mapError((cause) => storage("delegation.proof", cause)), + ); + }, + ), + ), + ), + targets: (request) => + humanFor(request, deps.workos, deps.directory).pipe( + Effect.flatMap((human) => targetsFor(deps.directory, human.organizationId, human.subject)), + ), + target: (request, subject) => + organizationForRequest(request, deps.apiKeys, deps.workos, deps.directory).pipe( + Effect.flatMap((organizationId) => + targetFor(deps.directory, organizationId, subject, subject), + ), + ), + scope: (request, targetSubject, scope) => + humanFor(request, deps.workos, deps.directory).pipe( + Effect.flatMap((human) => + scopeSnapshot(deps.db, targetSubject, human.organizationId, "", scope), + ), + ), + scopeOptions: (request, targetSubject) => + humanFor(request, deps.workos, deps.directory).pipe( + Effect.flatMap((human) => scopeOptions(deps.db, targetSubject, human.organizationId, "")), + ), + proof: (request, adapter) => { + const token = bearerValue(request); + if (token === null) return Effect.fail(new AccountUnauthorized()); + return deps.apiKeys.validate(token).pipe( + Effect.mapError(() => new AccountUnauthorized()), + Effect.flatMap((owner) => { + const credential = owner ? workosDelegatedCredential(owner, issuer()) : null; + return credential === null || owner === null + ? Effect.fail(new AccountUnauthorized()) + : Effect.succeed({ + credential: { ...credential, adapter }, + machineActor: `machine:${owner.keyId}`, + }); + }), + ); + }, + providerKeys: { + issue: (request, input) => + Effect.gen(function* () { + const organizationId = yield* organizationForRequest( + request, + deps.apiKeys, + deps.workos, + deps.directory, + ); + const created = yield* deps.apiKeys + .createUserKey({ accountId: input.targetSubject, organizationId, name: input.label }) + .pipe( + Effect.mapError( + () => new AccountError({ message: "Failed to issue delegated key." }), + ), + ); + return { + credential: { + kind: "issued-key" as const, + adapter: "workos-api-key", + issuer: issuer(), + audience: organizationId, + subject: input.targetSubject, + credentialRef: created.id, + }, + actorSubject: `machine:${created.id}`, + secret: created.value, + }; + }), + bind: () => Effect.void, + rotate: (request, input) => + Effect.gen(function* () { + const organizationId = yield* organizationForRequest( + request, + deps.apiKeys, + deps.workos, + deps.directory, + ); + const created = yield* deps.apiKeys + .createUserKey({ accountId: input.targetSubject, organizationId, name: input.label }) + .pipe( + Effect.mapError( + () => new AccountError({ message: "Failed to rotate delegated key." }), + ), + ); + return { + credential: { + kind: "issued-key" as const, + adapter: "workos-api-key", + issuer: issuer(), + audience: organizationId, + subject: input.targetSubject, + credentialRef: created.id, + }, + actorSubject: `machine:${created.id}`, + secret: created.value, + }; + }), + revokeBound: (request, input) => + Effect.gen(function* () { + yield* organizationForRequest(request, deps.apiKeys, deps.workos, deps.directory); + if (!input.credentialRef) return; + yield* deps.apiKeys + .revokeUserKey({ keyId: input.credentialRef }) + .pipe( + Effect.mapError( + () => new AccountError({ message: "Failed to revoke delegated key." }), + ), + ); + }), + revoke: (_request, input) => + deps.apiKeys + .revokeUserKey({ keyId: input.credential.credentialRef }) + .pipe( + Effect.mapError(() => new AccountError({ message: "Failed to revoke delegated key." })), + ), + }, + externalAdapters: ["workos-api-key"], + localKeysEnabled: issuanceEnabled(), + issuanceEnabled: issuanceEnabled(), + }; + return makeDelegationProvider(host); +}; diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index aac518b0f6..7858ea13b0 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -3,11 +3,11 @@ import { Effect, Layer } from "effect"; import { MemberDirectory } from "@executor-js/api/server"; -import { ApiKeyService } from "../auth/api-keys"; +import { ApiKeyService, ApiKeyValidationError } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; -import { resolveProtectedPrincipal } from "./protected"; +import { resolveProtectedPrincipal, resolveProtectedPrincipalWithDelegation } from "./protected"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -44,6 +44,40 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), }); +const racingApiKeys = (second: "missing" | "outage" | "changed") => + Layer.succeed(ApiKeyService)({ + validate: (() => { + let calls = 0; + return () => { + calls += 1; + if (calls === 1) { + return Effect.succeed({ + scope: "user" as const, + accountId: "user_123", + organizationId: "org_123", + keyId: "api_key_123", + }); + } + if (second === "missing") return Effect.succeed(null); + if (second === "changed") { + return Effect.succeed({ + scope: "user" as const, + accountId: "different_user", + organizationId: "org_123", + keyId: "api_key_other", + }); + } + return Effect.fail(new ApiKeyValidationError({ cause: "test outage" })); + }; + })(), + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.die("protected API auth test does not create API keys"), + revokeUserKey: () => Effect.void, + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), + }); + const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { @@ -133,6 +167,11 @@ const run = (request: Request) => Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), ); +const runDelegated = (request: Request, apiKeys: Layer.Layer) => + resolveProtectedPrincipalWithDelegation(request, null).pipe( + Effect.provide(Layer.mergeAll(apiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), + ); + describe("protected API key auth", () => { it.effect("resolves a valid bearer API key into protected identity", () => Effect.gen(function* () { @@ -180,4 +219,67 @@ describe("protected API key auth", () => { }); }), ); + + it.effect("does not promote a key that disappears between validation reads", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + runDelegated( + new Request("https://executor.test/api/tools", { + headers: { + authorization: "Bearer racing_key", + cookie: "wos-session=owner-session", + }, + }), + racingApiKeys("missing"), + ), + ); + + expect(error).toMatchObject({ + _tag: "Unauthorized", + code: "invalid_api_key", + }); + }), + ); + + it.effect("does not promote a key when the second validation is unavailable", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + runDelegated( + new Request("https://executor.test/api/tools", { + headers: { + authorization: "Bearer racing_key", + cookie: "wos-session=owner-session", + }, + }), + racingApiKeys("outage"), + ), + ); + + expect(error).toMatchObject({ + _tag: "Unavailable", + code: "api_key_validation_unavailable", + }); + }), + ); + + it.effect("does not mix the subject from two validation reads", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + runDelegated( + new Request("https://executor.test/api/tools", { + headers: { + authorization: "Bearer racing_key", + cookie: "wos-session=owner-session", + }, + }), + racingApiKeys("changed"), + ), + ); + + expect(error).toMatchObject({ + _tag: "Unauthorized", + code: "invalid_api_key", + }); + }), + ); }); diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index ac97dd5850..9f5a88dfdb 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -2,15 +2,20 @@ // middleware that resolves identity, builds the executor/engine, and provides // `AuthContext` + the execution-stack services to handlers. -import { Effect, Layer } from "effect"; +import { Context, Effect, Layer, Option } from "effect"; +import { env } from "cloudflare:workers"; import { + collectTables, + DelegatedExecutionResumer as DelegatedExecutionResumerTag, IdentityProvider, + makeExecutionStack, makeExecutionStackMiddleware, requestScopedMiddleware, RouterConfigLive, type IdentityFailure, - type MemberDirectory, + MemberDirectory, + type DelegatedExecutionResumerShape, } from "@executor-js/api/server"; import { cloudPlugins, type CloudPlugins } from "../plugins"; @@ -22,14 +27,105 @@ import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; import { CoreSharedServices } from "../auth/workos"; import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered"; +import { CLOUD_NAMESPACE, createDrizzleFumaDb } from "../db/fuma"; +import { makeCloudDelegationService } from "../auth/delegation"; +import { Subject } from "@executor-js/sdk"; import { ProtectedCloudApiLive, RequestScopedServicesLive } from "./layers"; +/** Request-local handles used by the durable approval resumer. Keeping them + * behind one cloud-local service gives the request-scoped resumer layer one + * exact request dependency to satisfy while retaining the database scope. */ +class DelegationResumerScope extends Context.Service< + DelegationResumerScope, + { readonly db: DbService["Service"]; readonly directory: MemberDirectory["Service"] } +>()("@executor-js/cloud/DelegationResumerScope") {} + +/** + * Durable delegated approvals run on a fresh request engine. The approver's + * ordinary engine is intentionally never reused: resolveBinding reconstructs + * the immutable grant, the audit actor is the verified human, and the rebuilt + * engine carries the original delegation context through dispatch. + * + * Resumer creation is a request-scoped layer below. Missing optional boot + * services produce the null reference value, so legacy compositions fail + * closed without borrowing an approver's ordinary engine. + */ +const makeCloudDelegatedResumer = (input: { + readonly db: DbService["Service"]; + readonly directory: MemberDirectory["Service"]; + readonly apiKeys: ApiKeyService["Service"]; + readonly autumn: AutumnService["Service"]; +}): DelegatedExecutionResumerShape => { + const fuma = createDrizzleFumaDb({ + db: input.db.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + const serviceFor = (tenant: string) => + makeCloudDelegationService({ + db: fuma.db, + tenant, + directory: input.directory, + apiKeys: input.apiKeys, + issuanceEnabled: env.DELEGATED_ISSUANCE_ENABLED === "true", + }); + return { + authorize: (request) => + Effect.gen(function* () { + const service = serviceFor(String(request.binding.tenant)); + const target = yield* input.directory.membership( + String(request.approver.accountId), + String(request.binding.tenant), + ["active"], + ); + if (!target) return false; + if ( + String(request.approver.accountId) !== String(request.binding.effectiveSubject) && + target.role !== "admin" + ) + return false; + yield* service.resolveBinding(request.binding); + return true; + }), + resume: (request) => + Effect.gen(function* () { + if (request.approver.accountId === null) return null; + const service = serviceFor(String(request.binding.tenant)); + const grant = yield* service.resolveBinding(request.binding); + yield* service.audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: Subject.make(request.approver.accountId), + effectiveSubject: grant.effectiveSubject, + action: "approval", + outcome: "allowed", + executionId: request.executionId, + }); + const stack = yield* makeExecutionStack( + String(grant.effectiveSubject), + String(grant.tenant), + String(grant.tenant), + { delegation: grant }, + ).pipe( + Effect.provide(CloudMeteredExecutionStackLayer), + Effect.provideService(DbService, input.db), + Effect.provideService(AutumnService, input.autumn), + ); + return yield* stack.engine + .executeWithPause(request.code, { autoApprove: true }) + .pipe(Effect.ensuring(stack.engine.shutdown)); + }), + }; +}; + // Re-exported for `protected-api-key-auth.node.test.ts`, which asserts the // per-path principal + error codes the folded resolver still produces. export { resolveApiKeyPrincipal, resolveSessionPrincipal, resolveProtectedPrincipal, + resolveProtectedPrincipalWithDelegation, } from "../auth/workos-auth-provider"; // One `HttpRouter` middleware that: @@ -117,11 +213,44 @@ export const makeProtectedApiLive = ( // factory so tests can swap in a counting fake — see // `apps/cloud/src/api.request-scope.node.test.ts`. const requestScopedLive = rsLive.pipe(Layer.provideMerge(identityLive)); + const resumerScopeLive = Layer.effect( + DelegationResumerScope, + Effect.gen(function* () { + return { + db: yield* DbService, + directory: yield* MemberDirectory, + }; + }), + ); + const requestScopedWithResumerScope = Layer.merge( + requestScopedLive, + resumerScopeLive.pipe(Layer.provide(requestScopedLive)), + ); + const delegatedResumerLive = Layer.effect( + DelegatedExecutionResumerTag, + Effect.gen(function* () { + const apiKeys = yield* Effect.serviceOption(ApiKeyService); + const autumn = yield* Effect.serviceOption(AutumnService); + const scope = yield* Effect.serviceOption(DelegationResumerScope); + if (Option.isNone(scope) || Option.isNone(apiKeys) || Option.isNone(autumn)) return null; + return makeCloudDelegatedResumer({ + db: scope.value.db, + directory: scope.value.directory, + apiKeys: apiKeys.value, + autumn: autumn.value, + }); + }), + ); + const requestScopedWithResumer = Layer.merge( + requestScopedWithResumerScope, + delegatedResumerLive.pipe(Layer.provide(requestScopedWithResumerScope)), + ); const protectedMiddleware = ExecutionStackMiddleware.combine( - requestScopedMiddleware(requestScopedLive), - ).layer; + requestScopedMiddleware(requestScopedWithResumer), + ); + const protectedApiMiddleware = protectedMiddleware.layer; return ProtectedCloudApiLive.pipe( - Layer.provide(protectedMiddleware), + Layer.provide(protectedApiMiddleware), Layer.provideMerge(AutumnService.Default), Layer.provideMerge(RouterConfigLive), ); diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 5851158f26..faff94aa21 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -24,6 +24,20 @@ import { } from "./layers"; import { makeProtectedApiLive } from "./protected"; +// Dynamic plugin handler layers intentionally erase their concrete request +// marker union at this composition boundary. HttpRouter supplies those +// markers while building each route; the web handler itself therefore has no +// caller context requirement. +type CloudApiLayer = Layer.Layer< + never, + unknown, + | HttpRouter.HttpRouter + | HttpRouter.Request<"Requires", never> + | HttpRouter.Request<"GlobalRequires", never> + | HttpRouter.Request<"Error", never> + | HttpRouter.Request<"GlobalError", never> +>; + // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy // is added as a plain `HttpRouter.add` route. They all merge into the same @@ -37,11 +51,11 @@ import { makeProtectedApiLive } from "./protected"; // `apps/cloud/src/api.request-scope.node.test.ts`. export const makeApiLive = ( requestScopedLive: Layer.Layer, -) => { +): CloudApiLayer => { const BillingRoutesLive = AutumnRoutesLive.pipe( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), ); - return Layer.mergeAll( + const appLayer = Layer.mergeAll( AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)), makeNonProtectedApiLive(requestScopedLive), makeOrgApiLive(requestScopedLive), @@ -51,6 +65,7 @@ export const makeApiLive = ( BillingRoutesLive, ApiErrorLoggingLive, ).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(BootSharedServices)); + return appLayer as CloudApiLayer; }; export const ApiLive = makeApiLive(RequestScopedServicesLive); diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts index 19b0213ead..8b7269228f 100644 --- a/apps/cloud/src/app.ts +++ b/apps/cloud/src/app.ts @@ -8,7 +8,7 @@ import { CoreSharedServices } from "./auth/workos"; import { makeCloudExtensionRoutes } from "./extensions/routes"; import { RequestScopedServicesLive } from "./api/layers"; import { CloudMeteringEngineDecorator } from "./engine/execution-stack-metered"; -import { workosAccountMiddleware } from "./account/account-api"; +import { workosAccountMiddleware, workosDelegationMiddleware } from "./account/account-api"; import { ApiKeyService } from "./auth/api-keys"; import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; import { DbService } from "./db/db"; @@ -82,6 +82,7 @@ const { appLayer, toWebHandler } = ExecutorApp.make({ // service closes over the per-request postgres socket; carries the Autumn // seat-gate. Self-combines `requestScopedMiddleware`. account: workosAccountMiddleware(RequestScopedServicesLive), + delegation: workosDelegationMiddleware(RequestScopedServicesLive), db: cloudDb, engine: { codeExecutor: CloudCodeExecutorProvider, diff --git a/apps/cloud/src/auth/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts index 96fa7a8f03..c22263cd1d 100644 --- a/apps/cloud/src/auth/admin-mfa-proof.test.ts +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { SignJWT } from "jose"; -import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; +import { readAdminMfaProof, readRecentAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; const secret = "a-test-only-cookie-password-of-32-characters"; const identity = { userId: "user_test", sessionId: "session_test" }; @@ -109,6 +109,27 @@ describe("admin verification cookie", () => { }), ); + it.effect("distinguishes fresh delegation assurance from a still-valid unlock", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 7 * 86400 }, + now, + ); + expect( + yield* readRecentAdminMfaProof(secret, identity, token, now + 4 * 60_000, 5 * 60_000), + ).not.toBeNull(); + expect( + yield* readRecentAdminMfaProof(secret, identity, token, now + 6 * 60_000, 5 * 60_000), + ).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 6 * 60_000), + ).not.toBeNull(); + }), + ); + it.effect("caps token age even when the supplied expiration is longer", () => Effect.gen(function* () { const token = yield* signAdminMfaProof( diff --git a/apps/cloud/src/auth/admin-mfa-proof.ts b/apps/cloud/src/auth/admin-mfa-proof.ts index 33f06f8d24..3217cecad6 100644 --- a/apps/cloud/src/auth/admin-mfa-proof.ts +++ b/apps/cloud/src/auth/admin-mfa-proof.ts @@ -59,7 +59,18 @@ export const readAdminMfaProof = ( purpose: Purpose, token: string | undefined, now: number, -) => { +): Effect.Effect => + readAdminMfaProofDetails(secret, identity, purpose, token, now).pipe( + Effect.map((details) => details?.proof ?? null), + ); + +const readAdminMfaProofDetails = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + token: string | undefined, + now: number, +): Effect.Effect<{ readonly proof: typeof Proof.Type; readonly issuedAt: number } | null> => { if (!token) return Effect.succeed(null); return Effect.tryPromise({ try: () => @@ -82,8 +93,30 @@ export const readAdminMfaProof = ( payload.exp > payload.iat + maxAge ) return null; - return Option.getOrNull(decodeProof(payload)); + const proof = Option.getOrNull(decodeProof(payload)); + return proof === null ? null : { proof, issuedAt: payload.iat * 1000 }; }), Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)), ); }; + +/** + * A verified MFA cookie remains useful for the administrative unlock flow for + * seven days, but delegation issuance and expansion require a fresh assurance + * event. The age check uses the signed `iat` from the MFA proof itself; a + * session-cookie timestamp is not an authentication event. + */ +export const readRecentAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + token: string | undefined, + now: number, + maxAgeMs: number, +): Effect.Effect => + readAdminMfaProofDetails(secret, identity, "verified", token, now).pipe( + Effect.map((details) => + details !== null && details.issuedAt <= now && now - details.issuedAt <= maxAgeMs + ? details.proof + : null, + ), + ); diff --git a/apps/cloud/src/auth/delegation.node.test.ts b/apps/cloud/src/auth/delegation.node.test.ts new file mode 100644 index 0000000000..ab9afa4092 --- /dev/null +++ b/apps/cloud/src/auth/delegation.node.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + collectTables, + Subject, + type DelegationService, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; +import type { MemberDirectoryShape } from "@executor-js/api/server"; + +import { makeCloudDelegationService, workosDelegatedCredential } from "./delegation"; + +const TENANT = "cloud-delegation-test"; +const HUMAN = "user-target"; +const KEY_ID = "key_delegated"; + +const directory: MemberDirectoryShape = { + membership: (accountId, organizationId, statuses) => + Effect.succeed( + accountId === HUMAN && + organizationId === TENANT && + (statuses === undefined || statuses.includes("active")) + ? { + accountId, + membershipId: "membership-target", + organizationId, + email: "target@example.test", + name: "Target", + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.succeed(null), + membershipsOf: () => Effect.succeed([]), + members: () => Effect.succeed([]), + membersById: () => Effect.succeed(new Map()), + findByEmail: () => Effect.succeed(null), +}; + +const apiKeys = { + listUserKeys: ({ accountId, organizationId }: { accountId: string; organizationId: string }) => + Effect.succeed(accountId === HUMAN && organizationId === TENANT ? [{ id: KEY_ID }] : []), +}; + +const credential: VerifiedDelegatedCredential = { + kind: "issued-key", + adapter: "workos-api-key", + issuer: "https://api.workos.com", + audience: TENANT, + subject: HUMAN, + credentialRef: KEY_ID, +}; + +const externalCredential: VerifiedDelegatedCredential = { + ...credential, + kind: "external", +}; + +const withDb = (body: (db: SqliteTestFumaDb) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + body, + (db) => Effect.promise(() => db.close()), + ); + +const makeCloudService = (db: SqliteTestFumaDb): DelegationService => + makeCloudDelegationService({ + db: db.db, + tenant: TENANT, + directory, + apiKeys, + issuanceEnabled: true, + verifyProviderCredential: ({ credential: candidate, effectiveSubject }) => + Effect.succeed( + candidate.kind === credential.kind && + candidate.adapter === credential.adapter && + candidate.credentialRef === credential.credentialRef && + String(effectiveSubject) === HUMAN, + ), + verifyProof: ({ credential: candidate, machineActor }) => + Effect.succeed( + candidate.credentialRef === KEY_ID && String(machineActor) === `machine:${KEY_ID}`, + ), + }); + +describe("cloud delegation authority", () => { + it.effect("uses the exact issued-key tuple and blocks a revoked binding", () => + withDb((db) => + Effect.gen(function* () { + const service = makeCloudService(db); + const registered = yield* service.registerProviderIssuedKey({ + createdBy: Subject.make(HUMAN), + effectiveSubject: Subject.make(HUMAN), + actorSubject: Subject.make(`machine:${KEY_ID}`), + label: "Cloud machine", + scope: { + actions: ["discover", "invoke"], + toolkitMembership: [], + toolAddresses: [], + }, + credential, + }); + const resolved = yield* service.resolve( + workosDelegatedCredential( + { scope: "user", accountId: HUMAN, organizationId: TENANT, keyId: KEY_ID }, + credential.issuer, + )!, + ); + expect(resolved.grantId).toBe(registered.grantId); + yield* service.revoke({ + grantId: registered.grantId, + actorSubject: Subject.make(HUMAN), + }); + const denied = yield* service.resolve(credential).pipe(Effect.flip); + expect(denied).toMatchObject({ _tag: "DelegationDeniedError", reason: "revoked" }); + }), + ), + ); + + it.effect("leaves ordinary WorkOS keys unmapped", () => + withDb((db) => + Effect.gen(function* () { + const service = makeCloudService(db); + const denied = yield* service + .resolve({ ...credential, credentialRef: "key_ordinary" }) + .pipe(Effect.flip); + expect(denied).toMatchObject({ reason: "unmapped" }); + }), + ), + ); + + it.effect("resolves a proof-bound ordinary WorkOS key and revokes it live", () => + withDb((db) => + Effect.gen(function* () { + const service = makeCloudService(db); + const challenge = yield* service.issueProofChallenge({ + adapter: "workos-api-key", + createdBy: Subject.make(HUMAN), + effectiveSubject: Subject.make(HUMAN), + label: "Existing WorkOS PAT", + scope: { + actions: ["discover", "invoke"], + toolkitMembership: [], + toolAddresses: [], + }, + }); + const completed = yield* service.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: externalCredential, + machineActor: Subject.make(`machine:${KEY_ID}`), + nonce: challenge.nonce, + }); + const grant = yield* service.create({ + proofChallengeId: completed.challengeId, + consentedBy: Subject.make(HUMAN), + }); + const resolved = yield* service.resolve(externalCredential); + expect(resolved.grantId).toBe(grant.grantId); + yield* service.revalidate(resolved, { operation: "invoke" }); + yield* service.revoke({ + grantId: grant.grantId, + actorSubject: Subject.make(HUMAN), + }); + const denied = yield* service.resolve(externalCredential).pipe(Effect.flip); + expect(denied).toMatchObject({ _tag: "DelegationDeniedError", reason: "revoked" }); + }), + ), + ); +}); diff --git a/apps/cloud/src/auth/delegation.ts b/apps/cloud/src/auth/delegation.ts new file mode 100644 index 0000000000..760b2b8895 --- /dev/null +++ b/apps/cloud/src/auth/delegation.ts @@ -0,0 +1,184 @@ +import { Effect, Predicate } from "effect"; + +import { type DirectoryMember, type MemberDirectoryShape } from "@executor-js/api/server"; +import { + makeDelegationService, + StorageError, + Subject, + Tenant, + type DelegationService, + type DelegationGrant, + type FumaDb, + type StorageFailure, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; + +import type { ApiKeyOwner } from "./api-keys"; + +type ApiKeys = { + readonly listUserKeys: (input: { + readonly accountId: string; + readonly organizationId: string; + }) => Effect.Effect; +}; + +const storageFailure = (operation: string, cause: unknown): StorageError => + new StorageError({ message: `Delegation authority ${operation} failed.`, operation, cause }); + +const mapStorage = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.mapError((cause) => storageFailure("delegation.authority", cause))); + +const activeMember = ( + directory: MemberDirectoryShape, + accountId: string, + organizationId: string, +): Effect.Effect => + mapStorage(directory.membership(accountId, organizationId, ["active"])); + +const keyIsBoundToTarget = ( + apiKeys: ApiKeys, + credential: VerifiedDelegatedCredential, + effectiveSubject: string, + organizationId: string, +): Effect.Effect => + mapStorage(apiKeys.listUserKeys({ accountId: effectiveSubject, organizationId })).pipe( + Effect.map((keys) => keys.some((key) => key.id === credential.credentialRef)), + ); + +const machineKeyId = (subject: string): string | null => + subject.startsWith("machine:") ? subject.slice("machine:".length) : null; + +export interface CloudDelegationServiceOptions { + readonly db: FumaDb; + readonly tenant: string; + readonly directory: MemberDirectoryShape; + readonly apiKeys: ApiKeys; + readonly issuanceEnabled?: boolean; + readonly maxTtlMs?: number; + readonly verifyProof?: (input: { + readonly challengeId: string; + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: string; + readonly nonce: string; + }) => Effect.Effect; + readonly verifyProviderCredential?: (input: { + readonly credential: VerifiedDelegatedCredential; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }) => Effect.Effect; +} + +/** + * The WorkOS authority used by both the bearer resolver and account API. + * WorkOS only supplies a stable API-key id after validation; the SDK stores + * that id as the credential reference and re-checks the key owner on every + * delegated request. The bearer secret never crosses this boundary. + */ +export const makeCloudDelegationService = ( + options: CloudDelegationServiceOptions, +): DelegationService => + makeDelegationService({ + db: options.db, + tenant: Tenant.make(options.tenant), + issuanceEnabled: options.issuanceEnabled, + maxTtlMs: options.maxTtlMs, + isVerifiedHuman: (subject) => + activeMember(options.directory, String(subject), options.tenant).pipe( + Effect.map((member) => member !== null), + ), + isCurrentlyAuthorized: (grant) => + Effect.gen(function* () { + const target = yield* activeMember( + options.directory, + String(grant.effectiveSubject), + options.tenant, + ); + if (!target) return false; + const keyId = machineKeyId(String(grant.actorSubject)); + if (!keyId) return false; + return yield* keyIsBoundToTarget( + options.apiKeys, + { + kind: "issued-key", + adapter: "workos-api-key", + issuer: "workos", + audience: options.tenant, + subject: String(grant.effectiveSubject), + credentialRef: keyId, + }, + String(grant.effectiveSubject), + options.tenant, + ); + }), + isManagementAuthorized: (actorSubject, targetSubject) => + Effect.gen(function* () { + const actor = yield* activeMember(options.directory, String(actorSubject), options.tenant); + if (!actor) return false; + if (String(targetSubject ?? actorSubject) === String(actorSubject)) return true; + return actor.role === "admin"; + }), + // The bearer resolver uses `resolve`, while the account adapter supplies a + // request-bound proof callback for challenges. Keeping this callback + // conservative prevents a nonce-only proof from becoming authorization. + verifyProof: (input) => + options.verifyProof?.({ + challengeId: input.challengeId, + credential: input.credential, + machineActor: String(input.machineActor), + nonce: input.nonce, + }) ?? Effect.succeed(false), + verifyProviderCredential: ({ credential, effectiveSubject, actorSubject }) => + options.verifyProviderCredential === undefined + ? credential.adapter !== "workos-api-key" + ? Effect.succeed(false) + : keyIsBoundToTarget( + options.apiKeys, + credential, + String(effectiveSubject), + options.tenant, + ) + : options.verifyProviderCredential({ credential, actorSubject, effectiveSubject }), + }); + +/** Stable tuple for a validated WorkOS user API key. */ +export const workosDelegatedCredential = ( + owner: ApiKeyOwner, + issuer: string, +): VerifiedDelegatedCredential | null => + owner.scope === "user" && owner.accountId !== null + ? { + kind: "issued-key", + adapter: "workos-api-key", + issuer, + audience: owner.organizationId, + subject: owner.accountId, + credentialRef: owner.keyId, + } + : null; + +export const isDelegationUnmapped = (error: unknown): boolean => + Predicate.isTagged(error, "DelegationDeniedError") && + Predicate.hasProperty("reason")(error) && + error.reason === "unmapped"; + +export type CloudDelegationPrincipal = { + readonly kind: "delegated"; + readonly accountId: string; + readonly organizationId: string; + readonly organizationName: string; + readonly organizationSlug?: string; + readonly email: ""; + readonly name: null; + readonly avatarUrl: null; + readonly roles: readonly []; + readonly orgRoleModel: "organization"; + readonly orgRole: "member"; + readonly tenant: string; + readonly grantId: string; + readonly credentialId: string; + readonly credentialRef: string; + readonly actorSubject: string; + readonly effectiveSubject: string; + readonly scopeVersion: string; + readonly delegation: DelegationGrant; +}; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index e6857d0aba..ee957d52fe 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -833,6 +833,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( stub.getPausedExecutionForApproval(params.executionId, { accountId: owner.accountId, organizationId: owner.organizationId, + orgRole: owner.memberRole, }), ); diff --git a/apps/cloud/src/auth/session-route-boundary.node.test.ts b/apps/cloud/src/auth/session-route-boundary.node.test.ts new file mode 100644 index 0000000000..00fa2afcb3 --- /dev/null +++ b/apps/cloud/src/auth/session-route-boundary.node.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; + +import { SessionAuth } from "./middleware"; +import { SessionAuthLive } from "./middleware-live"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +/** + * The raw Cloud session route is cookie-authenticated. This keeps the bearer + * machine credential plane from reaching an owner session handler, where a + * delegated key could otherwise inherit account/admin authority. The counter + * is deliberately behind the actual HTTP handler so this asserts the route + * rejects before any management side effect. + */ +class RawCloudAuthApi extends HttpApiGroup.make("cloudAuth") + .add( + HttpApiEndpoint.get("me", "/auth/me", { + success: Schema.String, + }), + ) + .middleware(SessionAuth) {} + +const RawCloudApi = HttpApi.make("cloudWeb").add(RawCloudAuthApi); + +const RawCloudAuthHandlers = HttpApiBuilder.group(RawCloudApi, "cloudAuth", (handlers) => + handlers.handle("me", () => + Effect.sync(() => { + sideEffects += 1; + return "owner-session"; + }), + ), +); + +let sideEffects = 0; +let sessionLookups = 0; +const sessionInputs: string[] = []; + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, property) => { + if (property === "authenticateSealedSession") { + return (credential: string) => + Effect.sync(() => { + sessionLookups += 1; + sessionInputs.push(credential); + return null; + }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(property)} call`); + }, + }), +); + +const app = HttpRouter.toWebHandler( + HttpApiBuilder.layer(RawCloudApi).pipe( + Layer.provide(RawCloudAuthHandlers), + Layer.provide(SessionAuthLive), + Layer.provide(stubWorkOS), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, +); + +describe("Cloud raw session management boundary", () => { + it("rejects a delegated bearer before the owner handler side effect", async () => { + sideEffects = 0; + sessionLookups = 0; + sessionInputs.length = 0; + + const response = await app.handler( + new Request("https://executor.test/auth/me", { + headers: { authorization: "Bearer delegated-bound-key" }, + }), + ); + + expect(response.status).toBe(401); + expect(sideEffects).toBe(0); + expect(sessionLookups).toBe(1); + expect(sessionInputs).toEqual([""]); + }); +}); diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 7165a93042..0371102c4f 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -32,7 +32,8 @@ // forced a forked tag before). // --------------------------------------------------------------------------- -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; +import { env } from "cloudflare:workers"; import { HttpServerResponse } from "effect/unstable/http"; import type { JWTVerifyGetKey } from "jose"; @@ -66,6 +67,14 @@ import { UserStoreService } from "./context"; import { sealedSessionDisplayName } from "./middleware"; import type { UserStoreError, WorkOSError, WorkOsMirrorError } from "./errors"; import { WorkOSClient } from "./workos"; +import { + isDelegationUnmapped, + makeCloudDelegationService, + workosDelegatedCredential, +} from "./delegation"; +import { collectTables } from "@executor-js/sdk"; +import { createDrizzleFumaDb, CLOUD_NAMESPACE } from "../db/fuma"; +import { DbService } from "../db/db"; import { verifyWorkosUserManagementToken } from "../mcp/jwt"; /** @@ -390,6 +399,106 @@ export const resolveProtectedPrincipal = ( return yield* resolveSessionPrincipal(request); }); +/** Resolve a validated WorkOS user key to a live delegated grant when one is + * registered. Ordinary user keys retain the existing member path; a grant + * denial other than the explicit unmapped case fails closed rather than + * silently downgrading a revoked binding to ordinary member access. */ +export const resolveProtectedPrincipalWithDelegation = ( + request: Request, + jwt: JwtBearerConfig | null, +) => + Effect.gen(function* () { + const principal = yield* resolveProtectedPrincipal(request, jwt); + if (principal.kind !== "member") return principal; + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith(BEARER_PREFIX)) return principal; + const value = authHeader.slice(BEARER_PREFIX.length).trim(); + if (!value || looksLikeJwt(value)) return principal; + + const apiKeys = yield* ApiKeyService; + const owner = yield* apiKeys.validate(value).pipe( + // The first resolver already accepted this explicit bearer. A second + // validation is still required to obtain the provider key reference for + // grant resolution, but a race or provider outage must fail closed. It + // must never fall through to the ordinary cookie-derived member. + Effect.catchTag("ApiKeyValidationError", () => + Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + ), + ); + if ( + owner === null || + owner.scope !== "user" || + owner.accountId === null || + owner.accountId !== principal.accountId || + owner.organizationId !== principal.organizationId + ) { + return yield* new Unauthorized(INVALID_API_KEY); + } + const credential = workosDelegatedCredential( + owner, + env.WORKOS_API_URL ?? "https://api.workos.com", + ); + if (credential === null) return principal; + const db = yield* Effect.serviceOption(DbService); + if (Option.isNone(db)) { + return yield* new Unavailable(API_KEY_VALIDATION_UNAVAILABLE); + } + const directory = yield* MemberDirectory; + const fuma = createDrizzleFumaDb({ + db: db.value.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + const service = makeCloudDelegationService({ + db: fuma.db, + tenant: owner.organizationId, + directory, + apiKeys, + issuanceEnabled: env.DELEGATED_ISSUANCE_ENABLED === "true", + }); + const grant = yield* service.resolve(credential).pipe( + Effect.catchTag("DelegationDeniedError", (error) => + isDelegationUnmapped(error) + ? Effect.succeed(null) + : Effect.fail(new Unauthorized(INVALID_API_KEY)), + ), + Effect.catchTag("StorageError", () => + Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + ), + Effect.catchTags({ + CredentialWriteIncompleteError: () => + Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + StorageConnectionError: () => Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + UniqueViolationError: () => Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + }), + ); + if (grant === null) return principal; + return { + kind: "delegated", + accountId: String(grant.effectiveSubject), + organizationId: principal.organizationId, + organizationName: principal.organizationName, + ...(principal.organizationSlug === undefined + ? {} + : { organizationSlug: principal.organizationSlug }), + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: String(grant.tenant), + grantId: String(grant.grantId), + credentialId: String(grant.credentialId), + credentialRef: grant.credentialRef, + actorSubject: String(grant.actorSubject), + effectiveSubject: String(grant.effectiveSubject), + scopeVersion: String(grant.scopeVersion), + delegation: grant, + } satisfies Principal; + }); + /** * Cloud's NEUTRAL `IdentityProvider` Layer. Closes over the long-lived * `WorkOSClient` + `ApiKeyService`; the request-scoped `UserStoreService` + @@ -403,16 +512,16 @@ export const resolveProtectedPrincipal = ( export const workosIdentityLayer: Layer.Layer< IdentityProvider, never, - WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror | DbService > = Layer.effect( IdentityProvider, Effect.gen(function* () { const context = yield* Effect.context< - WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror | DbService >(); return IdentityProvider.of({ authenticate: (request) => - resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( + resolveProtectedPrincipalWithDelegation(request, workosApiJwtBearerConfig).pipe( // `UserStoreError` / `WorkOSError` are org-resolution infra failures — // surface as a 500 defect, exactly as the old inline resolver let them // bubble. The narrow `die` here is the runtime edge for that infra diff --git a/apps/cloud/src/db/db.schema.test.ts b/apps/cloud/src/db/db.schema.test.ts index 8c8647a82c..fa9076c35b 100644 --- a/apps/cloud/src/db/db.schema.test.ts +++ b/apps/cloud/src/db/db.schema.test.ts @@ -14,7 +14,13 @@ // surfaces them all under `db._.fullSchema`. import { describe, expect, it } from "@effect/vitest"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { PGlite } from "@electric-sql/pglite"; import { drizzle } from "drizzle-orm/postgres-js"; +import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; import { Effect } from "effect"; import postgres from "postgres"; @@ -124,4 +130,75 @@ describe("combinedSchema", () => { ), ), ); + + it("upgrades an existing database without losing connection or OAuth rows", async () => { + const migrationSource = resolve(__dirname, "../../drizzle"); + const oldMigrationFolder = await mkdtemp(join(tmpdir(), "executor-cloud-old-migrations-")); + const database = await PGlite.create(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: migration fixtures require unconditional cleanup + try { + await cp(migrationSource, oldMigrationFolder, { recursive: true }); + const journalPath = join(oldMigrationFolder, "meta/_journal.json"); + // oxlint-disable-next-line executor/no-json-parse -- test boundary: Drizzle journal fixture is trusted local JSON + const journal = JSON.parse(await readFile(journalPath, "utf8")) as { + readonly entries: ReadonlyArray<{ readonly tag: string }>; + }; + await writeFile( + journalPath, + JSON.stringify({ + ...journal, + entries: journal.entries.filter( + (entry) => entry.tag !== "0021_funny_tombstone" && entry.tag !== "0022_mature_exodus", + ), + }), + ); + await Promise.all( + [ + "0021_funny_tombstone.sql", + "0022_mature_exodus.sql", + "meta/0021_snapshot.json", + "meta/0022_snapshot.json", + ].map((file) => rm(join(oldMigrationFolder, file))), + ); + + await migrate(drizzlePglite(database), { migrationsFolder: oldMigrationFolder }); + await database.query( + `INSERT INTO connection + (integration, name, template, provider, item_ids, created_at, updated_at, + row_id, tenant, owner, subject) + VALUES ('github', 'existing', 'token', 'vault', '{}', NOW(), NOW(), + 'connection_existing', 'tenant_existing', 'owner_existing', 'subject_existing')`, + ); + await database.query( + `INSERT INTO oauth_client + (slug, authorization_url, token_url, "grant", client_id, created_at, + row_id, tenant, owner, subject) + VALUES ('existing-pat', 'https://auth.example/authorize', + 'https://auth.example/token', 'authorization_code', 'client_existing', NOW(), + 'oauth_existing', 'tenant_existing', 'owner_existing', 'subject_existing')`, + ); + + await migrate(drizzlePglite(database), { migrationsFolder: migrationSource }); + const rows = await database.query<{ + readonly connection: string; + readonly oauth: string; + readonly notification: string; + readonly keyRefIndex: string; + }>(`SELECT + (SELECT name FROM connection WHERE row_id = 'connection_existing') AS connection, + (SELECT slug FROM oauth_client WHERE row_id = 'oauth_existing') AS oauth, + (SELECT to_regclass('public.delegation_notification')) AS notification, + (SELECT indexname FROM pg_indexes + WHERE indexname = 'credential_delegation_issued_key_ref_uidx') AS "keyRefIndex"`); + expect(rows.rows[0]).toEqual({ + connection: "existing", + oauth: "existing-pat", + notification: "delegation_notification", + keyRefIndex: "credential_delegation_issued_key_ref_uidx", + }); + } finally { + await database.close(); + await rm(oldMigrationFolder, { recursive: true, force: true }); + } + }, 30_000); }); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..c921fb4fab 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -49,6 +49,133 @@ export const subject = pgTable( (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], ); +export const credential_delegation = pgTable( + "credential_delegation", + { + id: varchar("id", { length: 255 }).notNull(), + label: varchar("label", { length: 255 }).notNull(), + created_by: varchar("created_by", { length: 255 }).notNull(), + credential_id: varchar("credential_id", { length: 255 }).notNull(), + credential_kind: varchar("credential_kind", { length: 255 }).notNull(), + adapter: varchar("adapter", { length: 255 }).notNull(), + issuer: text("issuer").notNull(), + audience: text("audience").notNull(), + credential_subject: text("credential_subject").notNull(), + credential_ref: varchar("credential_ref", { length: 255 }).notNull(), + issued_key_ref: varchar("issued_key_ref", { length: 255 }), + key_hash: varchar("key_hash", { length: 255 }), + actor_subject: varchar("actor_subject", { length: 255 }).notNull(), + effective_subject: varchar("effective_subject", { length: 255 }).notNull(), + toolkit_membership: json("toolkit_membership").notNull(), + scope: json("scope").notNull(), + scope_version: varchar("scope_version", { length: 255 }).notNull(), + expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), + revoked_at: bigint("revoked_at", { mode: "bigint" }), + revoke_reason: text("revoke_reason"), + created_at: timestamp("created_at").notNull(), + last_used_at: bigint("last_used_at", { mode: "bigint" }), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("credential_delegation_uidx").on(table.tenant, table.id), + uniqueIndex("credential_delegation_binding_uidx").on( + table.tenant, + table.adapter, + table.issuer, + table.audience, + table.credential_subject, + table.credential_ref, + ), + uniqueIndex("credential_delegation_issued_key_ref_uidx").on(table.tenant, table.issued_key_ref), + ], +); + +export const delegation_challenge = pgTable( + "delegation_challenge", + { + id: varchar("id", { length: 255 }).notNull(), + created_by: varchar("created_by", { length: 255 }).notNull(), + label: varchar("label", { length: 255 }).notNull(), + credential_id: varchar("credential_id", { length: 255 }), + credential_kind: varchar("credential_kind", { length: 255 }), + adapter: varchar("adapter", { length: 255 }).notNull(), + issuer: text("issuer"), + audience: text("audience"), + credential_subject: text("credential_subject"), + credential_ref: varchar("credential_ref", { length: 255 }), + actor_subject: varchar("actor_subject", { length: 255 }), + effective_subject: varchar("effective_subject", { length: 255 }).notNull(), + scope: json("scope").notNull(), + proof_hash: varchar("proof_hash", { length: 255 }).notNull(), + expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), + grant_expires_at: bigint("grant_expires_at", { mode: "bigint" }).notNull(), + proof_consumed_at: bigint("proof_consumed_at", { mode: "bigint" }), + consent_consumed_at: bigint("consent_consumed_at", { mode: "bigint" }), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("delegation_challenge_uidx").on(table.tenant, table.id)], +); + +export const delegation_audit = pgTable( + "delegation_audit", + { + id: varchar("id", { length: 255 }).notNull(), + grant_id: varchar("grant_id", { length: 255 }), + credential_id: varchar("credential_id", { length: 255 }), + actor_subject: varchar("actor_subject", { length: 255 }).notNull(), + effective_subject: varchar("effective_subject", { length: 255 }).notNull(), + action: varchar("action", { length: 255 }).notNull(), + tool: text("tool"), + outcome: varchar("outcome", { length: 255 }).notNull(), + request_id: text("request_id"), + execution_id: text("execution_id"), + occurred_at: timestamp("occurred_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("delegation_audit_uidx").on(table.tenant, table.id)], +); + +export const delegation_notification = pgTable( + "delegation_notification", + { + id: varchar("id", { length: 255 }).notNull(), + grant_id: varchar("grant_id", { length: 255 }).notNull(), + credential_id: varchar("credential_id", { length: 255 }).notNull(), + created_by: varchar("created_by", { length: 255 }).notNull(), + target_subject: varchar("target_subject", { length: 255 }).notNull(), + label: varchar("label", { length: 255 }).notNull(), + credential_kind: varchar("credential_kind", { length: 255 }).notNull(), + created_at: timestamp("created_at").notNull(), + read_at: bigint("read_at", { mode: "bigint" }), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("delegation_notification_uidx").on(table.tenant, table.id), + uniqueIndex("delegation_notification_grant_target_uidx").on( + table.tenant, + table.grant_id, + table.target_subject, + ), + ], +); + export const connection = pgTable( "connection", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index a7268e2764..af9958a2c4 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -32,6 +32,10 @@ import { artifact, blob, connection, + credential_delegation, + delegation_audit, + delegation_challenge, + delegation_notification, definition, integration, oauth_client, @@ -159,6 +163,60 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(credential_delegation).values({ + id: `grant-${tag}`, + label: "seed grant", + created_by: "user-owner", + credential_id: `credential-${tag}`, + credential_kind: "external", + adapter: "test", + issuer: "https://issuer.test", + audience: tenant, + credential_subject: "user-owner", + credential_ref: `ref-${tag}`, + actor_subject: "machine:test", + effective_subject: "user-owner", + toolkit_membership: [], + scope: { actions: [], toolkitMembership: [], toolAddresses: [] }, + scope_version: "1", + expires_at: BigInt(now.getTime() + 60_000), + created_at: now, + tenant, + }); + await db.insert(delegation_challenge).values({ + id: `challenge-${tag}`, + created_by: "user-owner", + label: "seed challenge", + adapter: "test", + effective_subject: "user-owner", + scope: { actions: [], toolkitMembership: [], toolAddresses: [] }, + proof_hash: `hash-${tag}`, + expires_at: BigInt(now.getTime() + 60_000), + grant_expires_at: BigInt(now.getTime() + 60_000), + created_at: now, + tenant, + }); + await db.insert(delegation_audit).values({ + id: `audit-${tag}`, + actor_subject: "user-owner", + effective_subject: "user-owner", + action: "created", + outcome: "created", + occurred_at: now, + tenant, + }); + await db.insert(delegation_notification).values({ + id: `notice-${tag}`, + grant_id: `grant-${tag}`, + credential_id: `credential-${tag}`, + created_by: "user-owner", + target_subject: "user-owner", + label: "seed grant", + credential_kind: "external", + created_at: now, + tenant, + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -186,6 +244,10 @@ const TENANT_TABLES = [ plugin_storage, subject, artifact, + credential_delegation, + delegation_challenge, + delegation_audit, + delegation_notification, ] as const; // Tables that are NOT purged by org id, each with the reason it is exempt. Any diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index abcff12f3b..512453e874 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -28,6 +28,10 @@ import { memberships, organizations } from "./schema"; import { artifact, blob, + credential_delegation, + delegation_audit, + delegation_challenge, + delegation_notification, connection, definition, integration, @@ -68,6 +72,12 @@ export const purgeOrganizationData = ( await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); + await tx.delete(credential_delegation).where(eq(credential_delegation.tenant, organizationId)); + await tx.delete(delegation_challenge).where(eq(delegation_challenge.tenant, organizationId)); + await tx.delete(delegation_audit).where(eq(delegation_audit.tenant, organizationId)); + await tx + .delete(delegation_notification) + .where(eq(delegation_notification.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 3e1fb0d8c1..812b47d3b3 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -155,6 +155,12 @@ declare global { // Shared with frontend VITE_PUBLIC_SITE_URL?: string; + /** Delegated credentials stay disabled unless explicitly enabled. */ + DELEGATED_ISSUANCE_ENABLED?: string; + /** Optional delegated grant cap in milliseconds, bounded by the SDK's 90-day maximum. */ + DELEGATED_MAX_TTL_MS?: string; + /** Maximum age of the verified human management proof, in milliseconds. */ + DELEGATED_RECENT_AUTH_MAX_AGE_MS?: string; VITE_PUBLIC_OTLP_TRACES_URL?: string; VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string; } diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index d10233953a..cc0d278d4e 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -175,6 +175,18 @@ const propsForPrincipal = ( ): Effect.Effect => Effect.gen(function* () { const propagation = yield* currentPropagationHeaders(request); + const delegation = + principal.kind === "delegated" + ? { + tenant: principal.tenant, + grantId: principal.grantId, + credentialId: principal.credentialId, + credentialRef: principal.credentialRef, + actorSubject: principal.actorSubject, + effectiveSubject: principal.effectiveSubject, + scopeVersion: principal.scopeVersion, + } + : undefined; return { session: { organizationId: principal.organizationId, @@ -193,6 +205,7 @@ const propsForPrincipal = ( toolMode: readToolMode(request), resource, webOrigin: new URL(request.url).origin, + ...(delegation === undefined ? {} : { delegation }), }, propagation, }; @@ -266,6 +279,19 @@ export const makeCloudMcpAgentHandler = () => { { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, + ...(outcome.principal.kind === "delegated" + ? { + delegation: { + tenant: outcome.principal.tenant, + grantId: outcome.principal.grantId, + credentialId: outcome.principal.credentialId, + credentialRef: outcome.principal.credentialRef, + actorSubject: outcome.principal.actorSubject, + effectiveSubject: outcome.principal.effectiveSubject, + scopeVersion: outcome.principal.scopeVersion, + }, + } + : {}), }, resource, ); @@ -305,6 +331,19 @@ export const makeCloudMcpAgentHandler = () => { { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, + ...(outcome.principal.kind === "delegated" + ? { + delegation: { + tenant: outcome.principal.tenant, + grantId: outcome.principal.grantId, + credentialId: outcome.principal.credentialId, + credentialRef: outcome.principal.credentialRef, + actorSubject: outcome.principal.actorSubject, + effectiveSubject: outcome.principal.effectiveSubject, + scopeVersion: outcome.principal.scopeVersion, + }, + } + : {}), }, resource, ), diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 1a88793d69..8c01c861a4 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -23,12 +23,21 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer, Predicate } from "effect"; import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; +import { + CredentialId, + DelegationId, + DelegationScopeVersion, + Subject, + Tenant, + type DelegationGrant, +} from "@executor-js/sdk"; import { WorkOSError } from "../auth/errors"; import { cloudMcpAuthProviderLayer } from "./auth-provider"; import { MCP_ORGANIZATION_HEADER, McpAuth, + McpDelegationAuth, McpOrganizationAuth, mcpAuthorized, mcpUnauthorized, @@ -59,6 +68,32 @@ const stubAuthMissingBearer = Layer.succeed(McpAuth)({ verifyBearer: () => Effect.succeed(mcpUnauthorized("missing_bearer")), }); +const stubDelegationAuth = Layer.succeed(McpDelegationAuth)({ + resolve: () => Effect.succeed(null), +}); + +const delegatedGrant: DelegationGrant = { + kind: "delegated", + tenant: Tenant.make(ORG_ID), + grantId: DelegationId.make("grant_test"), + credentialId: CredentialId.make("credential_test"), + credentialRef: "key_test", + actorSubject: Subject.make("user_machine-owner"), + effectiveSubject: Subject.make(ACCOUNT_ID), + scopeVersion: DelegationScopeVersion.make("1"), + expiresAt: Date.now() + 60_000, + scope: { + actions: ["discover", "invoke"], + toolkitMembership: [], + toolAddresses: [], + }, + revalidate: () => Effect.void, +}; + +const stubDelegatedAuth = Layer.succeed(McpDelegationAuth)({ + resolve: () => Effect.succeed(delegatedGrant), +}); + // `authorize` failing with the REAL `WorkOSError` the service boundary mints: // with `status` when WorkOS answered (its SDK exceptions all carry one), without // when the failure never reached WorkOS (network error / timeout). @@ -87,12 +122,15 @@ class StubInfraError extends Data.TaggedError("StubInfraError")<{ const authenticateWith = ( orgAuth: Layer.Layer, auth: Layer.Layer = stubAuth, + delegation: Layer.Layer = stubDelegationAuth, ) => Effect.gen(function* () { const provider = yield* McpAuthProvider; return yield* provider.authenticate(request()); }).pipe( - Effect.provide(cloudMcpAuthProviderLayer.pipe(Layer.provide(Layer.mergeAll(auth, orgAuth)))), + Effect.provide( + cloudMcpAuthProviderLayer.pipe(Layer.provide(Layer.mergeAll(auth, orgAuth, delegation))), + ), ); describe("cloud MCP org-authorization classification", () => { @@ -166,15 +204,44 @@ describe("cloud MCP org-authorization classification", () => { expect(principal?.organizationId).toBe(ORG_ID); expect(principal?.orgRoleModel).toBe("organization"); expect(principal?.orgRole, "the live membership role reaches the MCP session").toBe("admin"); - const legacyAccess = principal - ? orgWriteAccessForPrincipal( - (({ orgRole: _orgRole, ...legacyMissingRole }) => legacyMissingRole)(principal), - ) - : null; + const legacyAccess = + principal?.kind === "member" + ? orgWriteAccessForPrincipal( + (({ orgRole: _orgRole, ...legacyMissingRole }) => legacyMissingRole)(principal), + ) + : null; expect(legacyAccess).toBe("denied"); }), ); + it.effect("mapped API key -> delegated principal with member-only claims", () => + Effect.gen(function* () { + const outcome = yield* authenticateWith( + stubOrgAuthActive, + Layer.succeed(McpAuth)({ + verifyBearer: () => + Effect.succeed( + mcpAuthorized({ + accountId: ACCOUNT_ID, + organizationId: ORG_ID, + apiKeyId: "key_test", + }), + ), + }), + stubDelegatedAuth, + ); + expect(Predicate.isTagged(outcome, "Authenticated")).toBe(true); + if (!Predicate.isTagged(outcome, "Authenticated")) return; + if (outcome.principal.kind !== "delegated") return; + expect(outcome.principal.kind).toBe("delegated"); + expect(outcome.principal.accountId).toBe(ACCOUNT_ID); + expect(outcome.principal.orgRole).toBe("member"); + expect(outcome.principal.roles).toEqual([]); + expect(outcome.principal.email).toBe(""); + expect(outcome.principal.grantId).toBe("grant_test"); + }), + ); + it.effect("missing bearer still short-circuits to Unauthorized", () => Effect.gen(function* () { const outcome = yield* authenticateWith(stubOrgAuthActive, stubAuthMissingBearer); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index b05685af58..bbf67cb109 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -48,6 +48,7 @@ import { import { ApiKeyService } from "../auth/api-keys"; import { isDefinitiveWorkOSDenial } from "../auth/errors"; import { CoreSharedServices } from "../auth/workos"; +import { isDelegationUnmapped } from "../auth/delegation"; import { bearerChallengeFor, mcpOrganizationFromRequest, @@ -56,6 +57,8 @@ import { toolkitSlugFromRequest, McpAuth, McpAuthLive, + McpDelegationAuth, + McpDelegationAuthLive, McpOrganizationAuth, McpOrganizationAuthLive, type AuthorizedMcpOrganization, @@ -107,28 +110,54 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = const principalFromToken = ( token: VerifiedToken, organization: AuthorizedMcpOrganization, + delegation: import("@executor-js/sdk").DelegationGrant | null, ): Principal => ({ - accountId: token.accountId, - organizationId: organization.id, - organizationName: organization.name, - ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), - orgRoleModel: "organization", - orgRole: organization.memberRole, - email: "", - name: null, - avatarUrl: null, - roles: [], + ...(delegation === null + ? { + kind: "member" as const, + accountId: token.accountId, + organizationId: organization.id, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + orgRoleModel: "organization" as const, + orgRole: organization.memberRole, + email: "", + name: null, + avatarUrl: null, + roles: [], + } + : { + kind: "delegated" as const, + accountId: String(delegation.effectiveSubject), + organizationId: organization.id, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + orgRoleModel: "organization" as const, + orgRole: "member" as const, + email: "" as const, + name: null, + avatarUrl: null, + roles: [] as const, + tenant: String(delegation.tenant), + grantId: String(delegation.grantId), + credentialId: String(delegation.credentialId), + credentialRef: delegation.credentialRef, + actorSubject: String(delegation.actorSubject), + effectiveSubject: String(delegation.effectiveSubject), + scopeVersion: String(delegation.scopeVersion), + }), }); export const cloudMcpAuthProviderLayer: Layer.Layer< McpAuthProvider, never, - McpAuth | McpOrganizationAuth + McpAuth | McpOrganizationAuth | McpDelegationAuth > = Layer.effect( McpAuthProvider, Effect.gen(function* () { const auth = yield* McpAuth; const orgAuth = yield* McpOrganizationAuth; + const delegationAuth = yield* McpDelegationAuth; const discoveryRoutes: ReadonlyArray = [ { @@ -242,7 +271,17 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< // session. const organization = authorizeResult.success; if (!organization) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); - return authenticated(principalFromToken(token, organization)); + const delegationResult = yield* delegationAuth.resolve(token).pipe(Effect.result); + if (Result.isFailure(delegationResult)) { + if ( + Predicate.isTagged(delegationResult.failure, "DelegationDeniedError") && + !isDelegationUnmapped(delegationResult.failure) + ) { + return forbidden("Delegated credential is no longer authorized", -32001); + } + return unavailable(ORGANIZATION_AUTHORIZE_UNAVAILABLE); + } + return authenticated(principalFromToken(token, organization, delegationResult.success)); }); const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect => { @@ -318,6 +357,7 @@ export const cloudMcpAuth: Layer.Layer = cloudMcpAuthProviderLa Layer.mergeAll( McpAuthLive.pipe(Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)))), McpOrganizationAuthLive, + McpDelegationAuthLive, ), ), // A boot-time WorkOS misconfiguration (the `WorkOSClient.Default` config error) diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 9d34df7c57..1d81b3b0d2 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -11,6 +11,11 @@ import { env } from "cloudflare:workers"; import { Context, Effect, Layer, Predicate } from "effect"; +import { + collectTables, + type DelegationGrant, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; import { ApiKeyService } from "../auth/api-keys"; @@ -18,9 +23,12 @@ import { BEARER_PREFIX } from "../auth/bearer"; import { authorizeOrganization } from "../auth/organization"; import { UserStoreService, makeUserStoreLayer } from "../auth/context"; import { makeMemberDirectoryLayer } from "../auth/member-directory"; +import { MemberDirectory } from "@executor-js/api/server"; import { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; import { CoreSharedServices } from "../auth/workos"; -import { makeDbLayer } from "../db/db"; +import { DbService, makeDbLayer } from "../db/db"; +import { CLOUD_NAMESPACE, createDrizzleFumaDb } from "../db/fuma"; +import { isDelegationUnmapped, makeCloudDelegationService } from "../auth/delegation"; import { bearerChallenge } from "./responses"; import { McpJwtVerificationError, verifyWorkOSMcpAccessToken, type VerifiedToken } from "./jwt"; @@ -192,6 +200,19 @@ export class McpOrganizationAuth extends Context.Service< } >()("@executor-js/cloud/McpOrganizationAuth") {} +/** + * Request-scoped delegation lookup for MCP API keys. The bearer verifier has + * already authenticated the provider key and supplies only its stable id; + * this seam resolves the full grant against the authoritative database and + * current key ownership before the session is created or reused. + */ +export class McpDelegationAuth extends Context.Service< + McpDelegationAuth, + { + readonly resolve: (token: VerifiedToken) => Effect.Effect; + } +>()("@executor-js/cloud/McpDelegationAuth") {} + const verifyJwt = (token: string) => verifyWorkOSMcpAccessToken(token, jwks, { issuer: AUTHKIT_DOMAIN, @@ -302,6 +323,7 @@ export const McpAuthLive = Layer.effect( return mcpAuthorized({ accountId: principal.accountId, organizationId: principal.organizationId, + apiKeyId: principal.keyId, }); }); @@ -355,3 +377,64 @@ export const McpAuthLive = Layer.effect( }; }), ); + +/** Build the live MCP delegation resolver over the request-scoped mirror and + * API-key verifier. A provider key which has no grant remains ordinary MCP + * member compatibility; every mapped revoked/expired/storage failure is + * surfaced to the auth provider for a fail-closed outcome. */ +export const McpDelegationAuthLive = Layer.succeed(McpDelegationAuth)({ + resolve: (token) => { + const apiKeyId = token.apiKeyId; + const organizationId = token.organizationId; + if (!apiKeyId || !organizationId) return Effect.succeed(null); + return Effect.gen(function* () { + const db = yield* DbService; + const directory = yield* MemberDirectory; + const apiKeys = yield* ApiKeyService; + const fuma = createDrizzleFumaDb({ + db: db.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + const credential: VerifiedDelegatedCredential = { + kind: "issued-key", + adapter: "workos-api-key", + issuer: env.WORKOS_API_URL ?? "https://api.workos.com", + audience: organizationId, + subject: token.accountId, + credentialRef: apiKeyId, + }; + const service = makeCloudDelegationService({ + db: fuma.db, + tenant: organizationId, + directory, + apiKeys, + issuanceEnabled: env.DELEGATED_ISSUANCE_ENABLED === "true", + }); + // Provider-issued keys use the stronger `issued-key` descriptor. An + // existing WorkOS PAT can also be proof-bound as an `external` + // credential; try that descriptor only when the issued-key lookup did + // not identify a usable issued binding. Revoked/expired mapped rows are + // returned immediately and therefore cannot fall through to ordinary + // member compatibility. + return yield* service.resolve(credential).pipe( + Effect.catchTag("DelegationDeniedError", (error) => + error.reason === "unmapped" || error.reason === "invalid" + ? service.resolve({ ...credential, kind: "external" }) + : Effect.fail(error), + ), + Effect.catchTag("DelegationDeniedError", (error) => + isDelegationUnmapped(error) ? Effect.succeed(null) : Effect.fail(error), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)), + makeMcpOrganizationAuthServices(), + ), + ), + ); + }, +}); diff --git a/apps/cloud/src/mcp/jwt.ts b/apps/cloud/src/mcp/jwt.ts index 93ab7449de..9175c2903d 100644 --- a/apps/cloud/src/mcp/jwt.ts +++ b/apps/cloud/src/mcp/jwt.ts @@ -33,6 +33,8 @@ export type VerifiedToken = { accountId: string; /** The WorkOS organization ID, if the session has org context. */ organizationId: string | null; + /** Stable provider key id when this token was a verified API key. */ + apiKeyId?: string; }; export class McpJwtVerificationError extends Data.TaggedError("McpJwtVerificationError")<{ diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 81d9b50d4e..feaadacfd7 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -30,7 +30,9 @@ import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, + McpDelegationUnavailableError, type BuiltMcpServer, + type McpApprovalPrincipal, type IncomingTraceHeaders, type McpApprovalOwner, type McpModelResumeCaller, @@ -49,6 +51,18 @@ import { parseIntegrationInventory, type ResumeResponse, } from "@executor-js/execution"; +import { + collectTables, + CredentialId, + DelegationId, + DelegationScopeVersion, + Subject, + Tenant, + type DelegationBinding, + type DelegationCapability, + type DelegationService, +} from "@executor-js/sdk"; +import type { McpDelegationBinding } from "@executor-js/cloudflare/mcp/do-headers"; import { acquireBuildSlot, type BuildSlotHandle } from "./session-build-semaphore"; // The DO meters executions just like the HTTP `/api/*` plane: it builds its @@ -64,6 +78,10 @@ import { acquireBuildSlot, type BuildSlotHandle } from "./session-build-semaphor // `SessionAuthLive` instead.) import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; +import { ApiKeyService } from "../auth/api-keys"; +import { makeMemberDirectoryLayer } from "../auth/member-directory"; +import { MemberDirectory } from "@executor-js/api/server"; +import { makeCloudDelegationService } from "../auth/delegation"; import { resolveSessionMetaForToken } from "./session-meta"; import { DbService, @@ -72,6 +90,7 @@ import { type DrizzleDb, type DbServiceShape, } from "../db/db"; +import { CLOUD_NAMESPACE, createDrizzleFumaDb } from "../db/fuma"; import { AutumnService } from "../extensions/billing/service"; import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry"; import { @@ -157,7 +176,14 @@ const makeEphemeralDb = (): CloudSessionDbHandle => const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { const DbLive = Layer.succeed(DbService)({ sql: dbHandle.sql, db: dbHandle.db }); const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); - return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); + const MemberDirectoryLive = makeMemberDirectoryLayer().pipe(Layer.provide(DbLive)); + const ApiKeyLive = ApiKeyService.WorkOS.pipe( + Layer.provide(CoreSharedServices), + Layer.catchCause(() => + Layer.effect(ApiKeyService)(Effect.fail(new McpDelegationUnavailableError())), + ), + ); + return Layer.mergeAll(DbLive, UserStoreLive, MemberDirectoryLive, ApiKeyLive, CoreSharedServices); }; // The `ui://executor/shell.html` resource, over the ASSETS binding: the @@ -258,6 +284,113 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + return Effect.gen(function* () { + const directory = yield* MemberDirectory; + const apiKeys = yield* ApiKeyService; + const fuma = createDrizzleFumaDb({ + db: dbHandle.db, + tables: collectTables(), + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + return makeCloudDelegationService({ + db: fuma.db, + tenant, + directory, + apiKeys, + issuanceEnabled: env.DELEGATED_ISSUANCE_ENABLED === "true", + }); + }); + } + + private resolveDelegation( + binding: McpDelegationBinding, + dbHandle: CloudSessionDbHandle, + ): Effect.Effect< + import("@executor-js/sdk").DelegationGrant, + unknown, + MemberDirectory | ApiKeyService + > { + return this.delegationService(dbHandle, binding.tenant).pipe( + Effect.flatMap((service) => service.resolveBinding(this.delegationBinding(binding))), + ); + } + + protected override revalidateDelegation( + binding: McpDelegationBinding, + capability: DelegationCapability, + ): Effect.Effect { + return Effect.acquireUseRelease( + Effect.try({ + try: () => this.openSessionDb(), + catch: () => new McpDelegationUnavailableError(), + }), + (dbHandle) => + this.resolveDelegation(binding, dbHandle).pipe( + Effect.flatMap((grant) => + this.delegationService(dbHandle, binding.tenant).pipe( + Effect.flatMap((service) => service.revalidate(grant, capability)), + ), + ), + Effect.provide(makeSessionServices(dbHandle)), + Effect.mapError(() => new McpDelegationUnavailableError()), + ), + (dbHandle) => Effect.promise(() => dbHandle.end()).pipe(Effect.ignore), + ); + } + + protected override auditBrowserApproval( + binding: McpDelegationBinding | undefined, + approver: McpApprovalPrincipal, + executionId: string, + response: ResumeResponse, + ): Effect.Effect { + if (binding === undefined) return Effect.void; + return Effect.acquireUseRelease( + Effect.try({ + try: () => this.openSessionDb(), + catch: () => new McpDelegationUnavailableError(), + }), + (dbHandle) => + this.resolveDelegation(binding, dbHandle).pipe( + Effect.flatMap((grant) => + this.delegationService(dbHandle, binding.tenant).pipe( + Effect.flatMap((service) => + service.audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: Subject.make(approver.accountId), + effectiveSubject: grant.effectiveSubject, + action: "approval", + outcome: response.action === "accept" ? "allowed" : "denied", + executionId, + }), + ), + ), + ), + Effect.provide(makeSessionServices(dbHandle)), + Effect.mapError(() => new McpDelegationUnavailableError()), + ), + (dbHandle) => Effect.promise(() => dbHandle.end()).pipe(Effect.ignore), + ); + } + protected override resolveSessionMeta( token: McpSessionInit, storedMeta: SessionMeta | null, @@ -344,6 +477,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase, ); + const delegation = + sessionMeta.delegation === undefined + ? undefined + : yield* self.resolveDelegation(sessionMeta.delegation, dbHandle); const { executor, engine } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, @@ -351,6 +488,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase new AccountError({ message }); + +const requestFor = (request: DelegationRequestContext): Request => + new Request(`${request.requestOrigin}/api/account`, { + headers: new Headers(request.headers), + }); + +const sameOrigin = (request: DelegationRequestContext): boolean => + (request.browserOrigin === null && (request.method === "GET" || request.method === "HEAD")) || + (request.browserOrigin !== null && request.browserOrigin === request.requestOrigin); + +const cloudflareTarget = ( + subject: string, + human: DelegationHuman, + email: string, + name: string | null, + status: "verified" | "removed" = "verified", +) => ({ + id: subject, + email, + name, + isCurrentUser: subject === human.subject, + status, +}); + +const scopeSnapshot = ( + dbHandle: ExecutorDbHandle, + config: CloudflareConfig, + targetSubject: string, + scope: typeof import("@executor-js/api").DelegationScope.Type, +) => { + const executorEffect = makeScopedExecutor( + targetSubject, + config.organizationId, + config.organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: dbHandle.db, fuma: dbHandle.fuma, close: dbHandle.close }), + makeCloudflarePluginsProvider(config), + makeCloudflareHostConfig(config), + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(scope.toolkitIds, (toolkitId) => { + const toolkit = toolkits.find((item) => item.id === toolkitId); + if (!toolkit) return Effect.fail(delegationFailure("Delegation toolkit is unavailable.")); + return executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe( + Effect.flatMap((snapshot) => + snapshot + ? Effect.succeed(snapshot) + : Effect.fail(delegationFailure("Delegation toolkit is unavailable.")), + ), + ); + }); + return { + actions: scope.scopes, + toolkitMembership: snapshots, + toolAddresses: snapshots.flatMap((snapshot) => snapshot.toolAddresses), + ...(scope.connectionIds === undefined + ? {} + : { + connectionAddresses: scope.connectionIds.map((value) => ConnectionAddress.make(value)), + }), + } satisfies import("@executor-js/sdk").DelegationScope; + }).pipe( + Effect.mapError(() => delegationFailure("Failed to build the delegated scope snapshot.")), + ); +}; + +/** Build display-only scope choices from the target's live executor. The + * returned ids are the same stable ids/addresses consumed by `scope`; labels + * never participate in authorization. */ +const scopeOptions = ( + dbHandle: ExecutorDbHandle, + config: CloudflareConfig, + targetSubject: string, +) => { + const executorEffect = makeScopedExecutor( + targetSubject, + config.organizationId, + config.organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: dbHandle.db, fuma: dbHandle.fuma, close: dbHandle.close }), + makeCloudflarePluginsProvider(config), + makeCloudflareHostConfig(config), + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(toolkits, (toolkit) => + executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe(Effect.map((snapshot) => ({ toolkit, snapshot }))), + ); + const toolkitIdsByConnection = new Map(); + for (const { toolkit, snapshot } of snapshots) { + if (!snapshot) continue; + for (const address of snapshot.connectionAddresses) { + const key = String(address); + const ids = toolkitIdsByConnection.get(key) ?? []; + ids.push(String(toolkit.id)); + toolkitIdsByConnection.set(key, ids); + } + } + const connections = yield* executor.connections.list(); + return { + actions: ["discover", "invoke"], + toolkits: toolkits.map((toolkit) => ({ + id: String(toolkit.id), + slug: String(toolkit.slug), + name: toolkit.name, + })), + connections: connections.map((connection) => ({ + id: String(connection.address), + label: connection.identityLabel ?? String(connection.name), + description: connection.description ?? null, + toolkitIds: [...(toolkitIdsByConnection.get(String(connection.address)) ?? [])].sort(), + })), + maxExpiresAt: new Date(Date.now() + config.delegatedMaxTtlMs).toISOString(), + } satisfies typeof DelegationScopeOptionsResponse.Type; + }).pipe(Effect.mapError(() => delegationFailure("Failed to read the delegated scope options."))); +}; // --------------------------------------------------------------------------- // Cloudflare AccountProvider — backs the shared `/account/*` surface the @@ -78,3 +236,113 @@ export const cloudflareAccountProvider = ( /** The per-request `AccountProvider` middleware (mounted under `/api`). */ export const cloudflareAccountMiddleware = (config: CloudflareConfig) => accountProviderMiddlewareLayer(cloudflareAccountProvider(config)); + +/** Cloudflare Access implementation of the provider-neutral delegation API. + * Access has no user-directory write surface, so target display is limited to + * the current verified subject (or a generic verified historical subject). */ +export const cloudflareDelegationProvider = ( + dbHandle: ExecutorDbHandle, + config: CloudflareConfig, + registry: VerifiedHumanSubjectRegistry, + verifierOptions: AccessVerifierOptions = {}, +) => { + const verifier = makeAccessVerifier(config, verifierOptions); + + const human = ( + request: DelegationRequestContext, + ): Effect.Effect => + Effect.gen(function* () { + if (!config.enableDevAuth && !sameOrigin(request)) { + return yield* new AccountForbidden({ + message: "Delegation management requires a same-origin browser request.", + }); + } + const verified = yield* verifier.verifyWithClaims(requestFor(request)); + if ( + !verified || + (!config.enableDevAuth && classifyAccessClaims(verified.claims) !== "human") + ) { + return yield* new AccountUnauthorized(); + } + const subject = verified.principal.accountId; + const recent = + config.enableDevAuth || + hasRecentAccessAuthentication( + verified.claims, + config.delegatedRecentAuthMaxAgeMs, + Date.now(), + config.delegatedRecentAuthClaim, + ); + if (recent && sameOrigin(request)) { + yield* registry + .markVerifiedHuman({ tenant: config.organizationId, subject }) + .pipe(Effect.mapError(() => delegationFailure("Failed to record Access human."))); + } + return { + subject, + organizationId: config.organizationId, + recent, + canFilterTargets: verified.principal.orgRole === "admin", + } satisfies DelegationHuman; + }); + + const target = (request: DelegationRequestContext, subject: string) => + Effect.gen(function* () { + const current = yield* human(request); + if (subject === current.subject) { + const verified = yield* verifier.verifyWithClaims(requestFor(request)); + return verified + ? cloudflareTarget(subject, current, verified.principal.email, verified.principal.name) + : null; + } + const isVerified = yield* registry.isVerifiedHuman({ + tenant: config.organizationId, + subject, + }); + return isVerified ? cloudflareTarget(subject, current, "", null) : null; + }).pipe(Effect.mapError(() => delegationFailure("Failed to read the Access directory."))); + + const host: DelegationAccountHost = { + human, + service: (request) => + Effect.succeed( + makeCloudflareDelegationService( + dbHandle, + config, + makeCloudflareDelegationAuthority(config, registry, requestFor(request)), + ), + ), + targets: (request) => + human(request).pipe( + Effect.map((current) => { + const verified = cloudflareTarget(current.subject, current, "", null); + return [verified]; + }), + ), + scopeOptions: (_request, targetSubject) => scopeOptions(dbHandle, config, targetSubject), + target, + scope: (_request, targetSubject, scope) => + scopeSnapshot(dbHandle, config, targetSubject, scope), + onRevokeAll: (_request, targetSubject, _disableTarget) => + registry + .revokeVerifiedHuman({ tenant: config.organizationId, subject: targetSubject }) + .pipe(Effect.mapError(() => delegationFailure("Failed to disable the Access human."))), + proof: (request, adapter) => + Effect.gen(function* () { + const verified = yield* verifier.verifyWithClaims(requestFor(request)); + if (!verified || classifyAccessClaims(verified.claims) !== "service-token") { + return yield* new AccountUnauthorized(); + } + const credential = delegatedCredentialFromAccessClaims(verified.claims, config); + if (!credential || credential.adapter !== adapter) return yield* new AccountUnauthorized(); + return { + credential, + machineActor: `access:${credential.credentialRef}`, + }; + }), + externalAdapters: ["cloudflare-access"], + localKeysEnabled: false, + issuanceEnabled: config.delegatedIssuanceEnabled, + }; + return makeDelegationProvider(host); +}; diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 8c3eebf789..393f9d0e93 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -1,22 +1,121 @@ import { Effect } from "effect"; import { HttpEffect, HttpRouter } from "effect/unstable/http"; -import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; +import { + dbProviderLayer, + ExecutorApp, + textFailureStrategy, + type DelegatedExecutionResumerShape, +} from "@executor-js/api/server"; +import { Subject } from "@executor-js/sdk"; import { loadConfig, type CloudflareEnv } from "./config"; import { makeCloudflarePlugins } from "./plugins"; import { createD1ExecutorDb } from "./db/d1"; -import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; +import { makeCloudflareDelegatingIdentityLayer } from "./auth/identity"; import { CloudflareCodeExecutorProvider, + makeCloudflareExecutionStackLayer, makeCloudflareHostConfig, makeCloudflarePluginsProvider, + makeExecutionStack, } from "./execution"; import { ErrorCaptureLive } from "./observability"; -import { cloudflareAccountMiddleware } from "./account/account-provider"; +import { + cloudflareAccountMiddleware, + cloudflareDelegationProvider, +} from "./account/account-provider"; +import { makeCloudflareDelegationService } from "./auth/delegation"; import { makeCloudflareApprovalHandler } from "./mcp"; import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; import { preloadQuickJs } from "./quickjs"; +import { hasRecentAccessAuthentication } from "./auth/cloudflare-access"; +import { makeVerifiedHumanSubjectRegistry } from "@executor-js/sdk"; + +/** + * Rebuild the original grant-scoped engine when an HTTP approval arrives after + * the request that paused it has ended. The browser approver is checked from + * the live AuthContext by the shared handler; this host callback additionally + * requires the target to remain verified and resolves the immutable binding + * before it consumes or executes the pending record. + */ +const makeCloudflareDelegatedExecutionResumer = ( + dbHandle: Awaited>, + config: ReturnType, + verifiedHumans: ReturnType, +): DelegatedExecutionResumerShape => { + const serviceFor = () => + makeCloudflareDelegationService(dbHandle, config, { + isVerifiedHuman: (subject) => + verifiedHumans.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(subject), + }), + // The machine assertion is gone by the time a browser approves a + // durable pause. The immutable binding and the target tombstone are the + // remaining durable checks; the original Access service-token tuple is + // still carried through the rebuilt delegated context. + isCurrentlyAuthorized: (grant) => + verifiedHumans.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(grant.effectiveSubject), + }), + isManagementAuthorized: (actorSubject, targetSubject) => + Effect.gen(function* () { + const actor = yield* verifiedHumans.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(actorSubject), + }); + if (!actor) return false; + if (targetSubject === undefined) return true; + return yield* verifiedHumans.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(targetSubject), + }); + }), + verifyProof: () => Effect.succeed(false), + }); + + const canApprove = (input: Parameters[0]) => + input.approver.accountId !== null && + input.approver.organizationId === config.organizationId && + String(input.binding.tenant) === config.organizationId && + (String(input.binding.effectiveSubject) === input.approver.accountId || + input.approver.roles?.includes("admin") === true); + + return { + authorize: (input) => + Effect.gen(function* () { + if (!canApprove(input)) return false; + yield* serviceFor().resolveBinding(input.binding); + return true; + }), + resume: (input) => + Effect.gen(function* () { + if (!canApprove(input)) return null; + const service = serviceFor(); + const grant = yield* service.resolveBinding(input.binding); + yield* service.audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: Subject.make(input.approver.accountId ?? ""), + effectiveSubject: grant.effectiveSubject, + action: "approval", + outcome: "allowed", + executionId: input.executionId, + }); + const stack = yield* makeExecutionStack>( + String(grant.effectiveSubject), + String(grant.tenant), + config.organizationName, + { delegation: grant }, + ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + return yield* stack.engine + .executeWithPause(input.code, { autoApprove: true }) + .pipe(Effect.ensuring(stack.engine.shutdown)); + }), + }; +}; // =========================================================================== // The Cloudflare host, as ONE `ExecutorApp.make` call: the 4th app alongside @@ -44,7 +143,37 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // Open and idempotently bring up the D1 schema once. This is the long-lived // handle the per-request scoped executor reads through the DbProvider seam. const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); - const identityLayer = cloudflareAccessIdentityLayer(config); + const verifiedHumans = makeVerifiedHumanSubjectRegistry(dbHandle.db); + const delegatedExecutionResumer = makeCloudflareDelegatedExecutionResumer( + dbHandle, + config, + verifiedHumans, + ); + const identityLayer = makeCloudflareDelegatingIdentityLayer(dbHandle, config, verifiedHumans, { + onVerifiedHuman: (claims, principal, request) => { + const browserOrigin = request.headers.get("origin"); + const requestOrigin = new URL(request.url).origin; + const expectedOrigin = config.webBaseUrl ?? requestOrigin; + // Only a same-origin browser request with a recent IdP authentication + // event can establish the durable human registry status. `iat` is not a + // login signal, and service-token assertions never reach this callback. + if ( + browserOrigin !== expectedOrigin || + requestOrigin !== expectedOrigin || + !hasRecentAccessAuthentication( + claims, + config.delegatedRecentAuthMaxAgeMs, + Date.now(), + config.delegatedRecentAuthClaim, + ) + ) { + return Effect.void; + } + return verifiedHumans + .markVerifiedHuman({ tenant: config.organizationId, subject: principal.accountId }) + .pipe(Effect.catch(() => Effect.void)); + }, + }); const mcpAgentHandler = makeCloudflareMcpAgentHandler(config); const approvalHandler = makeCloudflareApprovalHandler(config, env); @@ -63,7 +192,9 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // auth context; `me` reflects the Access principal. Members/keys are // Access-managed, so the rest of the surface is stubbed. account: cloudflareAccountMiddleware(config), + delegation: cloudflareDelegationProvider(dbHandle, config, verifiedHumans), }, + delegatedExecutionResumer, extensions: { routes: [ // Browser approval of paused MCP executions: the console resume page diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index 1e1a7e00a7..674adf303c 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"; import { orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; import type { CloudflareConfig } from "../config"; -import { principalFromAccessClaims } from "./cloudflare-access"; +import { + classifyAccessClaims, + delegatedCredentialFromAccessClaims, + hasRecentAccessAuthentication, + makeAccessVerifier, + principalFromAccessClaims, +} from "./cloudflare-access"; const config: CloudflareConfig = { accessTeamDomain: "team.cloudflareaccess.com", @@ -18,6 +26,9 @@ const config: CloudflareConfig = { allowLocalNetwork: false, webBaseUrl: "https://localhost", enableDevAuth: false, + delegatedIssuanceEnabled: false, + delegatedRecentAuthMaxAgeMs: 300_000, + delegatedMaxTtlMs: 90 * 24 * 60 * 60 * 1000, }; describe("principalFromAccessClaims", () => { @@ -26,6 +37,8 @@ describe("principalFromAccessClaims", () => { { sub: "user-123", email: "person@example.com", name: "Person", groups: ["eng"] }, config, ); + expect(p).not.toBeNull(); + if (!p) return; expect(p.accountId).toBe("user-123"); expect(p.email).toBe("person@example.com"); expect(p.name).toBe("Person"); @@ -35,6 +48,8 @@ describe("principalFromAccessClaims", () => { it("grants admin when the email is in the allowlist", () => { const p = principalFromAccessClaims({ sub: "u", email: "ADMIN@example.com" }, config); + expect(p).not.toBeNull(); + if (!p) return; expect(p.roles).toContain("admin"); expect(p.orgRoleModel).toBe("organization"); expect(p.orgRole).toBe("admin"); @@ -44,6 +59,8 @@ describe("principalFromAccessClaims", () => { it("gives a SERVICE TOKEN (common_name, no email/sub) a stable identity", () => { // Cloudflare Access service-token JWT: common_name set, email/sub absent. const p = principalFromAccessClaims({ common_name: "df8a20db.access", type: "app" }, config); + expect(p).not.toBeNull(); + if (!p) return; expect(p.accountId).toBe("df8a20db.access"); // not empty — stable per token expect(p.name).toBe("df8a20db.access"); expect(p.email).toBe(""); @@ -51,11 +68,129 @@ describe("principalFromAccessClaims", () => { expect(p.organizationId).toBe("default"); }); + it("keeps a signed app assertion machine-only when incidental claims are present", () => { + const p = principalFromAccessClaims( + { + type: "app", + common_name: "machine-1", + sub: "incidental-subject", + email: "machine@example.com", + groups: ["admin"], + }, + config, + ); + expect(p).not.toBeNull(); + if (!p) return; + expect(p.accountId).toBe("machine-1"); + expect(p.email).toBe(""); + expect(p.roles).toEqual(["member"]); + expect(p.orgRole).toBe("member"); + expect(orgWriteAccessForPrincipal(p)).toBe("denied"); + }); + it("defaults to member when there are no groups and no admin match", () => { const p = principalFromAccessClaims({ sub: "u", email: "nobody@other.com" }, config); + expect(p).not.toBeNull(); + if (!p) return; expect(p.roles).toEqual(["member"]); expect(p.orgRoleModel).toBe("organization"); expect(p.orgRole).toBe("member"); expect(orgWriteAccessForPrincipal(p)).toBe("denied"); }); + + it("requires positive signed human or service-token claims", () => { + expect(classifyAccessClaims({ sub: "human", email: "human@example.com" })).toBe("human"); + expect(classifyAccessClaims({ type: "app", common_name: "machine" })).toBe("service-token"); + expect(classifyAccessClaims({ common_name: "client-id-from-a-header" })).toBe("invalid"); + expect( + principalFromAccessClaims({ common_name: "client-id-from-a-header" }, config), + ).toBeNull(); + }); + + it("builds an external credential tuple only for a signed Access app", () => { + expect( + delegatedCredentialFromAccessClaims({ type: "app", common_name: "machine-1" }, config), + ).toMatchObject({ + kind: "external", + adapter: "cloudflare-access", + issuer: "https://team.cloudflareaccess.com", + audience: "aud-tag", + subject: "machine-1", + credentialRef: "machine-1", + }); + expect(delegatedCredentialFromAccessClaims({ common_name: "machine-1" }, config)).toBeNull(); + }); +}); + +describe("Access recent-auth assurance", () => { + it("uses auth_time and rejects missing or stale assurance", () => { + const now = 1_700_000_000_000; + expect(hasRecentAccessAuthentication({ iat: now / 1000 }, 300_000, now)).toBe(false); + expect(hasRecentAccessAuthentication({ auth_time: now / 1000 - 60 }, 300_000, now)).toBe(true); + expect(hasRecentAccessAuthentication({ auth_time: now / 1000 - 301 }, 300_000, now)).toBe( + false, + ); + expect( + hasRecentAccessAuthentication( + { custom: { login_time: String(now / 1000 - 60) } }, + 300_000, + now, + "login_time", + ), + ).toBe(true); + expect( + hasRecentAccessAuthentication({ custom: { login_time: now / 1000 - 60 } }, 300_000, now), + ).toBe(false); + }); + + it.effect("requires a valid signed issuer, audience, and expiry", () => + Effect.promise(async () => { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + const keySet = createLocalJWKSet({ keys: [{ ...jwk, alg: "RS256", kid: "test-key" }] }); + const verifier = makeAccessVerifier(config, { keySet }); + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ + sub: "human-1", + email: "human@example.com", + auth_time: now - 60, + }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer("https://team.cloudflareaccess.com") + .setAudience("aud-tag") + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(privateKey); + const verified = await Effect.runPromise( + verifier.verifyWithClaims( + new Request("https://localhost/", { + headers: { "Cf-Access-Jwt-Assertion": token }, + }), + ), + ); + expect(verified?.principal.accountId).toBe("human-1"); + expect(verified?.claims.auth_time).toBe(now - 60); + + const wrongAudience = await new SignJWT({ + sub: "human-1", + email: "human@example.com", + auth_time: now - 60, + }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer("https://team.cloudflareaccess.com") + .setAudience("wrong-audience") + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(privateKey); + await expect( + Effect.runPromise( + verifier.verifyWithClaims( + new Request("https://localhost/", { + headers: { "Cf-Access-Jwt-Assertion": wrongAudience }, + }), + ), + ), + ).resolves.toBeNull(); + }), + ); }); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index b311256c2e..fb12682052 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -1,12 +1,49 @@ -import { createRemoteJWKSet, jwtVerify } from "jose"; -import { Effect, Layer } from "effect"; +import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; +import { Effect } from "effect"; -import { IdentityProvider, Unauthorized, type Principal } from "@executor-js/api/server"; +import { type Principal } from "@executor-js/api/server"; +import type { VerifiedDelegatedCredential } from "@executor-js/sdk"; import type { CloudflareConfig } from "../config"; +export type AccessVerifierOptions = { + /** Test/edge seam for a fixed local key set; production uses Access JWKS. */ + readonly keySet?: JWTVerifyGetKey; + readonly onVerifiedHuman?: ( + claims: Readonly>, + principal: Principal, + request: Request, + ) => Effect.Effect; +}; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** `auth_time` is the IdP authentication event, unlike `iat`, which is only + * token issuance time. Missing or stale values fail the recent-auth check + * used by delegated credential management. */ +export const hasRecentAccessAuthentication = ( + claims: Readonly>, + maxAgeMs: number, + now = Date.now(), + claimName = "auth_time", +): boolean => { + const custom = claims.custom; + const customClaims = isRecord(custom) ? custom : undefined; + const raw = claims[claimName] ?? customClaims?.[claimName]; + const authTime = + typeof raw === "number" + ? raw + : typeof raw === "string" && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw.trim()) + ? Number(raw) + : null; + if (authTime === null || !Number.isFinite(authTime) || authTime <= 0) return false; + const authMs = authTime * 1000; + return authMs <= now && now - authMs <= maxAgeMs; +}; + // --------------------------------------------------------------------------- -// Cloudflare Access IdentityProvider — the CF-native swap for self-host's +// Cloudflare Access assertion verifier — the CF-native swap for self-host's // Better Auth. Cloudflare Access (Zero Trust) sits IN FRONT of the Worker and // authenticates the human; it forwards a signed `Cf-Access-Jwt-Assertion` JWT. // This provider verifies that JWT against the team's public JWKS and maps its @@ -18,35 +55,88 @@ import type { CloudflareConfig } from "../config"; // --------------------------------------------------------------------------- /** - * Map verified Access JWT claims onto the neutral `Principal`. Pure (no JWT - * verification) so it is unit-testable. Handles both human identities (email + - * sub, optional groups) and SERVICE TOKENS — machine/API-key auth via the - * `CF-Access-Client-Id`/`-Secret` headers — which carry `common_name` (the - * token's client id) instead of email/sub. Single-tenant: every principal - * belongs to the one configured org; admin comes from the email allowlist. + * Access assertions have two positively identified forms. A service token is + * accepted only when the signed assertion says `type: "app"` and carries a + * non-empty signed `common_name`; a human assertion must carry both a stable + * signed `sub` and an email. A caller cannot turn an arbitrary client-id + * header into a principal by omitting the signed machine claims. */ +export type AccessAssertionKind = "human" | "service-token" | "invalid"; + +/** Convert a verified, positively classified Access app assertion into the + * carrier-neutral credential tuple used by the delegation service. The + * common_name is a signed stable provider reference; no client secret is ever + * accepted or returned here. */ +export const delegatedCredentialFromAccessClaims = ( + claims: Readonly>, + config: CloudflareConfig, +): VerifiedDelegatedCredential | null => { + if (classifyAccessClaims(claims) !== "service-token") return null; + const reference = typeof claims.common_name === "string" ? claims.common_name.trim() : ""; + if (!reference) return null; + return { + kind: "external", + adapter: "cloudflare-access", + issuer: `https://${config.accessTeamDomain}`, + audience: config.accessAud, + subject: reference, + credentialRef: reference, + }; +}; + +export const classifyAccessClaims = ( + claims: Readonly>, +): AccessAssertionKind => { + const email = typeof claims.email === "string" ? claims.email.trim() : ""; + const sub = typeof claims.sub === "string" ? claims.sub.trim() : ""; + const commonName = typeof claims.common_name === "string" ? claims.common_name.trim() : ""; + const type = typeof claims.type === "string" ? claims.type : ""; + // `type=app` + a signed common_name is the positive machine assertion. Do + // not let incidental claims supplied by an Access deployment (including a + // subject or email) turn a service token into a human or grant it human + // groups/admin authority. + if (type === "app" && commonName.length > 0) { + return "service-token"; + } + if (sub.length > 0 && email.length > 0 && commonName.length === 0) return "human"; + return "invalid"; +}; + export const principalFromAccessClaims = ( - claims: Record, + claims: Readonly>, config: CloudflareConfig, -): Principal => { - const email = typeof claims.email === "string" ? claims.email : ""; - const sub = typeof claims.sub === "string" && claims.sub.length > 0 ? claims.sub : ""; +): Principal | null => { + const kind = classifyAccessClaims(claims); + const email = kind === "human" && typeof claims.email === "string" ? claims.email : ""; + const sub = + kind === "human" && typeof claims.sub === "string" && claims.sub.length > 0 ? claims.sub : ""; const commonName = typeof claims.common_name === "string" ? claims.common_name : ""; + if (classifyAccessClaims(claims) === "invalid") return null; const nameClaim = claims[config.accessNameClaim]; - const groupsClaim = claims[config.accessGroupsClaim]; - const groups = Array.isArray(groupsClaim) ? groupsClaim.map(String) : []; - const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); + const groupsClaim = kind === "human" ? claims[config.accessGroupsClaim] : undefined; + const groups = Array.isArray(groupsClaim) + ? groupsClaim.filter((value): value is string => typeof value === "string") + : []; + const isAdmin = + kind === "human" && email.length > 0 && config.adminEmails.includes(email.toLowerCase()); return { kind: "member", - accountId: sub || email || commonName, + accountId: kind === "service-token" ? commonName : sub || email, organizationId: config.organizationId, organizationName: config.organizationName, organizationSlug: config.organizationSlug, email, - name: typeof nameClaim === "string" ? nameClaim : commonName || null, + name: kind === "service-token" ? commonName : typeof nameClaim === "string" ? nameClaim : null, avatarUrl: null, - roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + roles: + kind === "service-token" + ? ["member"] + : isAdmin + ? ["admin", ...groups] + : groups.length > 0 + ? groups + : ["member"], orgRoleModel: "organization", orgRole: isAdmin ? "admin" : "member", }; @@ -60,12 +150,15 @@ export const principalFromAccessClaims = ( * * `jose` caches + rotates the team JWKS, so build the verifier once per config. */ -export const makeAccessVerifier = (config: CloudflareConfig) => { +export const makeAccessVerifier = ( + config: CloudflareConfig, + options: AccessVerifierOptions = {}, +) => { const issuer = `https://${config.accessTeamDomain}`; // Cached, lazily-fetched team signing keys; jose handles rotation + caching. const jwks = config.enableDevAuth ? null - : createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + : (options.keySet ?? createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`))); // Dev/single-user escape hatch: bypass Access entirely, every request is a // fixed admin. Only when explicitly enabled (and the instance is otherwise @@ -84,9 +177,14 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { orgRole: "admin", }; - const verify = (request: Request): Effect.Effect => + const verifyWithClaims = ( + request: Request, + ): Effect.Effect<{ + readonly claims: Readonly>; + readonly principal: Principal; + } | null> => Effect.gen(function* () { - if (config.enableDevAuth) return devPrincipal; + if (config.enableDevAuth) return { claims: {}, principal: devPrincipal }; if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); if (!token) return null; @@ -97,24 +195,18 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { }).pipe(Effect.orElseSucceed(() => null)); if (!verified) return null; - return principalFromAccessClaims(verified.payload as Record, config); + const claims = verified.payload; + const principal = principalFromAccessClaims(claims, config); + if (principal?.kind === "member" && classifyAccessClaims(claims) === "human") { + yield* (options.onVerifiedHuman?.(claims, principal, request) ?? Effect.void).pipe( + Effect.orElseSucceed(() => undefined), + ); + } + return principal ? { claims, principal } : null; }); - return { verify }; -}; + const verify = (request: Request): Effect.Effect => + verifyWithClaims(request).pipe(Effect.map((verified) => verified?.principal ?? null)); -export const cloudflareAccessIdentityLayer = ( - config: CloudflareConfig, -): Layer.Layer => { - const { verify } = makeAccessVerifier(config); - return Layer.succeed(IdentityProvider)( - IdentityProvider.of({ - authenticate: (request) => - verify(request).pipe( - Effect.flatMap((principal) => - principal ? Effect.succeed(principal) : Effect.fail(new Unauthorized()), - ), - ), - }), - ); + return { verify, verifyWithClaims }; }; diff --git a/apps/host-cloudflare/src/auth/delegation.test.ts b/apps/host-cloudflare/src/auth/delegation.test.ts new file mode 100644 index 0000000000..4366a31e05 --- /dev/null +++ b/apps/host-cloudflare/src/auth/delegation.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Exit, Layer } from "effect"; +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type KeyLike } from "jose"; + +import { collectTables, Subject, type VerifiedHumanSubjectRegistry } from "@executor-js/sdk"; +import { + DelegationProvider, + IdentityProvider, + type DelegationRequestContext, +} from "@executor-js/api/server"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import type { CloudflareConfig } from "../config"; +import { cloudflareDelegationProvider } from "../account/account-provider"; +import { makeCloudflareDelegationAuthority, makeCloudflareDelegationService } from "./delegation"; +import { makeCloudflareDelegatingIdentityLayer } from "./identity"; + +const config: CloudflareConfig = { + accessTeamDomain: "team.cloudflareaccess.com", + accessAud: "aud-tag", + accessNameClaim: "name", + accessGroupsClaim: "groups", + adminEmails: ["admin@example.com"], + organizationId: "default", + organizationName: "Default", + organizationSlug: "default", + secretKey: "x".repeat(32), + allowLocalNetwork: false, + webBaseUrl: "https://console.example", + enableDevAuth: false, + delegatedIssuanceEnabled: true, + delegatedRecentAuthMaxAgeMs: 300_000, + delegatedMaxTtlMs: 90 * 24 * 60 * 60 * 1000, +}; + +const scope = { + actions: ["discover", "invoke"] as const, + toolkitMembership: [ + { + toolkitId: "toolkit-one", + revision: "revision-one", + toolAddresses: ["org.integration.connection.tool"], + connectionAddresses: ["org.integration.connection"], + }, + ], + toolAddresses: ["org.integration.connection.tool"], + connectionAddresses: ["org.integration.connection"], +}; + +const subjects = new Set(["human-admin", "human-target"]); + +const registry: VerifiedHumanSubjectRegistry = { + isVerifiedHuman: ({ subject }) => Effect.succeed(subjects.has(subject)), + markVerifiedHuman: ({ subject }) => { + subjects.add(subject); + return Effect.void; + }, + revokeVerifiedHuman: ({ subject }) => { + subjects.delete(subject); + return Effect.void; + }, +}; + +const signedAccessRequest = async ( + privateKey: KeyLike, + claims: Readonly>, +): Promise => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ ...claims }) + .setProtectedHeader({ alg: "RS256", kid: "delegation-test" }) + .setIssuer("https://team.cloudflareaccess.com") + .setAudience("aud-tag") + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(privateKey); + return new Request("https://console.example/api/account", { + headers: { "Cf-Access-Jwt-Assertion": token }, + }); +}; + +describe("Cloudflare delegated Access flow", () => { + it.effect("allows an originless human GET and blocks a cross-origin mutation", () => + Effect.promise(async () => { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + const keySet = createLocalJWKSet({ + keys: [{ ...jwk, alg: "RS256", kid: "delegation-test" }], + }); + const humanRequest = await signedAccessRequest(privateKey, { + sub: "new-human", + email: "new-human@example.com", + name: "New Human", + auth_time: Math.floor(Date.now() / 1000) - 30, + }); + const subjects = new Set(); + const humanRegistry: VerifiedHumanSubjectRegistry = { + isVerifiedHuman: ({ subject }) => Effect.succeed(subjects.has(subject)), + markVerifiedHuman: ({ subject }) => { + subjects.add(subject); + return Effect.void; + }, + revokeVerifiedHuman: ({ subject }) => { + subjects.delete(subject); + return Effect.void; + }, + }; + const db = await createSqliteTestFumaDb({ tables: collectTables() }); + const noOriginConfig: CloudflareConfig = { ...config, webBaseUrl: undefined }; + await Effect.runPromise( + Effect.acquireUseRelease( + Effect.succeed(db), + (owned) => + Effect.promise(async () => { + const context = await Effect.runPromise( + Effect.scoped( + Layer.build( + cloudflareDelegationProvider(owned, noOriginConfig, humanRegistry, { keySet }), + ), + ), + ); + const provider = Context.get(context, DelegationProvider); + const headers = Object.fromEntries(humanRequest.headers.entries()); + const browserRead = { + headers, + requestOrigin: "https://victim.example", + browserOrigin: null, + method: "GET", + } satisfies DelegationRequestContext; + const targets = await Effect.runPromise(provider.listDelegationTargets(browserRead)); + expect(targets.targets).toHaveLength(1); + expect(targets.targets[0]?.id).toBe("new-human"); + expect(subjects.has("new-human")).toBe(true); + + const crossOriginMutation = { + ...browserRead, + browserOrigin: "https://evil.example", + method: "POST", + } satisfies DelegationRequestContext; + const denied = await Effect.runPromiseExit( + provider.listDelegationTargets(crossOriginMutation), + ); + expect(Exit.isFailure(denied)).toBe(true); + }), + (owned) => Effect.promise(() => owned.close()), + ), + ); + }), + ); + + it.effect("proves possession, requires human consent, and revokes without fallback", () => + Effect.promise(async () => { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + const keySet = createLocalJWKSet({ + keys: [{ ...jwk, alg: "RS256", kid: "delegation-test" }], + }); + const humanRequest = await signedAccessRequest(privateKey, { + sub: "human-admin", + email: "admin@example.com", + name: "Admin", + auth_time: Math.floor(Date.now() / 1000) - 30, + }); + const machineRequest = await signedAccessRequest(privateKey, { + type: "app", + common_name: "machine-one", + }); + const staleHumanRequest = await signedAccessRequest(privateKey, { + sub: "human-admin", + email: "admin@example.com", + auth_time: + Math.floor(Date.now() / 1000) - Math.ceil(config.delegatedRecentAuthMaxAgeMs / 1000) - 1, + }); + const machineCredential = { + kind: "external" as const, + adapter: "cloudflare-access", + issuer: "https://team.cloudflareaccess.com", + audience: "aud-tag", + subject: "machine-one", + credentialRef: "machine-one", + }; + const db = await createSqliteTestFumaDb({ tables: collectTables() }); + const run = async () => { + const humanAuthority = makeCloudflareDelegationAuthority(config, registry, humanRequest, { + keySet, + }); + const machineAuthority = makeCloudflareDelegationAuthority( + config, + registry, + machineRequest, + { keySet }, + ); + const staleHumanAuthority = makeCloudflareDelegationAuthority( + config, + registry, + staleHumanRequest, + { keySet }, + ); + const humanService = makeCloudflareDelegationService(db, config, humanAuthority); + const machineService = makeCloudflareDelegationService(db, config, machineAuthority); + + const challenge = await Effect.runPromise( + humanService.issueProofChallenge({ + adapter: "cloudflare-access", + createdBy: Subject.make("human-admin"), + effectiveSubject: Subject.make("human-target"), + label: "Cloudflare agent", + scope, + }), + ); + const completion = await Effect.runPromise( + machineService.completeProofChallenge({ + challengeId: challenge.challengeId, + credential: machineCredential, + machineActor: Subject.make("access:machine-one"), + nonce: challenge.nonce, + }), + ); + expect(completion.credential.credentialRef).toBe("machine-one"); + + const grant = await Effect.runPromise( + humanService.create({ + proofChallengeId: challenge.challengeId, + consentedBy: Subject.make("human-admin"), + }), + ); + const resolved = await Effect.runPromise(machineService.resolve(machineCredential)); + expect(resolved.grantId).toBe(grant.grantId); + const identityAuthority = makeCloudflareDelegationAuthority( + config, + registry, + machineRequest, + { keySet }, + ); + expect(await Effect.runPromise(identityAuthority.isCurrentlyAuthorized(grant))).toBe(true); + const identityService = makeCloudflareDelegationService(db, config, identityAuthority); + const identityServiceResolved = await Effect.runPromiseExit( + identityService.resolve(machineCredential), + ); + expect(Exit.isSuccess(identityServiceResolved)).toBe(true); + const identityContext = await Effect.runPromise( + Effect.scoped( + Layer.build(makeCloudflareDelegatingIdentityLayer(db, config, registry, { keySet })), + ), + ); + const identity = Context.get(identityContext, IdentityProvider); + const delegatedPrincipal = await Effect.runPromise( + identity.authenticate( + new Request(machineRequest.url, { headers: new Headers(machineRequest.headers) }), + ), + ); + expect(delegatedPrincipal.kind).toBe("delegated"); + expect( + await Effect.runPromise( + humanAuthority.isManagementAuthorized( + Subject.make("human-admin"), + Subject.make("human-target"), + ), + ), + ).toBe(true); + expect( + await Effect.runPromise( + machineAuthority.isManagementAuthorized( + Subject.make("human-admin"), + Subject.make("human-target"), + ), + ), + ).toBe(false); + expect( + await Effect.runPromise( + staleHumanAuthority.isManagementAuthorized( + Subject.make("human-admin"), + Subject.make("human-target"), + ), + ), + ).toBe(false); + + await Effect.runPromise( + humanService.revoke({ + grantId: grant.grantId, + actorSubject: Subject.make("human-admin"), + }), + ); + const revoked = await Effect.runPromiseExit(machineService.resolve(machineCredential)); + expect(Exit.isFailure(revoked)).toBe(true); + const differentMachine = await Effect.runPromiseExit( + machineService.resolve({ ...machineCredential, credentialRef: "machine-two" }), + ); + expect(Exit.isFailure(differentMachine)).toBe(true); + const revokedIdentity = await Effect.runPromiseExit(identity.authenticate(machineRequest)); + expect(Exit.isFailure(revokedIdentity)).toBe(true); + const unmappedIdentity = await Effect.runPromiseExit( + identity.authenticate( + await signedAccessRequest(privateKey, { + type: "app", + common_name: "machine-two", + }), + ), + ); + expect(Exit.isSuccess(unmappedIdentity)).toBe(true); + const unmappedPrincipal = Exit.match(unmappedIdentity, { + onFailure: () => null, + onSuccess: (value) => value, + }); + expect(unmappedPrincipal).toMatchObject({ kind: "member", accountId: "machine-two" }); + }; + await Effect.runPromise( + Effect.acquireUseRelease( + Effect.succeed(db), + () => Effect.promise(run), + (owned) => Effect.promise(() => owned.close()), + ), + ); + }), + ); +}); diff --git a/apps/host-cloudflare/src/auth/delegation.ts b/apps/host-cloudflare/src/auth/delegation.ts new file mode 100644 index 0000000000..c2c7d714e2 --- /dev/null +++ b/apps/host-cloudflare/src/auth/delegation.ts @@ -0,0 +1,130 @@ +import { + Effect, + makeDelegationService, + Subject, + Tenant, + type VerifiedDelegatedCredential, + type DelegationService, + type DelegationStoreOptions, +} from "@executor-js/sdk"; +import type { ExecutorDbHandle } from "@executor-js/api/server"; +import type { VerifiedHumanSubjectRegistry } from "@executor-js/sdk"; + +import type { CloudflareConfig } from "../config"; +import { + classifyAccessClaims, + delegatedCredentialFromAccessClaims, + hasRecentAccessAuthentication, + makeAccessVerifier, + type AccessVerifierOptions, +} from "./cloudflare-access"; + +/** Every authority callback is required. A registry sighting cannot substitute + * for live membership/admin policy, and proof must be checked by the provider + * adapter before a challenge can become a grant. */ +export type CloudflareDelegationAuthority = Pick< + DelegationStoreOptions, + "isVerifiedHuman" | "isCurrentlyAuthorized" | "isManagementAuthorized" | "verifyProof" +>; + +/** + * Build the Cloudflare delegation service over the request/DO's D1 handle. + * + * The authority callbacks own the live Access human registry, tenant + * membership, admin policy, and provider proof check. They are required here + * so this constructor cannot silently turn a sighting into authorization. + */ +export const makeCloudflareDelegationService = ( + dbHandle: Pick, + config: CloudflareConfig, + authority: CloudflareDelegationAuthority, +): DelegationService => { + const tenant = Tenant.make(config.organizationId); + return makeDelegationService({ + db: dbHandle.db, + tenant, + issuanceEnabled: config.delegatedIssuanceEnabled, + maxTtlMs: config.delegatedMaxTtlMs, + isVerifiedHuman: authority.isVerifiedHuman, + isCurrentlyAuthorized: authority.isCurrentlyAuthorized, + isManagementAuthorized: authority.isManagementAuthorized, + verifyProof: authority.verifyProof, + }); +}; + +const sameCredential = ( + left: VerifiedDelegatedCredential, + right: VerifiedDelegatedCredential, +): boolean => + left.kind === right.kind && + left.adapter === right.adapter && + left.issuer === right.issuer && + left.audience === right.audience && + left.subject === right.subject && + left.credentialRef === right.credentialRef; + +const machineActorFor = (credential: VerifiedDelegatedCredential): string => + `access:${credential.credentialRef}`; + +/** Build live Access-backed authority callbacks for one request. The request's + * signed JWT is rechecked for each possession/current-authorization decision; + * a prior human sighting alone never authorizes a machine or administrator. */ +export const makeCloudflareDelegationAuthority = ( + config: CloudflareConfig, + registry: VerifiedHumanSubjectRegistry, + request: Request, + verifierOptions: AccessVerifierOptions = {}, +): CloudflareDelegationAuthority => { + const verifier = makeAccessVerifier(config, verifierOptions); + const verifiedRequest = () => verifier.verifyWithClaims(request); + const verifiedHuman = (subject: Subject) => + registry.isVerifiedHuman({ tenant: config.organizationId, subject: String(subject) }); + const verifiedMachineCredential = (credential: VerifiedDelegatedCredential) => + Effect.gen(function* () { + const verified = yield* verifiedRequest(); + if (!verified || classifyAccessClaims(verified.claims) !== "service-token") return false; + const presented = delegatedCredentialFromAccessClaims(verified.claims, config); + return presented !== null && sameCredential(presented, credential); + }); + + return { + isVerifiedHuman: verifiedHuman, + isCurrentlyAuthorized: (grant) => + Effect.gen(function* () { + const targetVerified = yield* verifiedHuman(grant.effectiveSubject); + if (!targetVerified) return false; + const verified = yield* verifiedRequest(); + if (!verified || classifyAccessClaims(verified.claims) !== "service-token") return false; + const credential = delegatedCredentialFromAccessClaims(verified.claims, config); + if (credential === null || machineActorFor(credential) !== String(grant.actorSubject)) { + return false; + } + return machineActorFor(credential) === String(grant.actorSubject); + }), + isManagementAuthorized: (actorSubject, targetSubject) => + Effect.gen(function* () { + const verified = yield* verifiedRequest(); + if (!verified || classifyAccessClaims(verified.claims) !== "human") return false; + if (verified.principal.accountId !== String(actorSubject)) return false; + if ( + !hasRecentAccessAuthentication( + verified.claims, + config.delegatedRecentAuthMaxAgeMs, + Date.now(), + config.delegatedRecentAuthClaim, + ) + ) { + return false; + } + const target = String(targetSubject ?? actorSubject); + if (!(yield* verifiedHuman(Subject.make(target)))) return false; + return target === String(actorSubject) || verified.principal.orgRole === "admin"; + }), + verifyProof: (input) => + verifiedMachineCredential(input.credential).pipe( + Effect.map( + (valid) => valid && machineActorFor(input.credential) === String(input.machineActor), + ), + ), + }; +}; diff --git a/apps/host-cloudflare/src/auth/identity.ts b/apps/host-cloudflare/src/auth/identity.ts new file mode 100644 index 0000000000..a3025277f7 --- /dev/null +++ b/apps/host-cloudflare/src/auth/identity.ts @@ -0,0 +1,82 @@ +import { Effect, Layer } from "effect"; + +import { + IdentityProvider, + Unauthorized, + type ExecutorDbHandle, + type Principal, +} from "@executor-js/api/server"; +import type { VerifiedHumanSubjectRegistry } from "@executor-js/sdk"; + +import type { CloudflareConfig } from "../config"; +import { + classifyAccessClaims, + delegatedCredentialFromAccessClaims, + makeAccessVerifier, + type AccessVerifierOptions, +} from "./cloudflare-access"; +import { makeCloudflareDelegationAuthority, makeCloudflareDelegationService } from "./delegation"; + +const delegatedPrincipalFrom = ( + grant: import("@executor-js/sdk").DelegationGrant, + config: CloudflareConfig, +): Principal => ({ + kind: "delegated", + accountId: String(grant.effectiveSubject), + organizationId: config.organizationId, + organizationName: config.organizationName, + organizationSlug: config.organizationSlug, + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: String(grant.tenant), + grantId: String(grant.grantId), + credentialId: String(grant.credentialId), + credentialRef: grant.credentialRef, + actorSubject: String(grant.actorSubject), + effectiveSubject: String(grant.effectiveSubject), + scopeVersion: String(grant.scopeVersion), + delegation: grant, +}); + +/** Identity layer for both human Access browser assertions and delegated Access + * app assertions. An app assertion with no delegation mapping preserves the + * existing machine principal for compatibility. Once a tuple has ever mapped, + * every mismatch, expiry, revocation, or storage failure fails closed. */ +export const makeCloudflareDelegatingIdentityLayer = ( + dbHandle: Pick, + config: CloudflareConfig, + registry: VerifiedHumanSubjectRegistry, + options: AccessVerifierOptions = {}, +): Layer.Layer => { + const { verifyWithClaims } = makeAccessVerifier(config, options); + return Layer.succeed(IdentityProvider)({ + authenticate: (request) => + Effect.gen(function* () { + const verified = yield* verifyWithClaims(request); + if (!verified) return yield* new Unauthorized(); + if (classifyAccessClaims(verified.claims) !== "service-token") { + return verified.principal; + } + const credential = delegatedCredentialFromAccessClaims(verified.claims, config); + if (credential === null) return yield* new Unauthorized(); + const service = makeCloudflareDelegationService( + dbHandle, + config, + makeCloudflareDelegationAuthority(config, registry, request, options), + ); + return yield* service.resolve(credential).pipe( + Effect.map((grant) => delegatedPrincipalFrom(grant, config)), + Effect.catchTag("DelegationDeniedError", (error) => + error.reason === "unmapped" + ? Effect.succeed(verified.principal) + : Effect.fail(new Unauthorized()), + ), + Effect.mapError(() => new Unauthorized()), + ); + }), + }); +}; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts index 39aab1e7d3..7395e11542 100644 --- a/apps/host-cloudflare/src/config.test.ts +++ b/apps/host-cloudflare/src/config.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "@effect/vitest"; import { parse } from "jsonc-parser"; +import { MAX_DELEGATION_TTL_MS } from "@executor-js/sdk"; import { loadConfig } from "./config"; @@ -56,6 +57,41 @@ describe("loadConfig", () => { enableDevAuth: false, }); }); + + it("honors a positive operator TTL cap within the SDK maximum", () => { + expect( + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + DELEGATED_MAX_TTL_MS: "604800000", + }), + ).delegatedMaxTtlMs, + ).toBe(604800000); + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + DELEGATED_MAX_TTL_MS: String(MAX_DELEGATION_TTL_MS + 1), + }), + ), + ).toThrowError("DELEGATED_MAX_TTL_MS"); + }); + + it("rejects token lifecycle claims as recent-auth evidence", () => { + for (const claim of ["iat", "exp", "nbf", "sub"]) { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + DELEGATED_RECENT_AUTH_CLAIM: claim, + }), + ), + ).toThrowError("DELEGATED_RECENT_AUTH_CLAIM"); + } + }); }); describe("Cloudflare deployment configuration", () => { diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef87..37efedb4a3 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -1,6 +1,7 @@ import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types"; import { isValidOrgSlug } from "@executor-js/api"; +import { MAX_DELEGATION_TTL_MS } from "@executor-js/sdk"; import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin"; let warnedNoCloudflareOrigin = false; @@ -56,6 +57,14 @@ export interface CloudflareEnv { * behind Access, or the instance is wide open. */ readonly ENABLE_DEV_AUTH?: string; + /** Explicit rollout gate for delegated credential issuance. */ + readonly DELEGATED_CREDENTIAL_ISSUANCE?: string; + /** Maximum age of a signed Access `auth_time` used for human management. */ + readonly DELEGATED_RECENT_AUTH_MAX_AGE_MS?: string; + /** Signed IdP/custom claim used as the recent-login assurance. */ + readonly DELEGATED_RECENT_AUTH_CLAIM?: string; + /** Operator cap for delegated grants, bounded by the SDK hard maximum. */ + readonly DELEGATED_MAX_TTL_MS?: string; } export interface CloudflareConfig { @@ -74,6 +83,13 @@ export interface CloudflareConfig { * static URL — the per-request origin is used instead (see RequestWebOrigin). */ readonly webBaseUrl?: string; readonly enableDevAuth: boolean; + readonly delegatedIssuanceEnabled: boolean; + readonly delegatedRecentAuthMaxAgeMs: number; + /** Signed IdP assurance claim carrying login time in Unix seconds. Access + * application JWTs expose `iat`, which is token issuance time and is not + * sufficient; configure a signed IdP/custom claim such as `auth_time`. */ + readonly delegatedRecentAuthClaim?: string; + readonly delegatedMaxTtlMs: number; } type CloudflareConfigEnv = Omit< @@ -98,6 +114,23 @@ const normalizeAccessTeamDomain = (value: string | undefined): string => .replace(/^https?:\/\//, "") .replace(/\/+$/, ""); +// These claims describe token identity or token lifetime, not a fresh IdP +// authentication event. Allowing one as the operator-selected assurance +// field would turn issuance time (`iat`) or expiry (`exp`) into a login proof. +const RESERVED_RECENT_AUTH_CLAIMS = new Set([ + "iss", + "sub", + "aud", + "exp", + "nbf", + "iat", + "jti", + "typ", + "email", + "common_name", + "type", +]); + export const missingCloudflareAccessVars = (env: CloudflareAccessEnv): readonly string[] => { if (env.ENABLE_DEV_AUTH === "true") return []; const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); @@ -138,6 +171,33 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { ); } const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; + const delegatedRecentAuthMaxAgeMs = Number(env.DELEGATED_RECENT_AUTH_MAX_AGE_MS ?? "300000"); + if (!Number.isSafeInteger(delegatedRecentAuthMaxAgeMs) || delegatedRecentAuthMaxAgeMs <= 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error("DELEGATED_RECENT_AUTH_MAX_AGE_MS must be a positive integer"); + } + const delegatedRecentAuthClaim = (env.DELEGATED_RECENT_AUTH_CLAIM ?? "auth_time").trim(); + if ( + !delegatedRecentAuthClaim || + !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(delegatedRecentAuthClaim) || + RESERVED_RECENT_AUTH_CLAIMS.has(delegatedRecentAuthClaim.toLowerCase()) + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: an assurance claim must be an unambiguous signed field name + throw new Error( + "DELEGATED_RECENT_AUTH_CLAIM must be a non-reserved signed claim name (for example auth_time)", + ); + } + const delegatedMaxTtlMs = Number(env.DELEGATED_MAX_TTL_MS ?? String(MAX_DELEGATION_TTL_MS)); + if ( + !Number.isSafeInteger(delegatedMaxTtlMs) || + delegatedMaxTtlMs <= 0 || + delegatedMaxTtlMs > MAX_DELEGATION_TTL_MS + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `DELEGATED_MAX_TTL_MS must be a positive integer no greater than ${MAX_DELEGATION_TTL_MS}`, + ); + } const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); const accessAud = (env.ACCESS_AUD ?? "").trim(); const missingAccessVars = missingCloudflareAccessVars(env); @@ -174,5 +234,9 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { // mirroring self-host (gated on enableDevAuth = local `wrangler dev`). webBaseUrl, enableDevAuth, + delegatedIssuanceEnabled: env.DELEGATED_CREDENTIAL_ISSUANCE === "true", + delegatedRecentAuthMaxAgeMs, + delegatedRecentAuthClaim, + delegatedMaxTtlMs, }; }; diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index afd469b0c0..5fe57c36d7 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -22,7 +22,7 @@ import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadat import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; -import { cloudflareAccessMcpAuth } from "./auth"; +import { cloudflareDelegatedAccessMcpAuth } from "./auth"; import { mcpResourceFromPath } from "./resource"; import { McpSessionDO } from "./session-durable-object"; @@ -67,12 +67,12 @@ const renderAuthError = ( return jsonRpcResponse(503, -32001, outcome.message); }; -const authenticate = (request: Request, config: CloudflareConfig) => +const authenticate = (request: Request, config: CloudflareConfig, env: CloudflareEnv) => Effect.gen(function* () { const auth = yield* McpAuthProvider; const outcome = yield* auth.authenticate(request); return { auth, outcome }; - }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); + }).pipe(Effect.provide(cloudflareDelegatedAccessMcpAuth(config, env))); const propsForPrincipal = ( request: Request, @@ -81,6 +81,18 @@ const propsForPrincipal = ( ): Effect.Effect => Effect.gen(function* () { const propagation = yield* currentPropagationHeaders(request); + const delegation = + principal.kind === "delegated" + ? { + tenant: principal.tenant, + grantId: principal.grantId, + credentialId: principal.credentialId, + credentialRef: principal.credentialRef, + actorSubject: principal.actorSubject, + effectiveSubject: principal.effectiveSubject, + scopeVersion: principal.scopeVersion, + } + : undefined; return { session: { organizationId: principal.organizationId, @@ -92,6 +104,7 @@ const propsForPrincipal = ( toolMode: readToolMode(request), resource, webOrigin: new URL(request.url).origin, + ...(delegation === undefined ? {} : { delegation }), }, propagation, }; @@ -109,7 +122,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { if (request.method === "OPTIONS") return corsPreflightResponse(); const sessionId = request.headers.get("mcp-session-id"); - const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); + const { auth, outcome } = await Effect.runPromise(authenticate(request, config, env)); if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { await Effect.runPromise( @@ -137,6 +150,19 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, + ...(outcome.principal.kind === "delegated" + ? { + delegation: { + tenant: outcome.principal.tenant, + grantId: outcome.principal.grantId, + credentialId: outcome.principal.credentialId, + credentialRef: outcome.principal.credentialRef, + actorSubject: outcome.principal.actorSubject, + effectiveSubject: outcome.principal.effectiveSubject, + scopeVersion: outcome.principal.scopeVersion, + }, + } + : {}), }, resource, ); @@ -161,6 +187,19 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, + ...(outcome.principal.kind === "delegated" + ? { + delegation: { + tenant: outcome.principal.tenant, + grantId: outcome.principal.grantId, + credentialId: outcome.principal.credentialId, + credentialRef: outcome.principal.credentialRef, + actorSubject: outcome.principal.actorSubject, + effectiveSubject: outcome.principal.effectiveSubject, + scopeVersion: outcome.principal.scopeVersion, + }, + } + : {}), }, resource, ), diff --git a/apps/host-cloudflare/src/mcp/auth.ts b/apps/host-cloudflare/src/mcp/auth.ts index 40481f86c4..920dc5de1c 100644 --- a/apps/host-cloudflare/src/mcp/auth.ts +++ b/apps/host-cloudflare/src/mcp/auth.ts @@ -5,10 +5,21 @@ import { McpAuthProvider, unauthorized, type McpDiscoveryRoute, + type Principal, } from "@executor-js/host-mcp"; +import { type DelegationGrant, makeVerifiedHumanSubjectRegistry } from "@executor-js/sdk"; -import { makeAccessVerifier } from "../auth/cloudflare-access"; -import type { CloudflareConfig } from "../config"; +import { + classifyAccessClaims, + delegatedCredentialFromAccessClaims, + makeAccessVerifier, +} from "../auth/cloudflare-access"; +import { + makeCloudflareDelegationAuthority, + makeCloudflareDelegationService, +} from "../auth/delegation"; +import { createD1ExecutorDb } from "../db/d1"; +import type { CloudflareConfig, CloudflareEnv } from "../config"; const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; @@ -53,24 +64,81 @@ const protectedResourceMetadataResponse = (request: Request): Response => { ); }; -// --------------------------------------------------------------------------- -// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the -// API gate. Cloudflare Access sits in front of the Worker and forwards the -// signed `Cf-Access-Jwt-Assertion` on every request, including `/mcp`. So the -// MCP auth seam reuses the SAME `makeAccessVerifier` the IdentityProvider uses: -// validate the JWT, map claims onto the neutral `Principal`, done. -// -// There is no MCP OAuth here. Auth is Access's browser/service-token flow, not -// the MCP `/authorize`+`/token` dance — so `discoveryRoutes` is empty and the -// 401 challenge points at a nominal protected-resource URL only to satisfy -// clients that probe for it. An external MCP client authenticates by presenting -// an Access JWT (or `Cf-Access-Client-Id`/`-Secret` service-token headers, which -// Access converts to one). When MCP OAuth-over-Access is needed, add the -// discovery docs + a token endpoint here behind this same seam. -// --------------------------------------------------------------------------- +/** Rebuild the neutral delegated principal only after the current Access JWT + * and the durable grant both resolve. The MCP worker is a separate entrypoint + * from the HTTP app, so it opens the request's D1 handle here and closes it + * before forwarding to the session DO. `unmapped` is the one compatibility + * case: a valid, previously unseen Access service token remains an ordinary + * machine principal. Every mapped failure (revoked, expired, mismatched, or + * storage failure) returns null and therefore 401s; it never falls back. */ +const delegatedPrincipalFrom = (grant: DelegationGrant, config: CloudflareConfig): Principal => ({ + kind: "delegated", + accountId: String(grant.effectiveSubject), + organizationId: config.organizationId, + organizationName: config.organizationName, + organizationSlug: config.organizationSlug, + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: String(grant.tenant), + grantId: String(grant.grantId), + credentialId: String(grant.credentialId), + credentialRef: grant.credentialRef, + actorSubject: String(grant.actorSubject), + effectiveSubject: String(grant.effectiveSubject), + scopeVersion: String(grant.scopeVersion), +}); + +export const resolveCloudflareAccessMcpPrincipal = ( + request: Request, + env: CloudflareEnv, + config: CloudflareConfig, +): Effect.Effect => + Effect.gen(function* () { + const { verifyWithClaims } = makeAccessVerifier(config); + const verified = yield* verifyWithClaims(request); + if (verified === null) return null; + if (classifyAccessClaims(verified.claims) !== "service-token") return verified.principal; + + const credential = delegatedCredentialFromAccessClaims(verified.claims, config); + if (credential === null) return null; + + const dbHandle = yield* Effect.tryPromise({ + try: () => createD1ExecutorDb(env.DB, env.BLOBS), + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + if (dbHandle === null) return null; + + const registry = makeVerifiedHumanSubjectRegistry(dbHandle.db); + const service = makeCloudflareDelegationService( + dbHandle, + config, + makeCloudflareDelegationAuthority(config, registry, request), + ); + return yield* Effect.acquireUseRelease( + Effect.succeed(dbHandle), + () => + service.resolve(credential).pipe( + Effect.map((grant) => delegatedPrincipalFrom(grant, config)), + Effect.catchTag("DelegationDeniedError", (error) => + error.reason === "unmapped" ? Effect.succeed(verified.principal) : Effect.succeed(null), + ), + Effect.catch(() => Effect.succeed(null)), + ), + (handle) => Effect.promise(() => handle.close()), + ); + }); -export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer => { - const { verify } = makeAccessVerifier(config); +/** Access MCP auth with the same delegated resolver used by the HTTP identity + * cheap generic verifier while the actual MCP worker request performs the + * durable grant lookup and live revalidation. */ +export const cloudflareDelegatedAccessMcpAuth = ( + config: CloudflareConfig, + env: CloudflareEnv, +): Layer.Layer => { const discoveryRoutes: ReadonlyArray = [ { path: PROTECTED_RESOURCE_METADATA_PATH, @@ -90,7 +158,7 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer new URL(metadataPathForRequest(request), new URL(request.url).origin).toString(), authenticate: (request) => - verify(request).pipe( + resolveCloudflareAccessMcpPrincipal(request, env, config).pipe( Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())), ), }); diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index bcb9d74b22..ab8272ab4e 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -8,9 +8,8 @@ import type { import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; -import { makeAccessVerifier } from "../auth/cloudflare-access"; +import { classifyAccessClaims, makeAccessVerifier } from "../auth/cloudflare-access"; -export { cloudflareAccessMcpAuth } from "./auth"; export { McpSessionDO } from "./session-durable-object"; export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execution-owner-directory"; @@ -24,15 +23,22 @@ export const makeCloudflareApprovalHandler = ( config: CloudflareConfig, env: CloudflareEnv, ): ((request: Request) => Promise) => { - const { verify } = makeAccessVerifier(config); + const { verifyWithClaims } = makeAccessVerifier(config); const stubFor = (sessionId: string) => mcpSessionStub(env.MCP_SESSION, sessionId); return async (request) => { - const principal = await Effect.runPromise(verify(request)); - if (!principal) return jsonResponse({ error: "Unauthorized" }, 401); + const verified = await Effect.runPromise(verifyWithClaims(request)); + // Access service-token assertions are valid machine identities, but they + // are never browser approvers. The approval endpoint must be reached by a + // positively classified human assertion (or the explicit local dev mode). + if (!verified || (!config.enableDevAuth && classifyAccessClaims(verified.claims) !== "human")) { + return jsonResponse({ error: "Unauthorized" }, 401); + } + const principal = verified.principal; const owner: McpApprovalOwner = { accountId: principal.accountId, organizationId: principal.organizationId, + orgRole: principal.orgRole === "admin" ? "admin" : "member", }; const { pathname } = new URL(request.url); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 1ddd680093..efd2da3610 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -11,7 +11,9 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, + McpDelegationUnavailableError, type BuiltMcpServer, + type McpApprovalPrincipal, type McpModelResumeCaller, type McpSessionModelResumeResult, type McpSessionInit, @@ -25,8 +27,20 @@ import { } from "@executor-js/cloudflare/mcp/execution-owner-directory"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { ResumeResponse } from "@executor-js/execution"; +import { + CredentialId, + DelegationId, + DelegationScopeVersion, + makeVerifiedHumanSubjectRegistry, + Subject, + Tenant, + type DelegationBinding, + type DelegationCapability, +} from "@executor-js/sdk"; +import type { McpDelegationBinding } from "@executor-js/cloudflare/mcp/do-headers"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; +import { makeCloudflareDelegationService } from "../auth/delegation"; import { createD1ExecutorDb } from "../db/d1"; import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution"; import { preloadQuickJs } from "../quickjs"; @@ -134,9 +148,120 @@ export class McpSessionDO extends McpAgentSessionDOBase) { + const config = this.cfConfig; + const registry = makeVerifiedHumanSubjectRegistry(dbHandle.db); + return makeCloudflareDelegationService(dbHandle, this.cfConfig, { + isVerifiedHuman: (subject) => + registry.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(subject), + }), + // The Worker verifies the current Access service-token JWT before every + // machine request reaches this DO. A cold restore has no bearer to + // recheck; this durable check therefore covers grant revocation, expiry, + // scope changes, and target disablement. Cloudflare does not expose an + // offline query for upstream Access-token revocation, so this adapter + // does not claim to observe that external lifecycle event. Each new + // request still requires a valid JWT at the Worker edge. + isCurrentlyAuthorized: (grant) => + registry.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(grant.effectiveSubject), + }), + isManagementAuthorized: (actorSubject, targetSubject) => + Effect.gen(function* () { + const actor = yield* registry.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(actorSubject), + }); + if (!actor) return false; + if (targetSubject === undefined) return true; + return yield* registry.isVerifiedHuman({ + tenant: config.organizationId, + subject: String(targetSubject), + }); + }), + verifyProof: () => Effect.succeed(false), + }); + } + + private resolveDelegation( + binding: McpDelegationBinding, + dbHandle: Pick, + ) { + return this.delegationService(dbHandle).resolveBinding(this.delegationBinding(binding)); + } + + protected override revalidateDelegation( + binding: McpDelegationBinding, + capability: DelegationCapability, + ): Effect.Effect { + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => this.openSessionDb(), + catch: () => new McpDelegationUnavailableError(), + }), + (dbHandle) => { + const service = this.delegationService(dbHandle); + return service.resolveBinding(this.delegationBinding(binding)).pipe( + Effect.flatMap((grant) => service.revalidate(grant, capability)), + Effect.mapError(() => new McpDelegationUnavailableError()), + ); + }, + (dbHandle) => Effect.promise(() => dbHandle.end()).pipe(Effect.ignore), + ); + } + + protected override auditBrowserApproval( + binding: McpDelegationBinding | undefined, + approver: McpApprovalPrincipal, + executionId: string, + response: ResumeResponse, + ): Effect.Effect { + if (binding === undefined) return Effect.void; + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => this.openSessionDb(), + catch: () => new McpDelegationUnavailableError(), + }), + (dbHandle) => { + const service = this.delegationService(dbHandle); + return service.resolveBinding(this.delegationBinding(binding)).pipe( + Effect.flatMap((grant) => + service.audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: Subject.make(approver.accountId), + effectiveSubject: grant.effectiveSubject, + action: "approval", + outcome: response.action === "accept" ? "allowed" : "denied", + executionId, + }), + ), + Effect.mapError(() => new McpDelegationUnavailableError()), + ); + }, + (dbHandle) => Effect.promise(() => dbHandle.end()).pipe(Effect.ignore), + ); + } + protected override buildMcpServer( sessionMeta: SessionMeta, dbHandle: CfSessionDbHandle, @@ -151,7 +276,13 @@ export class McpSessionDO extends McpAgentSessionDOBase { "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n", ); } - }); + }, 30_000); }); diff --git a/apps/host-selfhost/src/account/account-api.ts b/apps/host-selfhost/src/account/account-api.ts index c39863c512..9cf2232ba3 100644 --- a/apps/host-selfhost/src/account/account-api.ts +++ b/apps/host-selfhost/src/account/account-api.ts @@ -4,6 +4,8 @@ import { accountProviderMiddlewareLayer } from "@executor-js/api/server"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import { betterAuthAccountProvider } from "./better-auth-account-provider"; +import type { SelfHostConfig } from "../config"; +import type { SelfHostDbHandle } from "../db/self-host-db"; // --------------------------------------------------------------------------- // Self-host account seam: the per-request `AccountProvider` middleware backed by @@ -21,7 +23,13 @@ import { betterAuthAccountProvider } from "./better-auth-account-provider"; // `requestScopedMiddleware`). // --------------------------------------------------------------------------- -export const selfHostAccountMiddleware = (betterAuth: BetterAuthHandle) => +export const selfHostAccountMiddleware = ( + betterAuth: BetterAuthHandle, + db: SelfHostDbHandle, + config: SelfHostConfig, +) => accountProviderMiddlewareLayer( - betterAuthAccountProvider.pipe(Layer.provide(Layer.succeed(BetterAuth)(betterAuth))), + betterAuthAccountProvider(db, config).pipe( + Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + ), ); diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index 885ed74eee..e8ccd8732c 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -4,6 +4,9 @@ import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; import { AccountError, AccountUnauthorized } from "@executor-js/api"; import { BetterAuth } from "../auth/better-auth"; +import { classifySelfHostDelegatedBoundary } from "../auth/delegated-boundary"; +import type { SelfHostConfig } from "../config"; +import type { SelfHostDbHandle } from "../db/self-host-db"; // --------------------------------------------------------------------------- // Self-host AccountProvider — implements the provider-neutral account surface @@ -34,21 +37,49 @@ const masked = (start: string | null | undefined): string => (start ? `${start} const orgRole = (slug: string | undefined): "owner" | "admin" | "member" => slug === "owner" ? "owner" : slug === "admin" ? "admin" : "member"; -export const betterAuthAccountProvider: Layer.Layer = +export const betterAuthAccountProvider = ( + db: SelfHostDbHandle, + config: SelfHostConfig, +): Layer.Layer => Layer.effect(AccountProvider)( Effect.gen(function* () { - const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; - - const getSession = (headers: AccountHeaders) => - Effect.tryPromise({ - try: () => auth.api.getSession({ headers: toHeaders(headers) }), - catch: () => new AccountError({ message: "Failed to resolve session" }), - }).pipe(Effect.orElseSucceed(() => null)); + const betterAuth = yield* BetterAuth; + const { auth, organizationId, organizationName, organizationSlug } = betterAuth; + + const getSession = (headers: AccountHeaders) => { + return Effect.gen(function* () { + const boundary = yield* classifySelfHostDelegatedBoundary( + db, + betterAuth, + config, + toHeaders(headers), + ); + if (boundary !== "unmapped") return null; + return yield* Effect.tryPromise({ + try: () => auth.api.getSession({ headers: toHeaders(headers) }), + catch: () => new AccountError({ message: "Failed to resolve session" }), + }).pipe(Effect.orElseSucceed(() => null)); + }); + }; // Run a Better Auth api call, mapping any rejection to a neutral // AccountError with a stable, user-facing message. - const call = (message: string, run: () => Promise) => - Effect.tryPromise({ try: run, catch: () => new AccountError({ message }) }); + const call = ( + headers: AccountHeaders, + message: string, + run: () => Promise, + ): Effect.Effect => { + return Effect.gen(function* () { + const boundary = yield* classifySelfHostDelegatedBoundary( + db, + betterAuth, + config, + toHeaders(headers), + ); + if (boundary !== "unmapped") return yield* new AccountUnauthorized(); + return yield* Effect.tryPromise({ try: run, catch: () => new AccountError({ message }) }); + }); + }; return AccountProvider.of({ me: (headers) => @@ -71,7 +102,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to list API keys", () => + call(headers, "Failed to list API keys", () => auth.api.listApiKeys({ headers: toHeaders(headers) }), ).pipe( Effect.map((result) => ({ @@ -87,7 +118,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to create API key", () => + call(headers, "Failed to create API key", () => auth.api.createApiKey({ body: { name }, headers: toHeaders(headers) }), ).pipe( Effect.map((key) => ({ @@ -102,7 +133,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to revoke API key", () => + call(headers, "Failed to revoke API key", () => auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }), ).pipe(Effect.as({ success: true })), @@ -137,7 +168,7 @@ export const betterAuthAccountProvider: Layer.Layer + const result = yield* call(headers, "Failed to list members", () => auth.api.listMembers({ headers: toHeaders(headers) }), ).pipe( Effect.catchTag("AccountError", () => Effect.succeed({ members: [], total: 0 })), @@ -171,7 +202,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to invite member", () => + call(headers, "Failed to invite member", () => auth.api.createInvitation({ // Narrow the free-form slug to the org plugin's role union (no cast). body: { email: body.email, role: orgRole(body.roleSlug) }, @@ -180,7 +211,7 @@ export const betterAuthAccountProvider: Layer.Layer ({ id: invite.id, email: invite.email }))), removeMember: (headers, membershipId) => - call("Failed to remove member", () => + call(headers, "Failed to remove member", () => auth.api.removeMember({ body: { memberIdOrEmail: membershipId }, headers: toHeaders(headers), @@ -188,7 +219,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to update member role", () => + call(headers, "Failed to update member role", () => auth.api.updateMemberRole({ body: { memberId: membershipId, role: roleSlug }, headers: toHeaders(headers), @@ -196,7 +227,7 @@ export const betterAuthAccountProvider: Layer.Layer - call("Failed to update organization name", () => + call(headers, "Failed to update organization name", () => auth.api.updateOrganization({ body: { data: { name }, organizationId }, headers: toHeaders(headers), diff --git a/apps/host-selfhost/src/account/delegation-provider.ts b/apps/host-selfhost/src/account/delegation-provider.ts new file mode 100644 index 0000000000..ca8cf9a7a7 --- /dev/null +++ b/apps/host-selfhost/src/account/delegation-provider.ts @@ -0,0 +1,742 @@ +import { Effect, Layer, Predicate } from "effect"; + +import { + DbProvider, + makeExecutionStack, + makeDelegationProvider, + makeScopedExecutor, + type DelegatedExecutionResumer as DelegatedExecutionResumerShape, + type DelegationAccountHost, + type DelegationAccountKeyStore, + type DelegationHuman, + type DelegationRequestContext, +} from "@executor-js/api/server"; +import { AccountError, AccountForbidden, AccountUnauthorized } from "@executor-js/api"; +import { + StorageError, + Subject, + Tenant, + DelegationId, + CredentialId, + DelegationScopeVersion, + type DelegationScope, + type DelegationService, + type StorageFailure, + type VerifiedDelegatedCredential, +} from "@executor-js/sdk"; + +import type { SelfHostConfig } from "../config"; +import type { BetterAuthHandle } from "../auth/better-auth"; +import { SELF_HOST_DELEGATED_KEY_PREFIX } from "../auth/delegated-key"; +import { makeSelfHostDelegationService } from "../auth/delegation"; +import { + SelfHostExecutionStackLayer, + SelfHostHostConfig, + SelfHostPluginsProvider, +} from "../execution"; +import type { SelfHostPlugins } from "../plugins"; +import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; + +type MemberRow = { + readonly id: string; + readonly userId: string; + readonly organizationId: string; + readonly role: string; +}; +type UserRow = { + readonly id: string; + readonly email: string; + readonly name?: string | null; + readonly image?: string | null; +}; +type ApiKeyRow = { + readonly id: string; + readonly referenceId: string; + readonly enabled?: boolean | null; + readonly expiresAt?: Date | string | number | null; + readonly metadata?: unknown; +}; + +const DELEGATION_KEY_METADATA = "executorDelegationCredentialId"; + +const metadataCredentialId = (metadata: unknown): string | null => { + if (typeof metadata !== "object" || metadata === null) return null; + const value = (metadata as Record)[DELEGATION_KEY_METADATA]; + return typeof value === "string" && value.length > 0 ? value : null; +}; + +const isCurrentApiKey = (key: ApiKeyRow | null): key is ApiKeyRow => { + if (!key || key.enabled === false) return false; + if (key.expiresAt === null || key.expiresAt === undefined) return true; + const expiresAt = + key.expiresAt instanceof Date ? key.expiresAt.getTime() : new Date(key.expiresAt).getTime(); + return Number.isFinite(expiresAt) && expiresAt > Date.now(); +}; + +const failure = (message: string): AccountError => new AccountError({ message }); +const storageFailure = (message: string): StorageError => + new StorageError({ message, operation: "delegation.authority", cause: undefined }); +const headersOf = (request: DelegationRequestContext): Headers => new Headers(request.headers); + +const authorityFailure = (message: string): StorageError => storageFailure(message); + +const sameOrigin = (request: DelegationRequestContext, config: SelfHostConfig): boolean => + (request.browserOrigin === null && (request.method === "GET" || request.method === "HEAD")) || + (request.browserOrigin !== null && + request.browserOrigin === request.requestOrigin && + config.trustedOrigins.includes(request.requestOrigin)); + +const memberFor = ( + betterAuth: BetterAuthHandle, + subject: string, +): Effect.Effect<{ readonly member: MemberRow; readonly user: UserRow } | null, AccountError> => + Effect.tryPromise({ + try: async () => { + const context = await betterAuth.auth.$context; + const member = await context.adapter.findOne({ + model: "member", + where: [ + { field: "userId", value: subject }, + { field: "organizationId", value: betterAuth.organizationId }, + ], + }); + if (!member) return null; + const user = await context.internalAdapter.findUserById(subject); + if (!user) return null; + return { + member, + user: { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + }, + }; + }, + catch: () => failure("Failed to read the Better Auth membership."), + }); + +const memberForActor = ( + betterAuth: BetterAuthHandle, + subject: string, +): Effect.Effect<{ readonly member: MemberRow; readonly user: UserRow } | null, AccountError> => { + if (!subject.startsWith("machine:")) return memberFor(betterAuth, subject); + const keyId = subject.slice("machine:".length); + return Effect.tryPromise({ + try: async () => { + const context = await betterAuth.auth.$context; + const key = await context.adapter.findOne({ + model: "apikey", + where: [{ field: "id", value: keyId }], + }); + if (!isCurrentApiKey(key)) return null; + return key.referenceId; + }, + catch: () => failure("Failed to read the Better Auth API key."), + }).pipe(Effect.flatMap((owner) => (owner ? memberFor(betterAuth, owner) : Effect.succeed(null)))); +}; + +const targetFor = (betterAuth: BetterAuthHandle, subject: string, currentSubject: string) => + memberFor(betterAuth, subject).pipe( + Effect.map((record) => + record + ? { + id: record.user.id, + email: record.user.email, + name: record.user.name ?? null, + isCurrentUser: record.user.id === currentSubject, + status: "verified" as const, + } + : null, + ), + ); + +const targetListFor = ( + betterAuth: BetterAuthHandle, + currentSubject: string, +): Effect.Effect< + readonly (typeof import("@executor-js/api").DelegationTarget.Type)[], + AccountError +> => + Effect.tryPromise({ + try: async () => { + const context = await betterAuth.auth.$context; + const members = await context.adapter.findMany({ + model: "member", + where: [{ field: "organizationId", value: betterAuth.organizationId }], + }); + const targets = await Promise.all( + members.map(async (member) => { + const user = await context.internalAdapter.findUserById(member.userId); + if (!user) return null; + return { + id: user.id, + email: user.email, + name: user.name ?? null, + isCurrentUser: user.id === currentSubject, + status: "verified" as const, + }; + }), + ); + return targets.filter(Predicate.isNotNull); + }, + catch: () => failure("Failed to read the Better Auth membership directory."), + }); + +const isPrivilegedRole = (role: string): boolean => role === "owner" || role === "admin"; + +const providerKeyStore = (betterAuth: BetterAuthHandle): DelegationAccountKeyStore => { + const withContext = ( + operation: string, + run: (context: Awaited) => Promise, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => run(await betterAuth.auth.$context), + catch: () => failure(`Failed to ${operation}.`), + }); + + const deleteKey = (keyId: string) => + withContext("revoke the Better Auth API key", async (context) => { + await context.adapter.delete({ + model: "apikey", + where: [{ field: "id", value: keyId }], + }); + }); + + const keyMetadataFor = (credentialId: string) => + withContext("find the Better Auth delegation key", async (context) => { + const rows = await context.adapter.findMany({ model: "apikey", where: [] }); + return rows.filter((row) => metadataCredentialId(row.metadata) === credentialId); + }); + + const issue = ( + _request: DelegationRequestContext, + input: { readonly targetSubject: string; readonly label: string; readonly expiresAt: string }, + ) => { + return Effect.tryPromise< + Awaited>, + AccountError + >({ + try: () => + betterAuth.auth.api.createApiKey({ + body: { + name: input.label.slice(0, 32), + prefix: SELF_HOST_DELEGATED_KEY_PREFIX, + metadata: { executorDelegationPending: true }, + // Better Auth's server-only userId field makes the provider + // credential belong to the selected verified target, rather than + // accidentally binding it to the human administrator's session. + userId: input.targetSubject, + }, + }), + catch: () => failure("Failed to create the Better Auth delegation key."), + }).pipe( + Effect.map((key) => ({ + credential: { + kind: "issued-key" as const, + adapter: "better-auth-api-key", + issuer: betterAuth.auth.options.baseURL, + audience: betterAuth.organizationId, + subject: key.referenceId, + credentialRef: key.id, + }, + actorSubject: `machine:${key.id}`, + secret: key.key, + })), + ); + }; + + return { + issue: (request, input) => issue(request, input), + bind: (_request, input) => + Effect.gen(function* () { + const context = yield* Effect.tryPromise({ + try: () => betterAuth.auth.$context, + catch: () => failure("Failed to bind the Better Auth delegation key."), + }); + const current = yield* Effect.tryPromise({ + try: () => + context.adapter.findOne({ + model: "apikey", + where: [{ field: "id", value: input.credential.credentialRef }], + }), + catch: () => failure("Failed to bind the Better Auth delegation key."), + }); + if (!current) return yield* failure("The Better Auth delegation key disappeared."); + yield* Effect.tryPromise({ + try: () => + context.adapter.update({ + model: "apikey", + where: [{ field: "id", value: input.credential.credentialRef }], + update: { + metadata: { + executorDelegationCredentialId: input.credentialId, + executorDelegationTarget: input.targetSubject, + }, + }, + }), + catch: () => failure("Failed to bind the Better Auth delegation key."), + }); + }), + rotate: (request, input) => + Effect.gen(function* () { + const previous = yield* keyMetadataFor(input.credentialId); + yield* Effect.forEach(previous, (key) => deleteKey(key.id)); + return yield* issue(request, input); + }), + revokeBound: (_request, input) => + keyMetadataFor(input.credentialId).pipe( + Effect.flatMap((keys) => Effect.forEach(keys, (key) => deleteKey(key.id))), + Effect.asVoid, + ), + // A proof-bound existing Better Auth PAT remains an ordinary user PAT. + // Revoking its delegation must leave the provider credential intact so + // boundary lookups can observe the authoritative revoked grant and deny it + // even when a browser cookie is also present. Provider-issued keys are + // owned by this lifecycle and can be deleted on revoke. + revoke: (_request, input) => + input.credential.kind === "issued-key" + ? deleteKey(input.credential.credentialRef) + : Effect.void, + }; +}; + +const makeSelfHostAuthority = (betterAuth: BetterAuthHandle, requestHeaders: Headers) => ({ + isVerifiedHuman: (subject: Subject) => + memberFor(betterAuth, String(subject)).pipe( + Effect.map(Boolean), + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ), + isCurrentlyAuthorized: (grant: { + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }) => + Effect.gen(function* () { + const actor = yield* memberForActor(betterAuth, String(grant.actorSubject)).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + const target = yield* memberFor(betterAuth, String(grant.effectiveSubject)).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + if (!actor || !target) return false; + if (actor.member.userId === target.member.userId) return true; + return isPrivilegedRole(actor.member.role); + }), + isManagementAuthorized: (actorSubject: Subject, targetSubject?: Subject) => + Effect.gen(function* () { + const actor = yield* memberFor(betterAuth, String(actorSubject)).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + if (!actor) return false; + return ( + String(actorSubject) === String(targetSubject ?? actorSubject) || + isPrivilegedRole(actor.member.role) + ); + }), + verifyProof: (input: { + readonly credential: VerifiedDelegatedCredential; + readonly machineActor: Subject; + }) => { + const raw = requestHeaders.get("authorization") ?? requestHeaders.get("x-api-key"); + const key = raw?.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; + if (!key) return Effect.succeed(false); + return Effect.tryPromise< + Awaited>, + StorageError + >({ + try: () => + betterAuth.auth.api.verifyApiKey({ + body: { key }, + headers: requestHeaders, + }), + catch: () => authorityFailure("Failed to verify the Better Auth API key."), + }).pipe( + Effect.map( + (result) => + result.valid && + result.key !== null && + result.key.id === input.credential.credentialRef && + result.key.referenceId === input.credential.subject && + `machine:${result.key.id}` === String(input.machineActor), + ), + ); + }, + verifyProviderCredential: (input: { + readonly credential: VerifiedDelegatedCredential; + readonly actorSubject: Subject; + readonly effectiveSubject: Subject; + }) => + Effect.gen(function* () { + if ( + input.credential.kind !== "issued-key" || + !String(input.actorSubject).startsWith("machine:") + ) + return false; + const keyId = String(input.actorSubject).slice("machine:".length); + if (keyId !== input.credential.credentialRef) return false; + const key = yield* Effect.tryPromise({ + try: async () => { + const context = await betterAuth.auth.$context; + return context.adapter.findOne({ + model: "apikey", + where: [{ field: "id", value: keyId }], + }); + }, + catch: () => authorityFailure("Failed to read the Better Auth API key."), + }); + if (!isCurrentApiKey(key) || key.referenceId !== input.credential.subject) return false; + const actor = yield* memberForActor(betterAuth, String(input.actorSubject)).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + const target = yield* memberFor(betterAuth, String(input.effectiveSubject)).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + if (!actor || !target) return false; + return actor.member.userId === target.member.userId || isPrivilegedRole(actor.member.role); + }), +}); + +/** Build the live self-host delegation service for a trusted HTTP request. */ +export const makeSelfHostDelegationServiceForHeaders = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + config: SelfHostConfig, + requestHeaders: Headers, +): DelegationService => + makeSelfHostDelegationService( + db, + betterAuth, + config, + makeSelfHostAuthority(betterAuth, requestHeaders), + ); + +/** + * Attribute a browser approval to the verified Better Auth human while keeping + * the stored delegated grant as the execution identity. The binding is + * resolved first, so a revoked/expired/scope-changed grant cannot receive a + * successful approval audit or wake the paused execution. + */ +export const auditSelfHostBrowserApproval = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + config: SelfHostConfig, + input: { + readonly owner: Extract< + import("@executor-js/host-mcp").Principal, + { readonly kind: "delegated" } + >; + readonly approver: Extract< + import("@executor-js/host-mcp").Principal, + { readonly kind?: "member" } + >; + readonly executionId: string; + readonly response: { readonly action: "accept" | "decline" | "cancel" }; + }, + requestHeaders: Headers, +): Effect.Effect => { + const service = makeSelfHostDelegationServiceForHeaders(db, betterAuth, config, requestHeaders); + return Effect.gen(function* () { + const approver = yield* memberFor(betterAuth, input.approver.accountId).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + if ( + !approver || + (approver.user.id !== input.owner.effectiveSubject && !isPrivilegedRole(approver.member.role)) + ) { + return yield* authorityFailure("The browser approver is no longer authorized."); + } + const tenant = Tenant.make(input.owner.tenant); + const grantId = DelegationId.make(input.owner.grantId); + const credentialId = CredentialId.make(input.owner.credentialId); + const effectiveSubject = Subject.make(input.owner.effectiveSubject); + yield* service.resolveBinding({ + tenant, + grantId, + credentialId, + credentialRef: input.owner.credentialRef, + actorSubject: Subject.make(input.owner.actorSubject), + effectiveSubject, + scopeVersion: DelegationScopeVersion.make(input.owner.scopeVersion), + }); + yield* service.audit({ + grantId, + credentialId, + actorSubject: Subject.make(input.approver.accountId), + effectiveSubject, + action: "approval", + outcome: input.response.action === "accept" ? "allowed" : "denied", + executionId: input.executionId, + }); + }); +}; + +/** + * Rebuild the original grant-scoped execution stack when a human approves an + * HTTP execution after the request that paused it has ended. The approver is + * checked separately from the stored machine binding; the rebuilt executor + * retains the original delegated grant and therefore cannot inherit the + * approver's broader workspace access. + */ +export const makeSelfHostDelegatedExecutionResumer = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + config: SelfHostConfig, +): DelegatedExecutionResumerShape => { + const serviceFor = () => + makeSelfHostDelegationServiceForHeaders(db, betterAuth, config, new Headers()); + + return { + authorize: (input) => + Effect.gen(function* () { + if (input.approver.accountId === null) return false; + const approver = yield* memberFor(betterAuth, input.approver.accountId).pipe( + Effect.mapError(() => authorityFailure("Better Auth membership lookup failed.")), + ); + if (!approver) return false; + if ( + input.approver.accountId !== String(input.binding.effectiveSubject) && + !isPrivilegedRole(approver.member.role) + ) + return false; + yield* serviceFor().resolveBinding(input.binding); + return true; + }), + resume: (input) => + Effect.gen(function* () { + if (input.approver.accountId === null) return null; + const service = serviceFor(); + const grant = yield* service.resolveBinding(input.binding); + yield* service.audit({ + grantId: grant.grantId, + credentialId: grant.credentialId, + actorSubject: Subject.make(input.approver.accountId), + effectiveSubject: grant.effectiveSubject, + action: "approval", + outcome: "allowed", + executionId: input.executionId, + }); + const stack = yield* makeExecutionStack( + String(grant.effectiveSubject), + String(grant.tenant), + betterAuth.organizationName, + { delegation: grant }, + ).pipe( + Effect.provide( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + ), + ); + return yield* stack.engine + .executeWithPause(input.code, { autoApprove: true }) + .pipe(Effect.ensuring(stack.engine.shutdown)); + }), + }; +}; + +const scopeSnapshot = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + targetSubject: string, + scope: typeof import("@executor-js/api").DelegationScope.Type, +) => { + const executorEffect = makeScopedExecutor( + targetSubject, + betterAuth.organizationId, + betterAuth.organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: db.db, fuma: db.fuma, close: db.close }), + SelfHostPluginsProvider, + SelfHostHostConfig, + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(scope.toolkitIds, (toolkitId) => { + const toolkit = toolkits.find((item) => item.id === toolkitId); + if (!toolkit) return Effect.fail(failure("Delegation toolkit is unavailable.")); + return executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe( + Effect.flatMap((snapshot) => + snapshot + ? Effect.succeed(snapshot) + : Effect.fail(failure("Delegation toolkit is unavailable.")), + ), + ); + }); + const connections = + scope.connectionIds ?? + snapshots.flatMap((snapshot) => snapshot.connectionAddresses.map(String)); + return { + actions: scope.scopes, + toolkitMembership: snapshots, + toolAddresses: snapshots.flatMap((snapshot) => snapshot.toolAddresses), + connectionAddresses: connections, + } satisfies DelegationScope; + }).pipe(Effect.mapError(() => failure("Failed to build the delegated scope snapshot."))); +}; + +/** Build the same live target catalog used by scopeSnapshot, projecting only + * stable ids and display metadata for the human consent form. Labels are + * presentation data; only the returned ids are accepted by scope creation. */ +const scopeOptions = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + targetSubject: string, + config: Pick, +) => { + const executorEffect = makeScopedExecutor( + targetSubject, + betterAuth.organizationId, + betterAuth.organizationName, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DbProvider)({ db: db.db, fuma: db.fuma, close: db.close }), + SelfHostPluginsProvider, + SelfHostHostConfig, + ), + ), + ); + return Effect.gen(function* () { + const executor = yield* executorEffect; + const toolkits = yield* executor.toolkits.list(); + const snapshots = yield* Effect.forEach(toolkits, (toolkit) => + executor.toolkits + .snapshotForSlug(toolkit.slug) + .pipe(Effect.map((snapshot) => ({ toolkit, snapshot }))), + ); + const activeSnapshots = snapshots.filter( + (entry): entry is typeof entry & { readonly snapshot: NonNullable } => + entry.snapshot !== null, + ); + const addressToToolkits = new Map(); + for (const { toolkit, snapshot } of activeSnapshots) { + for (const address of snapshot.connectionAddresses) { + const id = String(address); + const toolkitIds = addressToToolkits.get(id) ?? []; + toolkitIds.push(String(toolkit.id)); + addressToToolkits.set(id, toolkitIds); + } + } + const connections = (yield* executor.connections.list()) + .filter((connection) => addressToToolkits.has(String(connection.address))) + .map((connection) => ({ + id: String(connection.address), + label: + connection.identityLabel ?? + `${String(connection.integration)} · ${String(connection.name)}`, + description: connection.description ?? null, + toolkitIds: addressToToolkits.get(String(connection.address)) ?? [], + })); + return { + actions: ["discover", "invoke"] as const, + toolkits: activeSnapshots.map(({ toolkit }) => ({ + id: String(toolkit.id), + slug: String(toolkit.slug), + name: String(toolkit.name), + })), + connections, + maxExpiresAt: new Date(Date.now() + config.delegatedMaxTtlMs).toISOString(), + }; + }).pipe(Effect.mapError(() => failure("Failed to read delegation scope choices."))); +}; + +const recentHuman = ( + betterAuth: BetterAuthHandle, + request: DelegationRequestContext, + config: SelfHostConfig, +): Effect.Effect => { + if (!sameOrigin(request, config)) + return Effect.fail( + new AccountForbidden({ + message: "Delegation management requires a same-origin browser request.", + }), + ); + if (!request.headers.cookie || request.headers.authorization || request.headers["x-api-key"]) { + return Effect.fail(new AccountUnauthorized()); + } + return Effect.promise(() => betterAuth.auth.api.getSession({ headers: headersOf(request) })).pipe( + Effect.flatMap( + ( + session, + ): Effect.Effect => { + if (!session) return Effect.fail(new AccountUnauthorized()); + const createdAt = new Date(session.session.createdAt).getTime(); + const recent = + Number.isSafeInteger(createdAt) && + Date.now() >= createdAt && + Date.now() - createdAt <= config.delegatedRecentAuthMaxAgeMs; + return memberFor(betterAuth, session.user.id).pipe( + Effect.flatMap((record) => + record + ? Effect.succeed({ + subject: session.user.id, + organizationId: betterAuth.organizationId, + recent, + canFilterTargets: isPrivilegedRole(record.member.role), + }) + : Effect.fail( + new AccountForbidden({ message: "The current user is no longer a member." }), + ), + ), + ); + }, + ), + ); +}; + +export const selfHostDelegationProvider = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + config: SelfHostConfig, +) => { + const host: DelegationAccountHost = { + human: (request) => recentHuman(betterAuth, request, config), + service: (request) => + Effect.succeed( + makeSelfHostDelegationServiceForHeaders(db, betterAuth, config, headersOf(request)), + ), + targets: (request) => + recentHuman(betterAuth, request, config).pipe( + Effect.flatMap((human) => targetListFor(betterAuth, human.subject)), + ), + // The shared provider calls this after a machine has completed possession + // proof. Human-gated operations authorize the target before reaching this + // lookup; completion must remain usable with the machine's API key and no + // browser cookie. + target: (_request, subject) => targetFor(betterAuth, subject, subject), + scopeOptions: (_request, targetSubject) => scopeOptions(db, betterAuth, targetSubject, config), + scope: (_request, targetSubject, scope) => scopeSnapshot(db, betterAuth, targetSubject, scope), + proof: (request, adapter) => { + const raw = request.headers.authorization ?? request.headers["x-api-key"]; + const key = raw?.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; + if (!key) return Effect.fail(new AccountUnauthorized()); + return Effect.tryPromise({ + try: () => betterAuth.auth.api.verifyApiKey({ body: { key }, headers: headersOf(request) }), + catch: () => new AccountUnauthorized(), + }).pipe( + Effect.flatMap((result) => { + if (!result.valid || !result.key) return Effect.fail(new AccountUnauthorized()); + const credential: VerifiedDelegatedCredential = { + kind: "external", + adapter, + issuer: betterAuth.auth.options.baseURL, + audience: betterAuth.organizationId, + subject: result.key.referenceId, + credentialRef: result.key.id, + }; + return Effect.succeed({ credential, machineActor: `machine:${result.key.id}` }); + }), + ); + }, + externalAdapters: ["better-auth-api-key"], + providerKeys: providerKeyStore(betterAuth), + localKeysEnabled: true, + issuanceEnabled: config.delegatedIssuanceEnabled, + }; + return makeDelegationProvider(host); +}; diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 8a4d458c52..ca1c19e99c 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -54,6 +54,7 @@ import type { Executor } from "@executor-js/sdk"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import { requireInstanceAdmin } from "./require-admin"; import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db"; +import type { SelfHostConfig } from "../config"; import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; /** @@ -63,8 +64,8 @@ import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; * of some OTHER organization: this plane reports every user in the instance, so * the org it authorizes against is the instance's own (see require-admin.ts). */ -const requireAdmin = (headers: AdminUsersHeaders) => - requireInstanceAdmin(new Headers(headers)).pipe( +const requireAdmin = (headers: AdminUsersHeaders, db: SelfHostDbHandle, config: SelfHostConfig) => + requireInstanceAdmin(new Headers(headers), db, config).pipe( Effect.mapError((denial) => denial === "unauthorized" ? new AdminUsersUnauthorized() : new AdminUsersForbidden(), ), @@ -73,6 +74,8 @@ const requireAdmin = (headers: AdminUsersHeaders) => const withPlatformView = ( headers: AdminUsersHeaders, organizationId: string, + db: SelfHostDbHandle, + config: SelfHostConfig, body: (executor: Executor) => Effect.Effect, ): Effect.Effect< A, @@ -82,52 +85,58 @@ const withPlatformView = => Effect.gen(function* () { - yield* requireAdmin(headers); + yield* requireAdmin(headers, db, config); const executor = yield* makePlatformExecutor(organizationId).pipe( Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), ); return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore)); }); -export const betterAuthAdminUsersProvider: Layer.Layer< +export const betterAuthAdminUsersProvider = ( + config: SelfHostConfig, +): Layer.Layer< AdminUsersProvider, never, - BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig -> = Layer.effect(AdminUsersProvider)( - Effect.gen(function* () { - const context = yield* Effect.context(); - const { organizationId } = yield* BetterAuth; - // Scoped to the INSTANCE's org — the same one the platform view is opened - // for, never the caller's `activeOrganizationId` (see require-admin.ts). - const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId); - return AdminUsersProvider.of({ - listUsers: (headers, options) => - withPlatformView(headers, organizationId, (executor) => - platformViewOf(executor).pipe( - Effect.flatMap((admin) => listAdminUsers(admin, options, directory)), - ), - ).pipe(Effect.provideContext(context)), - listUsersWithConnections: (headers, options) => - withPlatformView(headers, organizationId, (executor) => - platformViewOf(executor).pipe( - Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), - ), - ).pipe(Effect.provideContext(context)), - listUserConnections: (headers, externalId) => - withPlatformView(headers, organizationId, (executor) => - platformViewOf(executor).pipe( - Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), - ), - ).pipe(Effect.provideContext(context)), - getUser: (headers, identifier) => - withPlatformView(headers, organizationId, (executor) => - platformViewOf(executor).pipe( - Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), - ), - ).pipe(Effect.provideContext(context)), - }); - }), -); + BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig | SelfHostDb +> => + Layer.effect(AdminUsersProvider)( + Effect.gen(function* () { + const context = yield* Effect.context< + BetterAuth | DbProvider | PluginsProvider | HostConfig | SelfHostDb + >(); + const { organizationId } = yield* BetterAuth; + const db = yield* SelfHostDb; + // Scoped to the INSTANCE's org — the same one the platform view is opened + // for, never the caller's `activeOrganizationId` (see require-admin.ts). + const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId); + return AdminUsersProvider.of({ + listUsers: (headers, options) => + withPlatformView(headers, organizationId, db, config, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUsers(admin, options, directory)), + ), + ).pipe(Effect.provideContext(context)), + listUsersWithConnections: (headers, options) => + withPlatformView(headers, organizationId, db, config, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), + ), + ).pipe(Effect.provideContext(context)), + listUserConnections: (headers, externalId) => + withPlatformView(headers, organizationId, db, config, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), + ), + ).pipe(Effect.provideContext(context)), + getUser: (headers, identifier) => + withPlatformView(headers, organizationId, db, config, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), + ), + ).pipe(Effect.provideContext(context)), + }); + }), + ); export interface SelfHostAdminUsersApiDeps { readonly betterAuth: BetterAuthHandle; @@ -135,6 +144,7 @@ export interface SelfHostAdminUsersApiDeps { * plane reads the same directory instance every other plane does. */ readonly memberDirectory: Layer.Layer; readonly db: SelfHostDbHandle; + readonly config: SelfHostConfig; readonly mountPrefix: `/${string}`; } @@ -149,12 +159,13 @@ export const makeSelfHostAdminUsersApiLayer = ({ betterAuth, memberDirectory, db, + config, mountPrefix, }: SelfHostAdminUsersApiDeps) => { const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), ); - const provider = betterAuthAdminUsersProvider.pipe( + const provider = betterAuthAdminUsersProvider(config).pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), Layer.provide(memberDirectory), Layer.provide(SelfHostDbProvider), diff --git a/apps/host-selfhost/src/admin/handlers.ts b/apps/host-selfhost/src/admin/handlers.ts index b41f7a7fd0..23208aba47 100644 --- a/apps/host-selfhost/src/admin/handlers.ts +++ b/apps/host-selfhost/src/admin/handlers.ts @@ -12,6 +12,7 @@ import { import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import { requireInstanceAdmin } from "./require-admin"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; +import type { SelfHostConfig } from "../config"; import { createInviteCode, listInviteCodes, @@ -38,8 +39,8 @@ const requestHeaders = Effect.map( // Resolve + authorize the caller, rendering the shared gate's denial in THIS // plane's error vocabulary (the HttpApi group declares these, not the users // plane's). -const requireAdmin = (headers: Headers) => - requireInstanceAdmin(headers).pipe( +const requireAdmin = (headers: Headers, db: SelfHostDbHandle, config: SelfHostConfig) => + requireInstanceAdmin(headers, db, config).pipe( Effect.mapError((denial) => denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(), ), @@ -60,55 +61,60 @@ const toWire = (row: InviteCodeRow): typeof InviteCodeSchema.Type => ({ usedAt: row.usedAt, }); -export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handlers) => - handlers - .handle("listInvites", () => - Effect.gen(function* () { - yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; - const rows = yield* Effect.tryPromise({ - try: () => listInviteCodes(client), - catch: () => new AdminError({ message: "Failed to list invites" }), - }); - return { invites: rows.map(toWire) }; - }), - ) - .handle("createInvite", ({ payload }) => - Effect.gen(function* () { - const member = yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; - const days = payload.expiresInDays ?? null; - const expiresAt = - days && days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : null; - const row = yield* Effect.tryPromise({ - try: () => - createInviteCode(client, { - createdBy: member.userId, - role: narrowRole(payload.role), - label: payload.label?.trim() ? payload.label.trim() : null, - expiresAt, - }), - catch: () => new AdminError({ message: "Failed to create invite" }), - }); - return toWire(row); - }), - ) - .handle("revokeInvite", ({ params }) => - Effect.gen(function* () { - yield* requireAdmin(yield* requestHeaders); - const { client } = yield* SelfHostDb; - yield* Effect.tryPromise({ - try: () => revokeInviteCode(client, params.inviteId), - catch: () => new AdminError({ message: "Failed to revoke invite" }), - }); - return { success: true }; - }), - ), -); +const makeAdminHandlers = (config: SelfHostConfig) => + HttpApiBuilder.group(AdminHttpApi, "admin", (handlers) => + handlers + .handle("listInvites", () => + Effect.gen(function* () { + const db = yield* SelfHostDb; + yield* requireAdmin(yield* requestHeaders, db, config); + const { client } = db; + const rows = yield* Effect.tryPromise({ + try: () => listInviteCodes(client), + catch: () => new AdminError({ message: "Failed to list invites" }), + }); + return { invites: rows.map(toWire) }; + }), + ) + .handle("createInvite", ({ payload }) => + Effect.gen(function* () { + const db = yield* SelfHostDb; + const member = yield* requireAdmin(yield* requestHeaders, db, config); + const { client } = db; + const days = payload.expiresInDays ?? null; + const expiresAt = + days && days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : null; + const row = yield* Effect.tryPromise({ + try: () => + createInviteCode(client, { + createdBy: member.userId, + role: narrowRole(payload.role), + label: payload.label?.trim() ? payload.label.trim() : null, + expiresAt, + }), + catch: () => new AdminError({ message: "Failed to create invite" }), + }); + return toWire(row); + }), + ) + .handle("revokeInvite", ({ params }) => + Effect.gen(function* () { + const db = yield* SelfHostDb; + yield* requireAdmin(yield* requestHeaders, db, config); + const { client } = db; + yield* Effect.tryPromise({ + try: () => revokeInviteCode(client, params.inviteId), + catch: () => new AdminError({ message: "Failed to revoke invite" }), + }); + return { success: true }; + }), + ), + ); export interface SelfHostAdminApiDeps { readonly betterAuth: BetterAuthHandle; readonly db: SelfHostDbHandle; + readonly config: SelfHostConfig; readonly mountPrefix: `/${string}`; } @@ -124,13 +130,14 @@ export interface SelfHostAdminApiDeps { export const makeSelfHostAdminApiLayer = ({ betterAuth, db, + config, mountPrefix, }: SelfHostAdminApiDeps) => { const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), ); return HttpApiBuilder.layer(AdminHttpApi).pipe( - Layer.provide(AdminHandlers), + Layer.provide(makeAdminHandlers(config)), Layer.provide(prefixedRouter), HttpRouter.provideRequest( Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), diff --git a/apps/host-selfhost/src/admin/require-admin.ts b/apps/host-selfhost/src/admin/require-admin.ts index 794f2170ed..be0ed807e7 100644 --- a/apps/host-selfhost/src/admin/require-admin.ts +++ b/apps/host-selfhost/src/admin/require-admin.ts @@ -48,6 +48,9 @@ import { Effect } from "effect"; import { BetterAuth } from "../auth/better-auth"; +import { classifySelfHostDelegatedBoundary } from "../auth/delegated-boundary"; +import type { SelfHostConfig } from "../config"; +import type { SelfHostDbHandle } from "../db/self-host-db"; /** * Why the caller was refused, in the vocabulary both planes share. @@ -108,9 +111,21 @@ export const isPrivileged = (role: string): boolean => */ export const requireInstanceAdmin = ( headers: Headers, + db: SelfHostDbHandle, + config: SelfHostConfig, ): Effect.Effect => Effect.gen(function* () { - const { auth, organizationId } = yield* BetterAuth; + const betterAuth = yield* BetterAuth; + const { auth, organizationId } = betterAuth; + const delegatedBoundary = yield* classifySelfHostDelegatedBoundary( + db, + betterAuth, + config, + headers, + ); + if (delegatedBoundary !== "unmapped") { + return yield* Effect.fail("forbidden"); + } const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe( Effect.orElseSucceed(() => null), diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 18bcdf9fc2..4ea963ea58 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -17,6 +17,10 @@ import { makeSelfHostAdminApiLayer } from "./admin/handlers"; import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; +import { + makeSelfHostDelegatedExecutionResumer, + selfHostDelegationProvider, +} from "./account/delegation-provider"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; import { selfHostAnalytics, SelfHostAnalyticsEngineDecorator } from "./analytics"; @@ -101,7 +105,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { plugins: selfHostPlugins, providers: { identity: identityLayer, - account: selfHostAccountMiddleware(betterAuth), + account: selfHostAccountMiddleware(betterAuth, dbHandle, config), + delegation: selfHostDelegationProvider(dbHandle, betterAuth, config), db: SelfHostDbProvider, engine: { codeExecutor: SelfHostCodeExecutorProvider, @@ -127,7 +132,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // session-cookie-gated, delegating to the in-process MCP store. HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. - makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeSelfHostAdminApiLayer({ + betterAuth, + db: dbHandle, + config, + mountPrefix: "/api", + }), // Tenant-wide admin users API (/api/admin/users*): the owner's view of // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. @@ -135,6 +145,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { betterAuth, memberDirectory: memberDirectoryLayer, db: dbHandle, + config, mountPrefix: "/api", }), // Public system API: /api/health + /api/setup-status (unauthenticated). @@ -144,6 +155,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { ], }, config: { mountPrefix: "/api", failure: textFailureStrategy }, + delegatedExecutionResumer: makeSelfHostDelegatedExecutionResumer(dbHandle, betterAuth, config), // The boot-scoped context provideMerge'd under everything: the long-lived DB // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the // resolved identity (captured once by the execution middleware + MCP auth) diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index 870552b93b..7bdf18e3a5 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -15,8 +15,10 @@ process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; +process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT = "true"; process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; +process.env.EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE = "true"; const { makeSelfHostApiHandler } = await import("../app"); @@ -169,6 +171,12 @@ test("sign-up issues a bearer token and resolves to a per-user org-pinned identi expect(body.user.email).toBe("member@test.local"); expect(body.organization).not.toBeNull(); expect(body.organization!.id).toBeTruthy(); + const humanBearerTools = await handler( + new Request(`${BASE}/api/tools`, { + headers: { authorization: `Bearer ${token}` }, + }), + ); + expect(humanBearerTools.status).toBe(200); }); test("self-host API keys are not capped by Better Auth's default request limit", async () => { @@ -208,6 +216,601 @@ test("self-host API keys are not capped by Better Auth's default request limit", } }); +test("delegation scope options return a target-scoped named catalog", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const response = await handler( + new Request(`${origin}/api/account/delegated-credentials/scope-options`, { + headers: { cookie: cookie!, origin }, + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + actions: string[]; + toolkits: Array<{ id: string; slug: string; name: string }>; + connections: Array<{ + id: string; + label: string; + description: string | null; + toolkitIds: string[]; + }>; + maxExpiresAt: string; + }; + expect(body.actions).toEqual(["discover", "invoke"]); + expect(body.toolkits).toEqual(expect.any(Array)); + expect(body.connections).toEqual(expect.any(Array)); + expect(Number.isFinite(Date.parse(body.maxExpiresAt))).toBe(true); + expect(Date.parse(body.maxExpiresAt)).toBeGreaterThan(Date.now()); +}); + +test("origin-less browser reads can list delegation targets while mutations stay CSRF-bound", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + + const targets = await handler( + new Request(`${origin}/api/account/delegated-credentials/targets`, { + headers: { cookie: cookie! }, + }), + ); + expect(targets.status).toBe(200); + + const mutation = await handler( + new Request(`${origin}/api/account/delegated-credentials/keys`, { + method: "POST", + headers: { cookie: cookie!, "content-type": "application/json" }, + body: JSON.stringify({ + label: "originless mutation", + target: { kind: "verified-human", id: "self" }, + scope: { scopes: ["discover"], toolkitIds: [], connectionIds: [] }, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + }), + ); + expect(mutation.status).toBe(403); +}); + +test("an admin-issued provider key is owned by the selected member", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const humanHeaders = { cookie: cookie!, origin, "content-type": "application/json" }; + const targetsResponse = await handler( + new Request(`${origin}/api/account/delegated-credentials/targets`, { + headers: humanHeaders, + }), + ); + expect(targetsResponse.status).toBe(200); + const targets = (await targetsResponse.json()) as { + targets: Array<{ id: string; email: string; isCurrentUser: boolean }>; + }; + const member = targets.targets.find((target) => target.email === "member@test.local"); + expect(member).toBeTruthy(); + + const scope = { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] } as const; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const createdResponse = await handler( + new Request(`${origin}/api/account/delegated-credentials/keys`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ + label: "member-admin-issued-machine", + target: { kind: "verified-human", id: member!.id }, + scope, + expiresAt, + }), + }), + ); + expect(createdResponse.status).toBe(200); + const created = (await createdResponse.json()) as { + credential: { id: string }; + value: string; + }; + expect(created.value).toBeTruthy(); + + const { createClient } = await import("@libsql/client"); + const db = createClient({ + url: `file:${join(process.env.EXECUTOR_DATA_DIR!, "data.db")}`, + }); + const rows = ( + await db.execute({ + sql: "SELECT referenceId, metadata FROM apikey WHERE metadata LIKE ?", + args: [`%${created.credential.id}%`], + }) + ).rows.flatMap((row) => { + const referenceId = row.referenceId; + if (typeof referenceId !== "string") return []; + const metadata = row.metadata; + return [{ referenceId, metadata: typeof metadata === "string" ? metadata : null }]; + }); + const bound = rows.find((row) => row.metadata?.includes(created.credential.id)); + expect(bound?.referenceId).toBe(member!.id); + const notifications = ( + await db.execute({ + sql: "SELECT target_subject FROM delegation_notification WHERE credential_id = ?", + args: [created.credential.id], + }) + ).rows.flatMap((row) => { + const targetSubject = row.target_subject; + return typeof targetSubject === "string" ? [targetSubject] : []; + }); + expect(notifications).toContain(member!.id); + db.close(); + + const revoke = await handler( + new Request(`${origin}/api/account/delegated-credentials/${created.credential.id}`, { + method: "DELETE", + headers: humanHeaders, + }), + ); + expect(revoke.status).toBe(200); +}); + +test("a Better Auth API key completes delegated proof, scopes HTTP, and revokes live", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const humanHeaders = { + cookie: cookie!, + origin, + "content-type": "application/json", + }; + const keyResponse = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ name: "delegation-machine" }), + }), + ); + expect(keyResponse.status).toBe(200); + const keyBody = (await keyResponse.json()) as { value: string }; + const scope = { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] } as const; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const start = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ + adapter: "better-auth-api-key", + label: "delegation-machine", + target: { kind: "self" }, + scope, + expiresAt, + }), + }), + ); + expect(start.status).toBe(200); + const challenge = (await start.json()) as { id: string; nonce: string }; + const machineHeaders = { + authorization: `Bearer ${keyBody.value}`, + "content-type": "application/json", + }; + const proof = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges/${challenge.id}/proof`, { + method: "POST", + headers: machineHeaders, + body: JSON.stringify({ nonce: challenge.nonce, proofType: "api-key" }), + }), + ); + expect(proof.status).toBe(200); + const consent = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges/${challenge.id}/consent`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ scope, expiresAt }), + }), + ); + expect(consent.status).toBe(200); + + // Proof-bound ordinary Better Auth PATs keep their existing token format. + // They still must not become the owner's unrestricted Better Auth session at + // raw auth routes when a valid browser cookie is present. + const boundRawApiKeyCreate = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { + ...machineHeaders, + cookie: cookie!, + }, + body: JSON.stringify({ name: "bound-must-not-mint" }), + }), + ); + expect(boundRawApiKeyCreate.status).toBe(403); + const boundRawOrganizationUpdate = await handler( + new Request(`${origin}/api/auth/organization/update`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie! }, + body: JSON.stringify({ data: { name: "bound-must-not-update" } }), + }), + ); + expect(boundRawOrganizationUpdate.status).toBe(403); + + // Better Auth returns `{ valid: false, key: null }` for a disabled key. An + // explicit invalid bearer must fail closed at every management boundary; + // otherwise the valid browser cookie would become an unrestricted fallback. + const providerKeyList = await handler( + new Request(`${origin}/api/auth/api-key/list`, { headers: { cookie: cookie!, origin } }), + ); + expect(providerKeyList.status).toBe(200); + const providerKey = ( + (await providerKeyList.json()) as { apiKeys: Array<{ id: string; name: string }> } + ).apiKeys.find((key) => key.name === "delegation-machine"); + expect(providerKey).toBeTruthy(); + const providerKeyId = providerKey!.id; + const disableProviderKey = await handler( + new Request(`${origin}/api/auth/api-key/update`, { + method: "POST", + headers: { cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ keyId: providerKeyId, enabled: false }), + }), + ); + expect(disableProviderKey.status).toBe(200); + const disabledRawApiKeyCreate = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie! }, + body: JSON.stringify({ name: "disabled-bound-must-not-mint" }), + }), + ); + expect(disabledRawApiKeyCreate.status).toBe(403); + const disabledRawOrganizationUpdate = await handler( + new Request(`${origin}/api/auth/organization/update`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie! }, + body: JSON.stringify({ data: { name: "disabled-bound-must-not-update" } }), + }), + ); + expect(disabledRawOrganizationUpdate.status).toBe(403); + const disabledAccountApiKeyCreate = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ name: "disabled-bound-must-not-mint-account" }), + }), + ); + // The account router reports a denied bearer as 401; it must not use the + // valid cookie to mint a new unrestricted key. + expect(disabledAccountApiKeyCreate.status).toBe(401); + const disabledBareTools = await handler( + new Request(`${origin}/api/tools`, { headers: machineHeaders }), + ); + expect(disabledBareTools.status).toBe(401); + const enableProviderKey = await handler( + new Request(`${origin}/api/auth/api-key/update`, { + method: "POST", + headers: { cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ keyId: providerKeyId, enabled: true }), + }), + ); + expect(enableProviderKey.status).toBe(200); + + // `/tools` is an explicitly delegated execution path; integrations is a + // management path and must be refused before its handler runs. + const tools = await handler(new Request(`${origin}/api/tools`, { headers: machineHeaders })); + expect(tools.status).toBe(200); + const integrations = await handler( + new Request(`${origin}/api/integrations`, { headers: machineHeaders }), + ); + expect(integrations.status).toBe(403); + + const credentials = await handler( + new Request(`${origin}/api/account/delegated-credentials`, { headers: humanHeaders }), + ); + expect(credentials.status).toBe(200); + const credentialBody = (await credentials.json()) as { + credentials: Array<{ id: string }>; + }; + expect(credentialBody.credentials).toHaveLength(1); + const revoke = await handler( + new Request( + `${origin}/api/account/delegated-credentials/${credentialBody.credentials[0]!.id}`, + { method: "DELETE", headers: humanHeaders }, + ), + ); + expect(revoke.status).toBe(200); + const revokedRawApiKeyCreate = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { + ...machineHeaders, + cookie: cookie!, + }, + body: JSON.stringify({ name: "revoked-bound-must-not-mint" }), + }), + ); + expect(revokedRawApiKeyCreate.status).toBe(403); + const revokedRawOrganizationUpdate = await handler( + new Request(`${origin}/api/auth/organization/update`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie! }, + body: JSON.stringify({ data: { name: "revoked-bound-must-not-update" } }), + }), + ); + expect(revokedRawOrganizationUpdate.status).toBe(403); + const afterRevoke = await handler( + new Request(`${origin}/api/tools`, { headers: { ...machineHeaders, cookie: cookie! } }), + ); + expect(afterRevoke.status).toBe(401); + const afterRevokeBare = await handler( + new Request(`${origin}/api/tools`, { headers: machineHeaders }), + ); + expect(afterRevokeBare.status).toBe(401); + + // Deleting the provider row produces the same invalid verifier result as a + // disabled key. It must remain unable to fall through to this valid cookie. + const deleteProviderKey = await handler( + new Request(`${origin}/api/auth/api-key/delete`, { + method: "POST", + headers: { cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ keyId: providerKeyId }), + }), + ); + expect(deleteProviderKey.status).toBe(200); + const deletedRawApiKeyCreate = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie! }, + body: JSON.stringify({ name: "deleted-bound-must-not-mint" }), + }), + ); + expect(deletedRawApiKeyCreate.status).toBe(403); + const deletedAccountApiKeyCreate = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: { ...machineHeaders, cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ name: "deleted-bound-must-not-mint-account" }), + }), + ); + expect(deletedAccountApiKeyCreate.status).toBe(401); + const deletedBareTools = await handler( + new Request(`${origin}/api/tools`, { headers: machineHeaders }), + ); + expect(deletedBareTools.status).toBe(401); +}); + +test("a provider-issued Better Auth delegation key reaches HTTP and revokes without fallback", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const humanHeaders = { cookie: cookie!, origin, "content-type": "application/json" }; + const scope = { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] } as const; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const createdResponse = await handler( + new Request(`${origin}/api/account/delegated-credentials/keys`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ + label: "provider-issued-machine", + target: { kind: "self" }, + scope, + expiresAt, + }), + }), + ); + expect(createdResponse.status).toBe(200); + const created = (await createdResponse.json()) as { + credential: { id: string; reference?: { kind?: string } }; + value: string; + }; + expect(created.value).toBeTruthy(); + expect(created.credential.reference?.kind).toBe("local-key"); + const machineHeaders = { authorization: `Bearer ${created.value}` }; + + const rawApiKeyCreate = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { + ...machineHeaders, + cookie: humanHeaders.cookie, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "must-not-mint" }), + }), + ); + expect(rawApiKeyCreate.status).toBe(403); + + const rawOrganizationUpdate = await handler( + new Request(`${origin}/api/auth/organization/update`, { + method: "POST", + headers: { ...machineHeaders, "content-type": "application/json" }, + body: JSON.stringify({ data: { name: "must-not-update" } }), + }), + ); + expect(rawOrganizationUpdate.status).toBe(403); + + const accountApiKeyCreate = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: { ...machineHeaders, "content-type": "application/json" }, + body: JSON.stringify({ name: "must-not-mint-account" }), + }), + ); + expect(accountApiKeyCreate.status).toBe(401); + + expect( + (await handler(new Request(`${origin}/api/tools`, { headers: machineHeaders }))).status, + ).toBe(200); + expect( + (await handler(new Request(`${origin}/api/integrations`, { headers: machineHeaders }))).status, + ).toBe(403); + + const revoked = await handler( + new Request(`${origin}/api/account/delegated-credentials/${created.credential.id}`, { + method: "DELETE", + headers: humanHeaders, + }), + ); + expect(revoked.status).toBe(200); + expect( + ( + await handler( + new Request(`${origin}/api/tools`, { headers: { ...machineHeaders, cookie: cookie! } }), + ) + ).status, + ).toBe(401); + const revokedRawAuth = await handler( + new Request(`${origin}/api/auth/api-key/create`, { + method: "POST", + headers: { + ...machineHeaders, + cookie: cookie!, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "revoked-must-not-mint" }), + }), + ); + expect(revokedRawAuth.status).toBe(403); +}); + +test("a current member can invoke its own delegated grant", async () => { + // The preceding sign-up test creates this non-admin member. Keeping this + // path separate from the bootstrap-admin flow proves the machine actor is + // resolved to its live API-key owner before self-authorization is decided. + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "member@test.local", password: "member-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const humanHeaders = { cookie: cookie!, origin, "content-type": "application/json" }; + const keyResponse = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ name: "member-delegation-machine" }), + }), + ); + expect(keyResponse.status).toBe(200); + const keyBody = (await keyResponse.json()) as { value: string }; + const scope = { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] } as const; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const start = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ + adapter: "better-auth-api-key", + label: "member-delegation-machine", + target: { kind: "self" }, + scope, + expiresAt, + }), + }), + ); + expect(start.status).toBe(200); + const challenge = (await start.json()) as { id: string; nonce: string }; + const machineHeaders = { authorization: `Bearer ${keyBody.value}` }; + const proof = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges/${challenge.id}/proof`, { + method: "POST", + headers: { ...machineHeaders, "content-type": "application/json" }, + body: JSON.stringify({ nonce: challenge.nonce, proofType: "api-key" }), + }), + ); + expect(proof.status).toBe(200); + const consent = await handler( + new Request(`${origin}/api/account/delegated-credentials/challenges/${challenge.id}/consent`, { + method: "POST", + headers: humanHeaders, + body: JSON.stringify({ scope, expiresAt }), + }), + ); + expect(consent.status).toBe(200); + expect( + (await handler(new Request(`${origin}/api/tools`, { headers: machineHeaders }))).status, + ).toBe(200); +}); + +test("a reserved local key cannot fall through to a browser session", async () => { + const origin = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ email: "admin@test.local", password: "admin-password-123" }), + }), + ); + expect(signIn.status).toBe(200); + const cookie = signIn.headers.get("set-cookie")?.split(";", 1)[0]; + expect(cookie).toBeTruthy(); + const reservedKey = `key_${"0".repeat(32)}.${"0".repeat(64)}`; + const response = await handler( + new Request(`${origin}/api/tools`, { + headers: { authorization: `Bearer ${reservedKey}`, cookie: cookie! }, + }), + ); + expect(response.status).toBe(401); + + const keyResponse = await handler( + new Request(`${origin}/api/account/api-keys`, { + method: "POST", + headers: { cookie: cookie!, origin, "content-type": "application/json" }, + body: JSON.stringify({ name: "revoked-provider-key" }), + }), + ); + expect(keyResponse.status).toBe(200); + const keyBody = (await keyResponse.json()) as { id: string; value: string }; + const revoke = await handler( + new Request(`${origin}/api/account/api-keys/${keyBody.id}`, { + method: "DELETE", + headers: { cookie: cookie!, origin }, + }), + ); + expect(revoke.status).toBe(200); + const revokedResponse = await handler( + new Request(`${origin}/api/tools`, { + headers: { authorization: `Bearer ${keyBody.value}`, cookie: cookie! }, + }), + ); + expect(revokedResponse.status).toBe(401); +}); + test("an unauthenticated request is rejected with 401", async () => { const res = await handler(new Request("http://localhost/api/account/me")); expect(res.status).toBe(401); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 4031fdf273..179f159f9f 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -167,7 +167,11 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // through that path). organization({ allowUserToCreateOrganization: false }), admin(), - apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), + apiKey({ + enableSessionForAPIKeys: true, + enableMetadata: true, + rateLimit: { enabled: false }, + }), bearer(), // RFC 8628 device authorization, the CLI `executor login` flow. Registers // /device/code + /device/token + the approval endpoints; the issued token diff --git a/apps/host-selfhost/src/auth/delegated-boundary.ts b/apps/host-selfhost/src/auth/delegated-boundary.ts new file mode 100644 index 0000000000..b17be3b6a3 --- /dev/null +++ b/apps/host-selfhost/src/auth/delegated-boundary.ts @@ -0,0 +1,100 @@ +import { Effect } from "effect"; + +import type { VerifiedDelegatedCredential } from "@executor-js/sdk"; + +import { makeSelfHostDelegationServiceForHeaders } from "../account/delegation-provider"; +import type { SelfHostConfig } from "../config"; +import type { SelfHostDbHandle } from "../db/self-host-db"; +import { type BetterAuthHandle } from "./better-auth"; +import { SELF_HOST_DELEGATED_KEY_PREFIX, selfHostCredentialToken } from "./delegated-key"; + +/** + * Result of checking a credential at a boundary which can create a Better Auth + * session or invoke a privileged account operation. + * + * `unmapped` is the compatibility case for a normal, unbound Better Auth API + * key. `delegated` is a live binding and must be refused by the boundary; + * `denied` covers every known binding which is revoked/expired as well as any + * lookup or verifier failure. The latter deliberately fails closed because a + * valid browser cookie must never turn an unresolvable bearer into a session. + */ +export type SelfHostDelegatedBoundaryResult = "unmapped" | "delegated" | "denied"; + +const isProviderIssuedDelegationKey = (metadata: unknown): boolean => { + if (typeof metadata !== "object" || metadata === null) return false; + const value = (metadata as Record).executorDelegationCredentialId; + return typeof value === "string" && value.length > 0; +}; + +/** + * Resolve a Better Auth bearer/API key against the authoritative delegation + * service before allowing a raw auth/account/admin boundary to proceed. + * + * This intentionally verifies every key format. The `exec_` prefix is only a + * fast path for newly-issued provider keys; proof-bound existing Better Auth + * PATs retain their ordinary format and therefore must still reach the live + * delegation lookup. An ordinary PAT which has never been bound remains + * compatible (`unmapped`). + */ +export const classifySelfHostDelegatedBoundary = ( + db: SelfHostDbHandle, + betterAuth: BetterAuthHandle, + config: SelfHostConfig, + headers: Headers | Record, +): Effect.Effect => { + const token = selfHostCredentialToken(headers); + if (!token) return Effect.succeed("unmapped" as const); + if (token.startsWith(SELF_HOST_DELEGATED_KEY_PREFIX)) return Effect.succeed("denied" as const); + + const requestHeaders = headers instanceof Headers ? headers : new Headers(headers); + const resolveKey = ( + key: NonNullable>["key"]>, + ) => { + const credential: VerifiedDelegatedCredential = { + kind: isProviderIssuedDelegationKey(key.metadata) ? "issued-key" : "external", + adapter: "better-auth-api-key", + issuer: betterAuth.auth.options.baseURL, + audience: betterAuth.organizationId, + subject: key.referenceId, + credentialRef: key.id, + }; + return makeSelfHostDelegationServiceForHeaders(db, betterAuth, config, requestHeaders) + .resolve(credential) + .pipe( + Effect.map(() => "delegated" as const), + Effect.catchTag("DelegationDeniedError", (error) => + Effect.succeed(error.reason === "unmapped" ? ("unmapped" as const) : ("denied" as const)), + ), + Effect.catch(() => Effect.succeed("denied" as const)), + ); + }; + const sessionHeaders = new Headers( + requestHeaders.has("x-api-key") ? { "x-api-key": token } : { authorization: `Bearer ${token}` }, + ); + return Effect.tryPromise({ + try: () => + betterAuth.auth.api.verifyApiKey({ + body: { key: token }, + headers: requestHeaders, + }), + // A verifier/storage outage cannot fall through to the cookie session. + catch: () => "delegated credential lookup failed", + }).pipe( + Effect.flatMap((result) => { + if (!result || result.valid !== true) { + // Better Auth returns no stable key tuple for disabled, expired, or + // deleted keys. Preserve legitimate Better Auth session bearer + // compatibility by checking the token alone; never pass the browser + // cookie into this lookup, or an invalid bearer could fall through to + // that human session. + return Effect.tryPromise({ + try: () => betterAuth.auth.api.getSession({ headers: sessionHeaders }), + catch: () => null, + }).pipe(Effect.map((session) => (session ? ("unmapped" as const) : ("denied" as const)))); + } + if (!result.key) return Effect.succeed("denied" as const); + return resolveKey(result.key); + }), + Effect.catch(() => Effect.succeed("denied" as const)), + ); +}; diff --git a/apps/host-selfhost/src/auth/delegated-key.ts b/apps/host-selfhost/src/auth/delegated-key.ts new file mode 100644 index 0000000000..0dcac7cbe2 --- /dev/null +++ b/apps/host-selfhost/src/auth/delegated-key.ts @@ -0,0 +1,24 @@ +/** + * Provider-issued self-host delegation keys use a reserved Better Auth prefix. + * This lets every session-producing boundary reject a delegated bearer before + * Better Auth can turn it into the key owner's unrestricted session. Ordinary + * Better Auth API keys keep their existing format and behavior. + */ +export const SELF_HOST_DELEGATED_KEY_PREFIX = "exec_"; + +export const selfHostCredentialToken = ( + headers: Headers | Record, +): string | null => { + const apiKey = headers instanceof Headers ? headers.get("x-api-key") : headers["x-api-key"]; + if (apiKey?.trim()) return apiKey.trim(); + const authorization = + headers instanceof Headers ? headers.get("authorization") : headers.authorization; + if (authorization?.toLowerCase().startsWith("bearer ")) { + const token = authorization.slice(7).trim(); + if (token.length > 0) return token; + } + return null; +}; + +export const isSelfHostDelegatedKey = (headers: Headers | Record): boolean => + selfHostCredentialToken(headers)?.startsWith(SELF_HOST_DELEGATED_KEY_PREFIX) === true; diff --git a/apps/host-selfhost/src/auth/delegation.ts b/apps/host-selfhost/src/auth/delegation.ts new file mode 100644 index 0000000000..0862243ae9 --- /dev/null +++ b/apps/host-selfhost/src/auth/delegation.ts @@ -0,0 +1,44 @@ +import { + makeDelegationService, + Tenant, + type DelegationService, + type DelegationStoreOptions, +} from "@executor-js/sdk"; + +import type { SelfHostConfig } from "../config"; +import type { BetterAuthHandle } from "./better-auth"; +import type { SelfHostDbHandle } from "../db/self-host-db"; + +/** The self-host factory requires live Better Auth membership/admin and the + * provider's possession check. A subject sighting alone is insufficient. */ +export type SelfHostDelegationAuthority = Pick< + DelegationStoreOptions, + | "isVerifiedHuman" + | "isCurrentlyAuthorized" + | "isManagementAuthorized" + | "verifyProof" + | "verifyProviderCredential" +>; + +/** Build the Better Auth/self-host delegation service over the shared SQLite + * Fuma handle. The supplied authority must use live Better Auth membership and + * admin reads on every management/revalidation decision. */ +export const makeSelfHostDelegationService = ( + dbHandle: Pick, + betterAuth: Pick, + config: Pick, + authority: SelfHostDelegationAuthority, +): DelegationService => { + const tenant = Tenant.make(betterAuth.organizationId); + return makeDelegationService({ + db: dbHandle.db, + tenant, + issuanceEnabled: config.delegatedIssuanceEnabled, + maxTtlMs: config.delegatedMaxTtlMs, + isVerifiedHuman: authority.isVerifiedHuman, + isCurrentlyAuthorized: authority.isCurrentlyAuthorized, + isManagementAuthorized: authority.isManagementAuthorized, + verifyProof: authority.verifyProof, + verifyProviderCredential: authority.verifyProviderCredential, + }); +}; diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index a637b12a6a..f213af373b 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -1,8 +1,12 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; +import type { DelegationGrant, VerifiedDelegatedCredential } from "@executor-js/sdk"; import { isPrivileged } from "../admin/require-admin"; +import { makeSelfHostDelegationServiceForHeaders } from "../account/delegation-provider"; +import type { SelfHostConfig } from "../config"; +import type { SelfHostDbHandle } from "../db/self-host-db"; import { BetterAuth, type BetterAuthHandle } from "./better-auth"; // --------------------------------------------------------------------------- @@ -16,8 +20,7 @@ import { BetterAuth, type BetterAuthHandle } from "./better-auth"; // - fails Unauthorized -> no/invalid credential (renders 401) // - fails NoOrganization -> valid credential, no org (renders 403) // -// `betterAuthIdentityLayer` is the only production provider. The trivial fake -// identities tests inject live in `src/testing/test-app.ts`. +// The trivial fake identities tests inject live in `src/testing/test-app.ts`. // --------------------------------------------------------------------------- const bearerToken = (headers: Headers): string | undefined => { @@ -50,19 +53,112 @@ export const resolveSelfHostOrgRole = ( Effect.map((membership) => (membership && isPrivileged(membership.role) ? "admin" : "member")), ); -// --------------------------------------------------------------------------- -// The production IdentityProvider: resolve a request to a Better Auth session -// and map it to a neutral Principal. Three credential shapes resolve here: -// - session cookie (browser SPA) -// - Bearer session token (bearer plugin) -// - Bearer API key — the apiKey plugin reads `x-api-key`, so when the normal -// resolution fails we retry with the Bearer value as x-api-key, which (with -// enableSessionForAPIKeys) mints the owner's session. This is what lets a -// generated API key authenticate the API + MCP endpoint as a Bearer token. -// Single-org instance, so organizationName is the boot-cached org name. -// --------------------------------------------------------------------------- +const delegatedPrincipalFrom = ( + grant: DelegationGrant, + betterAuth: BetterAuthHandle, +): import("@executor-js/api/server").Principal => ({ + kind: "delegated", + accountId: String(grant.effectiveSubject), + organizationId: betterAuth.organizationId, + organizationName: betterAuth.organizationName, + organizationSlug: betterAuth.organizationSlug, + email: "", + name: null, + avatarUrl: null, + roles: [], + orgRoleModel: "organization", + orgRole: "member", + tenant: String(grant.tenant), + grantId: String(grant.grantId), + credentialId: String(grant.credentialId), + credentialRef: grant.credentialRef, + actorSubject: String(grant.actorSubject), + effectiveSubject: String(grant.effectiveSubject), + scopeVersion: String(grant.scopeVersion), + delegation: grant, +}); + +const bearerValue = (request: Request): string | undefined => { + const authorization = request.headers.get("authorization"); + if (authorization?.toLowerCase().startsWith("bearer ")) { + return authorization.slice(7).trim() || undefined; + } + return request.headers.get("x-api-key") ?? undefined; +}; + +// Core-issued keys are intentionally a reserved local format. Self-host uses +// Better Auth API keys as its provider credentials, so a malformed or revoked +// local key must not fall through to the cookie/session resolver. +const isReservedLocalIssuedKey = (token: string): boolean => + /^key_[0-9a-f]{32}\.[0-9a-f]{64}$/.test(token); + +// An explicit x-api-key header is always an API-key attempt. A bearer paired +// with a browser cookie is also treated as an API-key attempt: this prevents a +// revoked provider key from falling through to the valid human cookie. Bare +// bearer tokens retain Better Auth session compatibility because Better Auth +// does not give session and API-key token strings distinct prefixes. +const isExplicitBetterAuthApiKey = (request: Request): boolean => + request.headers.has("x-api-key") || request.headers.has("cookie"); + +const isProviderIssuedDelegationKey = (metadata: unknown): boolean => { + if (typeof metadata !== "object" || metadata === null) return false; + const value = (metadata as Record).executorDelegationCredentialId; + return typeof value === "string" && value.length > 0; +}; + +const delegatedApiKey = ( + request: Request, + betterAuth: BetterAuthHandle, + service: ReturnType, +): Effect.Effect => { + const token = bearerValue(request); + if (!token) return Effect.succeed(null); + if (isReservedLocalIssuedKey(token)) return Effect.fail(new Unauthorized()); + const explicitApiKey = isExplicitBetterAuthApiKey(request); + // Keep the verifier failure boundary separate from delegation resolution. + // A verifier result with no key is the compatibility case for a normal + // Better Auth session bearer. Once a key tuple has resolved, however, any + // revoked/expired/tuple-mismatched/storage failure must remain Unauthorized; + // swallowing that failure for a bare bearer would let getSession turn the + // revoked provider key back into its owner's unrestricted member session. + const verify = Effect.tryPromise({ + try: () => + betterAuth.auth.api.verifyApiKey({ + body: { key: token }, + headers: request.headers, + }), + catch: () => new Unauthorized(), + }); + return verify.pipe( + Effect.flatMap((result) => { + if (!result?.valid || !result.key) { + return explicitApiKey ? Effect.fail(new Unauthorized()) : Effect.succeed(null); + } + const credential: VerifiedDelegatedCredential = { + kind: isProviderIssuedDelegationKey(result.key.metadata) ? "issued-key" : "external", + adapter: "better-auth-api-key", + issuer: betterAuth.auth.options.baseURL, + audience: betterAuth.organizationId, + subject: result.key.referenceId, + credentialRef: result.key.id, + }; + return service.resolve(credential).pipe( + Effect.map((grant) => delegatedPrincipalFrom(grant, betterAuth)), + Effect.catchTags({ + DelegationDeniedError: (error) => + error.reason === "unmapped" ? Effect.succeed(null) : Effect.fail(new Unauthorized()), + }), + Effect.mapError(() => new Unauthorized()), + ); + }), + ); +}; -export const betterAuthIdentityLayer: Layer.Layer = +/** Identity seam with the live delegated external-credential resolver. */ +export const makeBetterAuthDelegatingIdentityLayer = ( + db: SelfHostDbHandle, + config: SelfHostConfig, +): Layer.Layer => Layer.effect(IdentityProvider)( Effect.gen(function* () { const betterAuth = yield* BetterAuth; @@ -70,11 +166,15 @@ export const betterAuthIdentityLayer: Layer.Layer Effect.gen(function* () { + const delegated = yield* delegatedApiKey( + request, + betterAuth, + makeSelfHostDelegationServiceForHeaders(db, betterAuth, config, request.headers), + ); + if (delegated) return delegated; let resolved = yield* Effect.promise(() => auth.api.getSession({ headers: request.headers }), ); - // The credential shape that resolved the session — the SAME headers - // are what the membership-role lookup below must present. let sessionHeaders: Headers | Record = request.headers; if (!resolved) { const token = bearerToken(request.headers); @@ -87,19 +187,8 @@ export const betterAuthIdentityLayer: Layer.Layer unauthenticated. - // The middleware's failure strategy renders this as a 401. if (!resolved) return yield* new Unauthorized(); - // Single-org instance: every authenticated user belongs to the one - // seeded org. Cookie/bearer-session logins are pinned to it by the - // session hook; API-key-minted sessions carry no active org, so we - // default to the seeded org rather than rejecting with NoOrganization. const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; - // The workspace role, resolved against the INSTANCE org exactly as - // the admin gate does (require-admin.ts): the explicit - // `organizationId` query keeps a caller-controlled active org from - // answering for an org they own elsewhere. FAIL CLOSED to "member" - // — an infra fault demotes rather than escalates. const orgRole = yield* resolveSelfHostOrgRole( betterAuth, sessionHeaders, @@ -118,7 +207,7 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), - orgRoleModel: "organization", + orgRoleModel: "organization" as const, orgRole, }; }), diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts index 1005970c21..1f4376fafa 100644 --- a/apps/host-selfhost/src/auth/index.ts +++ b/apps/host-selfhost/src/auth/index.ts @@ -1,17 +1,18 @@ -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { IdentityProvider, MemberDirectory } from "@executor-js/api/server"; import { loadConfig } from "../config"; import type { SelfHostDbHandle } from "../db/self-host-db"; import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; -import { betterAuthIdentityLayer } from "./identity"; +import { makeBetterAuthDelegatingIdentityLayer } from "./identity"; import { betterAuthMemberDirectoryLayer } from "./member-directory"; import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; import { rewriteInvalidOrigin } from "./invalid-origin-help"; +import { classifySelfHostDelegatedBoundary } from "./delegated-boundary"; export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; -export { betterAuthIdentityLayer } from "./identity"; +export { makeBetterAuthDelegatingIdentityLayer } from "./identity"; export { betterAuthMemberDirectoryLayer } from "./member-directory"; // --------------------------------------------------------------------------- @@ -66,6 +67,17 @@ export const resolveAuthProviders = async ( // resulting consent redirect with the registered client name. const config = loadConfig(); const authHandler = async (request: Request): Promise => { + const delegatedBoundary = await Effect.runPromise( + classifySelfHostDelegatedBoundary(dbHandle, betterAuth, config, request.headers).pipe( + Effect.orElseSucceed(() => "denied" as const), + ), + ); + if (delegatedBoundary !== "unmapped") { + return new Response( + JSON.stringify({ error: "Delegated credentials cannot access auth routes" }), + { status: 403, headers: { "content-type": "application/json" } }, + ); + } const response = await betterAuth.handler(withForcedMcpConsent(request)); // Turn Better Auth's bare 403 "Invalid origin" into a setup instruction — // on a fresh deploy it almost always means the public URL needs configuring. @@ -83,7 +95,9 @@ export const resolveAuthProviders = async ( }; return { - identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), + identityLayer: makeBetterAuthDelegatingIdentityLayer(dbHandle, config).pipe( + Layer.provide(betterAuthLayer), + ), memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)), authHandler, betterAuth, diff --git a/apps/host-selfhost/src/auth/oauth-callback-login.ts b/apps/host-selfhost/src/auth/oauth-callback-login.ts index 557dc15f3f..4ff1a861e8 100644 --- a/apps/host-selfhost/src/auth/oauth-callback-login.ts +++ b/apps/host-selfhost/src/auth/oauth-callback-login.ts @@ -1,4 +1,5 @@ import { loginPath } from "./return-to"; +import { isSelfHostDelegatedKey } from "./delegated-key"; export const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; @@ -15,6 +16,7 @@ export const oauthCallbackSignInRedirectLocation = async ( if (request.method !== "GET" && request.method !== "HEAD") return null; const url = new URL(request.url); if (url.pathname !== OAUTH_CALLBACK_PATH) return null; + if (isSelfHostDelegatedKey(request.headers)) return loginPath(`${url.pathname}${url.search}`); const session = await auth.api.getSession({ headers: request.headers }); if (session) return null; diff --git a/apps/host-selfhost/src/config.test.ts b/apps/host-selfhost/src/config.test.ts new file mode 100644 index 0000000000..40416062c5 --- /dev/null +++ b/apps/host-selfhost/src/config.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; +import { MAX_DELEGATION_TTL_MS } from "@executor-js/sdk"; + +import { parseDelegatedMaxTtlMs } from "./config"; + +describe("self-host delegated credential configuration", () => { + it("uses the SDK maximum by default and accepts a lower cap", () => { + expect(parseDelegatedMaxTtlMs(undefined)).toBe(MAX_DELEGATION_TTL_MS); + expect(parseDelegatedMaxTtlMs("604800000")).toBe(604800000); + }); + + it.each(["0", "-1", "not-a-number", String(MAX_DELEGATION_TTL_MS + 1)])( + "rejects an invalid operator cap: %s", + (raw) => { + expect(() => parseDelegatedMaxTtlMs(raw)).toThrowError("EXECUTOR_DELEGATED_MAX_TTL_MS"); + }, + ); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 07dcc56d14..1cc62687e0 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { isValidOrgSlug } from "@executor-js/api"; +import { MAX_DELEGATION_TTL_MS } from "@executor-js/sdk"; import { missingPublicOriginWarning, resolvePublicOrigin, @@ -95,6 +96,12 @@ export interface SelfHostConfig { * re-sync, leaving stale-marking and config revision as the only triggers. */ readonly toolsSyncTtlMs: number | null | undefined; + /** Explicit rollout gate for delegated credential issuance. */ + readonly delegatedIssuanceEnabled: boolean; + /** Maximum age of the Better Auth session creation time used for human management. */ + readonly delegatedRecentAuthMaxAgeMs: number; + /** Operator cap for delegated grants, bounded by the SDK hard maximum. */ + readonly delegatedMaxTtlMs: number; } export const resolveDataDir = (): string => @@ -207,9 +214,39 @@ export const loadConfig = (): SelfHostConfig => { sso: resolveSso(), mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(), toolsSyncTtlMs: resolveToolsSyncTtlMs(), + delegatedIssuanceEnabled: process.env.EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE === "true", + delegatedRecentAuthMaxAgeMs: resolveDelegatedRecentAuthMaxAgeMs(), + delegatedMaxTtlMs: resolveDelegatedMaxTtlMs(), }; }; +export const parseDelegatedMaxTtlMs = (rawInput: string | undefined): number => { + const raw = rawInput ?? String(MAX_DELEGATION_TTL_MS); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_DELEGATION_TTL_MS) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_DELEGATED_MAX_TTL_MS ${JSON.stringify(raw)} must be a positive integer no greater than ${MAX_DELEGATION_TTL_MS}`, + ); + } + return value; +}; + +const resolveDelegatedMaxTtlMs = (): number => + parseDelegatedMaxTtlMs(process.env.EXECUTOR_DELEGATED_MAX_TTL_MS); + +const resolveDelegatedRecentAuthMaxAgeMs = (): number => { + const raw = process.env.EXECUTOR_DELEGATED_RECENT_AUTH_MAX_AGE_MS ?? "300000"; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_DELEGATED_RECENT_AUTH_MAX_AGE_MS ${JSON.stringify(raw)} must be a positive integer`, + ); + } + return value; +}; + // Well-known discovery documents for providers an operator can name without // hunting down the URL. Anything else (Okta, Entra, Auth0, …) has a // tenant-specific issuer, so EXECUTOR_SSO_DISCOVERY_URL is required for it. diff --git a/apps/host-selfhost/src/index.ts b/apps/host-selfhost/src/index.ts index ee92a0b34d..cee23aba8d 100644 --- a/apps/host-selfhost/src/index.ts +++ b/apps/host-selfhost/src/index.ts @@ -6,4 +6,4 @@ export { type MakeSelfHostAppOptions, } from "./app"; export { loadConfig, type SelfHostConfig } from "./config"; -export { BetterAuth, buildBetterAuth, betterAuthIdentityLayer } from "./auth"; +export { BetterAuth, buildBetterAuth, makeBetterAuthDelegatingIdentityLayer } from "./auth"; diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index 967d4255d3..85e176eac8 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -225,6 +225,7 @@ export const selfHostMcpAuth: Layer.Layer ({ + kind: "member" as const, accountId: resolved.user.id, organizationId, organizationName: betterAuth.organizationName, @@ -116,8 +129,18 @@ const makeApprovalHandler = ( store: ReturnType, betterAuth: BetterAuthHandle, + db: SelfHostDbHandle, + config: SelfHostConfig, ): ((request: Request) => Promise) => async (request) => { + const delegatedBoundary = await Effect.runPromise( + classifySelfHostDelegatedBoundary(db, betterAuth, config, request.headers).pipe( + Effect.orElseSucceed(() => "denied" as const), + ), + ); + if (delegatedBoundary !== "unmapped") { + return jsonResponse({ error: "Unauthorized" }, 401); + } // A malformed cookie must read as unauthenticated, not 500. const session = await Effect.runPromise( Effect.tryPromise({ @@ -160,6 +183,43 @@ export const makeSelfHostMcpSeams = ( dbHandle, config.webBaseUrl, config.mcpSessionIdleTtlMs, + (input) => + input.owner.kind === "delegated" && input.approver.kind !== "delegated" + ? auditSelfHostBrowserApproval( + dbHandle, + betterAuth, + config, + { + owner: input.owner, + approver: input.approver, + executionId: input.executionId, + response: input.response, + }, + new Headers(), + ) + : Effect.void, + (principal, capability) => { + const service = makeSelfHostDelegationServiceForHeaders( + dbHandle, + betterAuth, + config, + new Headers(), + ); + return service + .resolveBinding({ + tenant: Tenant.make(principal.tenant), + grantId: DelegationId.make(principal.grantId), + credentialId: CredentialId.make(principal.credentialId), + credentialRef: principal.credentialRef, + actorSubject: Subject.make(principal.actorSubject), + effectiveSubject: Subject.make(principal.effectiveSubject), + scopeVersion: DelegationScopeVersion.make(principal.scopeVersion), + }) + .pipe( + Effect.flatMap((grant) => service.revalidate(grant, capability)), + Effect.asVoid, + ); + }, ); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), @@ -168,7 +228,7 @@ export const makeSelfHostMcpSeams = ( auth, sessions: selfHostMcpSessions(sessionStore), reporter: selfHostMcpReporter, - approvalHandler: makeApprovalHandler(sessionStore, betterAuth), + approvalHandler: makeApprovalHandler(sessionStore, betterAuth, dbHandle, config), close: sessionStore.close, }; }; diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 60f07d2988..10c974991f 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -10,6 +10,7 @@ process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-mcp-")); process.env.BETTER_AUTH_SECRET = "mcp-test-secret-0123456789-abcdefghij-klmnop"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@mcp.test"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; +process.env.EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE = "true"; const { makeSelfHostApiHandler } = await import("../app"); @@ -130,6 +131,177 @@ test("an authenticated MCP client initializes, lists tools, and executes code", expect(JSON.stringify(await call.json())).toContain("42"); }); +test("a provider-issued delegated key owns an MCP session until revocation", async () => { + const admin = await signInBootstrap(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const created = await handler( + new Request(`${BASE}/api/account/delegated-credentials/keys`, { + method: "POST", + headers: { + cookie: admin.cookie, + origin: BASE, + "content-type": "application/json", + }, + body: JSON.stringify({ + label: "mcp-machine", + target: { kind: "self" }, + scope: { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] }, + expiresAt, + }), + }), + ); + expect(created.status).toBe(200); + const body = (await created.json()) as { credential: { id: string }; value: string }; + const sessionId = await initSession(body.value); + const listed = await mcp(body.value, { jsonrpc: "2.0", id: 2, method: "tools/list" }, sessionId); + expect(listed.status).toBe(200); + + const revoked = await handler( + new Request(`${BASE}/api/account/delegated-credentials/${body.credential.id}`, { + method: "DELETE", + headers: { cookie: admin.cookie, origin: BASE }, + }), + ); + expect(revoked.status).toBe(200); + const afterRevoke = await mcp( + body.value, + { jsonrpc: "2.0", id: 3, method: "tools/list" }, + sessionId, + ); + expect(afterRevoke.status).toBe(401); +}); + +test("browser approval audits the human while retaining the delegated scope", async () => { + const admin = await signInBootstrap(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const scope = { scopes: ["discover", "invoke"], toolkitIds: [], connectionIds: [] } as const; + const created = await handler( + new Request(`${BASE}/api/account/delegated-credentials/keys`, { + method: "POST", + headers: { cookie: admin.cookie, origin: BASE, "content-type": "application/json" }, + body: JSON.stringify({ + label: "approval-machine", + target: { kind: "self" }, + scope, + expiresAt, + }), + }), + ); + expect(created.status).toBe(200); + const createdBody = (await created.json()) as { + credential: { id: string; scope: typeof scope }; + value: string; + }; + const gate = await account(admin.token, "/api/policies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + pattern: "selfhost-browser-audit.*", + action: "require_approval", + }), + }); + expect(gate.status).toBe(200); + const gateBody = (await gate.json()) as { id: string }; + const sessionId = await initSession(createdBody.value, true); + const pausedResponse = await mcp( + createdBody.value, + { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "execute", + arguments: { + code: [ + "return await tools.executor.coreTools.policies.create({", + ' owner: "org",', + ' pattern: "selfhost-browser-audit.result",', + ' action: "block"', + "});", + ].join("\n"), + }, + }, + }, + sessionId, + true, + ); + const pausedBody = (await pausedResponse.json()) as { + result?: { structuredContent?: { executionId?: string } }; + }; + const executionId = pausedBody.result?.structuredContent?.executionId; + expect(executionId).toBeTruthy(); + if (!executionId) return; + const resume = mcp( + createdBody.value, + { + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "resume", arguments: { executionId } }, + }, + sessionId, + true, + ); + await Promise.resolve(); + const decision = await handler( + new Request( + `${BASE}/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(executionId)}/resume`, + { + method: "POST", + headers: { cookie: admin.cookie, origin: BASE, "content-type": "application/json" }, + body: JSON.stringify({ action: "accept", content: {} }), + }, + ), + ); + expect(decision.status).toBe(200); + expect((await resume).status).toBe(200); + + const auditResponse = await handler( + new Request( + `${BASE}/api/account/delegated-credentials/audit?credentialId=${encodeURIComponent(createdBody.credential.id)}`, + { headers: { cookie: admin.cookie, origin: BASE } }, + ), + ); + expect(auditResponse.status).toBe(200); + const audit = (await auditResponse.json()) as { + events: ReadonlyArray<{ + action: string; + outcome: string; + executionId: string | null; + actor: { kind: string; id?: string }; + }>; + }; + expect( + audit.events.some( + (event) => + event.action === "approval" && + event.outcome === "allowed" && + event.executionId === executionId && + event.actor.kind === "human", + ), + ).toBe(true); + + const listed = await handler( + new Request(`${BASE}/api/account/delegated-credentials`, { + headers: { cookie: admin.cookie, origin: BASE }, + }), + ); + expect(listed.status).toBe(200); + const listedBody = (await listed.json()) as { + credentials: ReadonlyArray<{ id: string; scope: typeof scope }>; + }; + expect( + listedBody.credentials.find((item) => item.id === createdBody.credential.id)?.scope, + ).toEqual(scope); + + await account(admin.token, `/api/policies/${encodeURIComponent(gateBody.id)}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ owner: "org" }), + }); +}); + test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { const alice = await signUp("alice2@mcp.test"); const bob = await signUp("bob2@mcp.test"); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index f9d29b1f05..dc6d51c16d 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,10 +1,12 @@ -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; import type { McpErrorReporter } from "@executor-js/host-mcp"; +import type { DelegationCapability } from "@executor-js/sdk"; import { inMemoryMcpSessionsLayer, makeInMemoryMcpSessionStore, + type BrowserApprovalAuditInput, type InMemoryMcpSessionStore, } from "@executor-js/host-mcp/in-memory-session-store"; @@ -37,6 +39,11 @@ export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, sessionIdleTtlMs?: number, + onBrowserApproval?: (input: BrowserApprovalAuditInput) => Effect.Effect, + revalidateDelegation?: ( + principal: Extract, + capability: DelegationCapability, + ) => Effect.Effect, ): InMemoryMcpSessionStore => makeInMemoryMcpSessionStore( makeMcpBuildServer( @@ -52,6 +59,8 @@ export const makeSelfHostMcpSessionStore = ( { ...(webBaseUrl === undefined ? {} : { webBaseUrl }), ...(sessionIdleTtlMs === undefined ? {} : { sessionIdleTtlMs }), + ...(onBrowserApproval === undefined ? {} : { onBrowserApproval }), + ...(revalidateDelegation === undefined ? {} : { revalidateDelegation }), }, ); diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index 2ab7eb7f4e..e0d0562cb0 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -168,13 +168,17 @@ describe("toolkitsPlugin", () => { }); yield* executor.toolkits.createPolicy(toolkit.id, { pattern: "snapshot-test.org.main.read", + action: "block", + }); + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "snapshot-test.org.main.*", action: "require_approval", }); const snapshot = yield* executor.toolkits.snapshotForSlug(toolkit.slug); expect(snapshot).not.toBeNull(); expect(snapshot?.toolAddresses.map(String)).toContain("tools.snapshot-test.org.main.read"); - expect(snapshot?.connectionAddresses.map(String)).toContain("snapshot-test.org.main"); + expect(snapshot?.connectionAddresses.map(String)).toContain("tools.snapshot-test.org.main"); const resolved = yield* executor.toolkits.resolvePolicyForSlug( toolkit.slug, "tools.snapshot-test.org.main.read", diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index bc8be54c94..fc012218d3 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -253,7 +253,11 @@ const isPersonalDynamicToolId = (toolId: string): boolean => const connectionAddressForTool = (toolId: string): ConnectionAddress | null => { if (!toolId.startsWith("tools.")) return null; const parts = canonicalToolkitToolId(toolId).split("."); - return parts.length >= 4 ? ConnectionAddress.make(parts.slice(0, 3).join(".")) : null; + // ConnectionAddress is the same callable prefix used by the persisted + // connection row and SDK dispatch capability. Toolkit policy patterns omit + // `tools.` for matching, but the frozen snapshot must retain it so scope + // options and delegated revalidation join the real catalog row exactly. + return parts.length >= 4 ? ConnectionAddress.make(`tools.${parts.slice(0, 3).join(".")}`) : null; }; const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ From 0903a6981efae639af4e65bac51d54f931a19a89 Mon Sep 17 00:00:00 2001 From: donmasakayan <1178461+donmasakayan@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:14:48 +1300 Subject: [PATCH 4/5] feat: add delegated credential account controls --- apps/cloud/src/routeTree.gen.ts | 29 +- apps/host-cloudflare/web/routeTree.gen.ts | 25 + apps/host-selfhost/web/routeTree.gen.ts | 25 + packages/app/src/routeTree.gen.ts | 23 + packages/react/src/api/account-atoms.tsx | 195 ++++ packages/react/src/api/reactivity-keys.tsx | 5 + packages/react/src/console-routes.ts | 2 + packages/react/src/multiplayer/shell.tsx | 1 + .../react/src/pages/delegated-credentials.tsx | 1028 +++++++++++++++++ .../src/routes/delegated-credentials.tsx | 7 + packages/react/src/routes/routeTree.gen.ts | 21 + 11 files changed, 1357 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/pages/delegated-credentials.tsx create mode 100644 packages/react/src/routes/delegated-credentials.tsx diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e0ca33dbdf..8bbe31a966 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as BillingRouteImport } from './routes/app/billing' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport } from './../../../packages/react/src/routes/delegated-credentials' import { Route as OrgRouteImport } from './routes/app/org' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as SecretsRouteImport } from './routes/app/secrets' @@ -68,6 +69,14 @@ const BillingRoute = BillingRouteImport.update({ path: '/{-$orgSlug}/billing', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport.update( + { + id: '/{-$orgSlug}/delegated-credentials', + path: '/{-$orgSlug}/delegated-credentials', + getParentRoute: () => rootRouteImport, + } as any, + ) const OrgRoute = OrgRouteImport.update({ id: '/{-$orgSlug}/org', path: '/{-$orgSlug}/org', @@ -170,6 +179,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -193,6 +203,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -217,6 +228,7 @@ export interface FileRoutesById { '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute @@ -242,6 +254,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -265,6 +278,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -288,6 +302,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -312,6 +327,7 @@ export interface RootRouteChildren { ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute @@ -378,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/delegated-credentials': { + id: '/{-$orgSlug}/delegated-credentials' + path: '/{-$orgSlug}/delegated-credentials' + fullPath: '/{-$orgSlug}/delegated-credentials' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/org': { id: '/{-$orgSlug}/org' path: '/{-$orgSlug}/org' @@ -517,6 +540,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, BillingRoute: BillingRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, @@ -545,15 +570,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' - import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { interface Register { ssr: true - router: Awaited> - config: Awaited> } } diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 60f5c1a2e4..7aa6354121 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport } from './../../../packages/react/src/routes/delegated-credentials' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' @@ -36,6 +37,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport.update( + { + id: '/{-$orgSlug}/delegated-credentials', + path: '/{-$orgSlug}/delegated-credentials', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport.update({ id: '/{-$orgSlug}/policies', @@ -129,6 +138,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRo export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -145,6 +155,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -162,6 +173,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -180,6 +192,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -196,6 +209,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -212,6 +226,7 @@ export interface FileRouteTypes { id: | '__root__' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -229,6 +244,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -258,6 +274,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/delegated-credentials': { + id: '/{-$orgSlug}/delegated-credentials' + path: '/{-$orgSlug}/delegated-credentials' + fullPath: '/{-$orgSlug}/delegated-credentials' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/policies': { id: '/{-$orgSlug}/policies' path: '/{-$orgSlug}/policies' @@ -378,6 +401,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index bb1f572221..e01bf60632 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport import { Route as AdminRouteImport } from './routes/app/admin' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport } from './../../../packages/react/src/routes/delegated-credentials' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' @@ -55,6 +56,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport.update( + { + id: '/{-$orgSlug}/delegated-credentials', + path: '/{-$orgSlug}/delegated-credentials', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport.update({ id: '/{-$orgSlug}/policies', @@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -177,6 +187,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -198,6 +209,7 @@ export interface FileRoutesById { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -220,6 +232,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -240,6 +253,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -260,6 +274,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -281,6 +296,7 @@ export interface RootRouteChildren { AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -332,6 +348,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/delegated-credentials': { + id: '/{-$orgSlug}/delegated-credentials' + path: '/{-$orgSlug}/delegated-credentials' + fullPath: '/{-$orgSlug}/delegated-credentials' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/policies': { id: '/{-$orgSlug}/policies' path: '/{-$orgSlug}/policies' @@ -462,6 +485,8 @@ const rootRouteChildren: RootRouteChildren = { ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesDelegatedCredentialsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index cf99c99b1c..db402bb7a1 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as DotDotDotDotDotDotReactSrcRoutesIndexRouteImport } from './../../react/src/routes/index' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRouteImport } from './../../react/src/routes/delegated-credentials' import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './../../react/src/routes/policies' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsRouteImport } from './../../react/src/routes/toolkits' @@ -36,6 +37,12 @@ const DotDotDotDotDotDotReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute = + DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRouteImport.update({ + id: '/{-$orgSlug}/delegated-credentials', + path: '/{-$orgSlug}/delegated-credentials', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotReactSrcRoutesPoliciesRoute = DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport.update({ id: '/{-$orgSlug}/policies', @@ -112,6 +119,7 @@ const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute = export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -128,6 +136,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -145,6 +154,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -163,6 +173,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -179,6 +190,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -195,6 +207,7 @@ export interface FileRouteTypes { id: | '__root__' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -212,6 +225,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren + DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute: typeof DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren @@ -241,6 +255,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/delegated-credentials': { + id: '/{-$orgSlug}/delegated-credentials' + path: '/{-$orgSlug}/delegated-credentials' + fullPath: '/{-$orgSlug}/delegated-credentials' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/policies': { id: '/{-$orgSlug}/policies' path: '/{-$orgSlug}/policies' @@ -361,6 +382,8 @@ const DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotReactSrcRoutesArtifactsRouteWithChildren, + DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute: + DotDotDotDotDotDotReactSrcRoutesDelegatedCredentialsRoute, DotDotDotDotDotDotReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotReactSrcRoutesPoliciesRoute, SecretsRoute: SecretsRoute, diff --git a/packages/react/src/api/account-atoms.tsx b/packages/react/src/api/account-atoms.tsx index bbae9384ca..8145b72b10 100644 --- a/packages/react/src/api/account-atoms.tsx +++ b/packages/react/src/api/account-atoms.tsx @@ -1,4 +1,11 @@ import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Effect from "effect/Effect"; +import { + DelegationScope, + DelegationChallengeResponse, + DelegationScopeOptionsResponse, +} from "@executor-js/api"; import { AccountApiClient } from "./account-client"; import { ReactivityKey } from "./reactivity-keys"; @@ -71,3 +78,191 @@ export const inviteMember = AccountApiClient.mutation("account", "inviteMember") export const removeMember = AccountApiClient.mutation("account", "removeMember"); export const updateMemberRole = AccountApiClient.mutation("account", "updateMemberRole"); export const updateOrgName = AccountApiClient.mutation("account", "updateOrgName"); + +// ── Delegated credentials ─────────────────────────────────────────────────── + +export const delegatedCredentialsAtom = AccountApiClient.query( + "account", + "listDelegatedCredentials", + { + query: {}, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.delegatedCredentials], + }, +); + +export const delegatedCredentialsOptimisticAtom = Atom.optimistic(delegatedCredentialsAtom); + +export const delegationTargetsAtom = AccountApiClient.query("account", "listDelegationTargets", { + timeToLive: "1 minute", + reactivityKeys: [ReactivityKey.delegatedCredentials], +}); + +/** Target-scoped, human-readable choices for exact delegation consent. The + * `self` key intentionally omits `targetId`, preserving self-target semantics + * at the HTTP boundary. */ +export const delegationScopeOptionsAtom = Atom.family((targetKey: string) => + AccountApiClient.query("account", "listDelegationScopeOptions", { + query: targetKey === "self" ? {} : { targetId: targetKey }, + timeToLive: "30 seconds", + reactivityKeys: [ + ReactivityKey.delegatedCredentials, + ReactivityKey.policies, + ReactivityKey.connections, + ], + }), +); + +export const delegationAuditAtom = AccountApiClient.query("account", "listDelegationAudit", { + query: {}, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.delegationAudit], +}); + +export const delegationNotificationsAtom = AccountApiClient.query( + "account", + "listDelegationNotifications", + { + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.delegatedCredentials], + }, +); + +export const markDelegationNotificationRead = AccountApiClient.mutation( + "account", + "markDelegationNotificationRead", +); + +type DelegationChallenge = typeof DelegationChallengeResponse.Type; +type DelegationScopeValue = typeof DelegationScope.Type; +export type DelegationScopeOptions = typeof DelegationScopeOptionsResponse.Type; +const emptyDelegationChallengeAtom = Atom.make( + Effect.succeed(null), +); + +export const delegationChallengeAtom = Atom.family((challengeId: string) => + challengeId.length === 0 + ? emptyDelegationChallengeAtom + : Atom.mapResult( + AccountApiClient.query("account", "getDelegationChallenge", { + params: { challengeId }, + timeToLive: "5 seconds", + reactivityKeys: [ReactivityKey.delegatedCredentials], + }), + (value): DelegationChallenge | null => value, + ), +); + +export const createDelegatedKey = AccountApiClient.mutation("account", "createDelegatedKey"); +export const startDelegationChallenge = AccountApiClient.mutation( + "account", + "startDelegationChallenge", +); +export const completeDelegationProof = AccountApiClient.mutation( + "account", + "completeDelegationProof", +); +export const consentDelegationChallenge = AccountApiClient.mutation( + "account", + "consentDelegationChallenge", +); +export const consentDelegatedCredential = AccountApiClient.mutation( + "account", + "consentDelegatedCredential", +); +export const rotateDelegatedKey = AccountApiClient.mutation("account", "rotateDelegatedKey"); +export const revokeDelegatedCredential = AccountApiClient.mutation( + "account", + "revokeDelegatedCredential", +); +export const revokeAllDelegatedCredentials = AccountApiClient.mutation( + "account", + "revokeAllDelegatedCredentials", +); + +export const revokeDelegatedCredentialOptimistic = delegatedCredentialsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: (current, arg: { readonly params: { readonly credentialId: string } }) => + AsyncResult.map(current, (response) => ({ + ...response, + credentials: response.credentials.map((credential) => + credential.id === arg.params.credentialId + ? { + ...credential, + lifecycle: { + status: "revoked" as const, + expiresAt: credential.lifecycle.expiresAt, + revokedAt: new Date().toISOString(), + revokeReason: "revoked", + }, + } + : credential, + ), + })), + fn: revokeDelegatedCredential, + }), +); + +export const revokeAllDelegatedCredentialsOptimistic = delegatedCredentialsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: ( + current, + arg: { + readonly payload: { + readonly target: + | { readonly kind: "self" } + | { readonly kind: "verified-human"; readonly id: string }; + }; + }, + ) => + AsyncResult.map(current, (response) => ({ + ...response, + credentials: response.credentials.map((credential) => { + const matches = + arg.payload.target.kind === "self" + ? credential.target.isCurrentUser + : credential.target.id === arg.payload.target.id; + return matches + ? { + ...credential, + lifecycle: { + status: "revoked" as const, + expiresAt: credential.lifecycle.expiresAt, + revokedAt: new Date().toISOString(), + revokeReason: "subject-revoked", + }, + } + : credential; + }), + })), + fn: revokeAllDelegatedCredentials, + }), +); + +export const consentDelegatedCredentialOptimistic = delegatedCredentialsOptimisticAtom.pipe( + Atom.optimisticFn({ + reducer: ( + current, + arg: { + readonly params: { readonly credentialId: string }; + readonly payload: { + readonly scope: DelegationScopeValue; + readonly expiresAt: string; + }; + }, + ) => + AsyncResult.map(current, (response) => ({ + ...response, + credentials: response.credentials.map((credential) => + credential.id === arg.params.credentialId + ? { + ...credential, + scope: arg.payload.scope, + lifecycle: { ...credential.lifecycle, expiresAt: arg.payload.expiresAt }, + } + : credential, + ), + })), + fn: consentDelegatedCredential, + }), +); diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index 7f9bc27407..a31fd8670f 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -45,6 +45,9 @@ export const ReactivityKey = { * writes), so nothing invalidates it — it exists so the pages refresh * together and so a future lifecycle mutation has a key to publish. */ adminUsers: "admin:users", + /** Delegated credential grants, proof challenges, and lifecycle changes. */ + delegatedCredentials: "delegated:credentials", + delegationAudit: "delegated:audit", } as const; /** Mutations that add/remove/refresh an integration also affect tool listings. */ @@ -102,3 +105,5 @@ export const authWriteKeys = [ ReactivityKey.orgMembers, ReactivityKey.orgDomains, ] as const; + +export const delegatedCredentialWriteKeys = [ReactivityKey.delegatedCredentials] as const; diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index def5841e58..3ef004f40c 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -39,6 +39,7 @@ export const CONSOLE_ROUTE_PATHS = [ "/integrations/add/$pluginKey", "/integrations/browse", "/policies", + "/delegated-credentials", "/secrets", "/tools", "/users", @@ -81,6 +82,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array = [ { to: "/", label: "Integrations" }, { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, + { to: "/delegated-credentials", label: "Delegated credentials" }, { to: "/toolkits", label: "Toolkits" }, { to: "/artifacts", label: "Artifacts" }, ]; diff --git a/packages/react/src/pages/delegated-credentials.tsx b/packages/react/src/pages/delegated-credentials.tsx new file mode 100644 index 0000000000..52af0653c6 --- /dev/null +++ b/packages/react/src/pages/delegated-credentials.tsx @@ -0,0 +1,1028 @@ +import { useEffect, useState } from "react"; +import { Cause, Exit, Option, Schema } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + ConsentDelegationBody, + CreateDelegatedKeyBody, + DelegatedCredentialSummary, + DelegatedCredentialsResponse, + DelegationChallengeResponse, + DelegationScope, + DelegationTarget, +} from "@executor-js/api"; +import { + consentDelegationChallenge, + createDelegatedKey, + delegatedCredentialsOptimisticAtom, + delegationAuditAtom, + delegationChallengeAtom, + delegationNotificationsAtom, + delegationScopeOptionsAtom, + delegationTargetsAtom, + type DelegationScopeOptions, + markDelegationNotificationRead, + revokeAllDelegatedCredentialsOptimistic, + revokeDelegatedCredentialOptimistic, + rotateDelegatedKey, + startDelegationChallenge, +} from "../api/account-atoms"; +import { delegatedCredentialWriteKeys } from "../api/reactivity-keys"; +import { Button } from "../components/button"; +import { CopyButton } from "../components/copy-button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/dialog"; +import { ErrorState } from "../components/error-state"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; +import { NativeSelect, NativeSelectOption } from "../components/native-select"; +import { PageContainer, PageHeader } from "../components/page"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useExecutorDocumentTitle } from "../lib/document-title"; + +type Scope = typeof DelegationScope.Type; +type Target = typeof DelegationTarget.Type; +type Credential = typeof DelegatedCredentialSummary.Type; +type CredentialsResponse = typeof DelegatedCredentialsResponse.Type; +type Challenge = typeof DelegationChallengeResponse.Type; +type ScopeOptions = DelegationScopeOptions; +type ConnectionMode = "toolkit" | "none" | "selected"; +const decodeFailureMessage = Schema.decodeUnknownOption(Schema.Struct({ message: Schema.String })); + +const defaultExpiry = (): string => { + const value = new Date(); + value.setDate(value.getDate() + 30); + return value.toISOString().slice(0, 10); +}; + +const expiryTimestamp = (value: string): string => new Date(`${value}T23:59:59.000Z`).toISOString(); + +const defaultScope = (): Scope => ({ + // A named toolkit grant needs discovery to let a machine enumerate the + // exact frozen tools before invoking one. Users can opt out when they have + // a separate, pre-known invocation path. + scopes: ["discover", "invoke"], + toolkitIds: [], +}); + +const maxExpiryDate = (value: string | undefined): string | undefined => + value === undefined ? undefined : value.slice(0, 10); + +const proofTypeForAdapter = (adapter: string): string => + adapter.endsWith("-api-key") ? "api-key" : adapter.endsWith("-access") ? "access" : adapter; + +const connectionModeForScope = (scope: Scope): ConnectionMode => + scope.connectionIds === undefined + ? "toolkit" + : scope.connectionIds.length === 0 + ? "none" + : "selected"; + +const failureMessage = (exit: Exit.Exit, fallback: string): string => { + if (Exit.isSuccess(exit)) return fallback; + const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); + const message = error === null ? null : Option.getOrNull(decodeFailureMessage(error))?.message; + if (message !== undefined && message !== null) return message; + return fallback; +}; + +const useDelegationScopeOptions = (target: "self" | string) => { + const result = useAtomValue(delegationScopeOptionsAtom(target)); + return { + options: AsyncResult.isSuccess(result) ? (result.value as ScopeOptions) : null, + loading: isAsyncResultLoading(result), + }; +}; + +function ScopeFields(props: { + readonly scope: Scope; + readonly options: ScopeOptions | null; + readonly loading: boolean; + readonly onChange: (scope: Scope) => void; +}) { + const [connectionMode, setConnectionMode] = useState(() => + connectionModeForScope(props.scope), + ); + const toggle = (scope: Scope["scopes"][number]) => { + const scopes = props.scope.scopes.includes(scope) + ? props.scope.scopes.filter((value) => value !== scope) + : [...props.scope.scopes, scope]; + props.onChange({ ...props.scope, scopes }); + }; + return ( +
+ +
+ {(["discover", "invoke"] as const).map((scope) => ( + + ))} +
+
+ + {props.loading && ( +

Loading available toolkits…

+ )} + {!props.loading && props.options === null && ( +

+ Scope choices are unavailable for this verified target. Try again before consenting. +

+ )} + {props.options && props.options.toolkits.length === 0 && ( +

No verified toolkit access is available.

+ )} + {props.options?.toolkits.map((toolkit) => ( + + ))} +

+ An empty toolkit list denies every tool. Membership is frozen at consent. +

+
+
+ + { + const mode = event.target.value; + if (mode === "toolkit") { + setConnectionMode(mode); + props.onChange({ ...props.scope, connectionIds: undefined }); + } else if (mode === "none") { + setConnectionMode(mode); + props.onChange({ ...props.scope, connectionIds: [] }); + } else if (mode === "selected") { + setConnectionMode(mode); + props.onChange({ ...props.scope, connectionIds: props.scope.connectionIds ?? [] }); + } + }} + > + Frozen toolkit membership + Deny all connections + Allow selected connections + + {connectionMode === "selected" && ( +
+ {props.options?.connections.length === 0 && ( +

+ No verified connections are available. +

+ )} + {props.options?.connections.map((connection) => ( + + ))} +
+ )} +

+ An explicit empty list denies every connection; omitted uses the frozen toolkit set. +

+
+
+ ); +} + +function TargetSelect(props: { + readonly targets: readonly Target[]; + readonly value: "self" | string; + readonly onChange: (value: "self" | string) => void; +}) { + return ( +
+ + props.onChange(event.target.value)} + > + My account + {props.targets.map((target) => ( + + {target.email} + {target.name ? ` — ${target.name}` : ""} + + ))} + +
+ ); +} + +function CredentialRow(props: { + readonly credential: Credential; + readonly onRevoke: (credential: Credential) => void; + readonly onRotate: (credential: Credential) => void; + readonly rotating: boolean; +}) { + const credential = props.credential; + return ( +
+
+

{credential.label}

+

+ {credential.reference.kind === "external" + ? `${credential.reference.adapter}: ${credential.reference.reference}` + : credential.reference.prefix} +

+
+
+

{credential.target.email}

+

{credential.lifecycle.status}

+
+
+

{credential.scope.scopes.join(", ") || "No operations"}

+

+ expires {new Date(credential.lifecycle.expiresAt).toLocaleDateString()} +

+ {credential.notification && ( +

notification: {credential.notification.status}

+ )} +
+
+ {credential.supportsRotation && credential.lifecycle.status === "active" && ( + + )} + {credential.lifecycle.status === "active" && ( + + )} +
+
+ ); +} + +function CreateDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly targets: readonly Target[]; + readonly onCreate: (body: typeof CreateDelegatedKeyBody.Type) => Promise; +}) { + const [label, setLabel] = useState("Delegated key"); + const [target, setTarget] = useState<"self" | string>("self"); + const [scope, setScope] = useState(defaultScope); + const [expiresAt, setExpiresAt] = useState(defaultExpiry); + const [value, setValue] = useState(null); + const [creating, setCreating] = useState(false); + const scopeOptions = useDelegationScopeOptions(target); + + const submit = async () => { + if ( + !label.trim() || + scope.scopes.length === 0 || + scopeOptions.options === null || + scopeOptions.loading || + creating + ) + return; + setCreating(true); + const created = await props.onCreate({ + label: label.trim(), + target: target === "self" ? { kind: "self" } : { kind: "verified-human", id: target }, + scope, + expiresAt: expiryTimestamp(expiresAt), + }); + setCreating(false); + if (created) setValue(created); + }; + + return ( + + + + Create delegated key + + {value ? ( +
+

+ Copy this secret now. It will not be shown again. +

+
+ + +
+
+ ) : ( +
+
+ + setLabel(event.target.value)} + /> +
+ +
+ + setExpiresAt(event.target.value)} + /> +
+ +
+ )} + + + + + {!value && ( + + )} + +
+
+ ); +} + +function ChallengeDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly targets: readonly Target[]; + readonly adapters: readonly string[]; + readonly challenge: Challenge | null; + readonly onStart: (body: { + readonly adapter: string; + readonly label: string; + readonly target: + | { readonly kind: "self" } + | { readonly kind: "verified-human"; readonly id: string }; + readonly scope: Scope; + readonly expiresAt: string; + }) => Promise; + readonly onConsent: ( + challenge: Challenge, + body: typeof ConsentDelegationBody.Type, + ) => Promise; +}) { + const [label, setLabel] = useState("External delegated credential"); + const [adapter, setAdapter] = useState(props.adapters[0] ?? ""); + const [target, setTarget] = useState<"self" | string>("self"); + const [scope, setScope] = useState(defaultScope); + const [expiresAt, setExpiresAt] = useState(defaultExpiry); + const [starting, setStarting] = useState(false); + const [consenting, setConsenting] = useState(false); + const [proofNonce, setProofNonce] = useState(null); + const scopeOptions = useDelegationScopeOptions(target); + const refreshChallenge = useAtomRefresh(delegationChallengeAtom(props.challenge?.id ?? "")); + + useEffect(() => { + if (!props.open || props.challenge?.status !== "awaiting-proof") setProofNonce(null); + }, [props.challenge?.status, props.open]); + + useEffect(() => { + if (!props.open || props.challenge?.status !== "awaiting-proof") return; + const interval = setInterval(() => refreshChallenge(), 1_000); + return () => clearInterval(interval); + }, [props.challenge?.id, props.challenge?.status, props.open, refreshChallenge]); + + const start = async () => { + if ( + !adapter || + !label.trim() || + scopeOptions.options === null || + scopeOptions.loading || + starting + ) + return; + setStarting(true); + const started = await props.onStart({ + adapter, + label: label.trim(), + target: target === "self" ? { kind: "self" } : { kind: "verified-human", id: target }, + scope, + expiresAt: expiryTimestamp(expiresAt), + }); + if (started) setProofNonce(started.nonce); + setStarting(false); + }; + + const consent = async () => { + if (!props.challenge || consenting) return; + setConsenting(true); + await props.onConsent(props.challenge, { scope, expiresAt: expiryTimestamp(expiresAt) }); + setConsenting(false); + }; + + return ( + + + + Connect external credential + + {props.challenge ? ( +
+
+

Proof status: {props.challenge.status}

+

Challenge {props.challenge.id}

+ {props.challenge.provenCredential && ( +

+ Proven:{" "} + {props.challenge.provenCredential.kind === "external" + ? `${props.challenge.provenCredential.adapter}: ${props.challenge.provenCredential.reference}` + : props.challenge.provenCredential.prefix} +

+ )} +
+ {props.challenge.status === "awaiting-proof" && proofNonce !== null && ( +
+

+ Using the machine's own credential, POST this JSON to the endpoint below. The + nonce is shown only while this challenge is open. +

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ )} + {props.challenge.instructions && ( +

{props.challenge.instructions}

+ )} + +
+ ) : ( +
+
+ + setAdapter(event.target.value)} + > + {props.adapters.map((item) => ( + + {item} + + ))} + +
+
+ + setLabel(event.target.value)} + /> +
+ +
+ + setExpiresAt(event.target.value)} + /> +
+ +
+ )} + + + + + {!props.challenge ? ( + + ) : props.challenge.status === "proof-complete" ? ( + + ) : null} + +
+
+ ); +} + +export function DelegatedCredentialsPage() { + useExecutorDocumentTitle("Delegated credentials"); + const credentials = useAtomValue(delegatedCredentialsOptimisticAtom); + const targets = useAtomValue(delegationTargetsAtom); + const audit = useAtomValue(delegationAuditAtom); + const notifications = useAtomValue(delegationNotificationsAtom); + const refreshCredentials = useAtomRefresh(delegatedCredentialsOptimisticAtom); + const refreshAudit = useAtomRefresh(delegationAuditAtom); + const refreshNotifications = useAtomRefresh(delegationNotificationsAtom); + const doCreate = useAtomSet(createDelegatedKey, { mode: "promiseExit" }); + const doStartChallenge = useAtomSet(startDelegationChallenge, { mode: "promiseExit" }); + const doConsentChallenge = useAtomSet(consentDelegationChallenge, { mode: "promiseExit" }); + const doMarkNotificationRead = useAtomSet(markDelegationNotificationRead, { + mode: "promiseExit", + }); + const doRotate = useAtomSet(rotateDelegatedKey, { mode: "promiseExit" }); + const doRevoke = useAtomSet(revokeDelegatedCredentialOptimistic, { mode: "promiseExit" }); + const doRevokeAll = useAtomSet(revokeAllDelegatedCredentialsOptimistic, { mode: "promiseExit" }); + const [createOpen, setCreateOpen] = useState(false); + const [challengeOpen, setChallengeOpen] = useState(false); + const [challengeId, setChallengeId] = useState(""); + const challengeResult = useAtomValue(delegationChallengeAtom(challengeId)); + const [rotatingId, setRotatingId] = useState(null); + const [rotatedSecret, setRotatedSecret] = useState(null); + const [targetFilter, setTargetFilter] = useState("all"); + const [disableTarget, setDisableTarget] = useState(false); + const [actionError, setActionError] = useState(null); + + const response: CredentialsResponse | null = AsyncResult.isSuccess(credentials) + ? credentials.value + : null; + const targetRows: readonly Target[] = AsyncResult.isSuccess(targets) + ? targets.value.targets.filter((target) => target.status === "verified") + : []; + const challenge: Challenge | null = + AsyncResult.isSuccess(challengeResult) && challengeResult.value !== null + ? challengeResult.value + : null; + const visibleCredentials = + response?.credentials.filter( + (credential) => targetFilter === "all" || credential.target.id === targetFilter, + ) ?? []; + const cloudMfaAvailable = + response?.capabilities.externalAdapters.includes("workos-api-key") ?? false; + + const create = async (body: typeof CreateDelegatedKeyBody.Type): Promise => { + setActionError(null); + const exit = await doCreate({ payload: body, reactivityKeys: delegatedCredentialWriteKeys }); + if (Exit.isFailure(exit)) { + setActionError(failureMessage(exit, "The delegated key could not be created.")); + return null; + } + refreshCredentials(); + return exit.value.value; + }; + + const start = async (body: { + readonly adapter: string; + readonly label: string; + readonly target: + | { readonly kind: "self" } + | { readonly kind: "verified-human"; readonly id: string }; + readonly scope: Scope; + readonly expiresAt: string; + }): Promise => { + setActionError(null); + const exit = await doStartChallenge({ + payload: body, + reactivityKeys: delegatedCredentialWriteKeys, + }); + if (Exit.isFailure(exit)) { + setActionError(failureMessage(exit, "The external challenge could not be started.")); + return null; + } + setChallengeId(exit.value.id); + return exit.value; + }; + + const consent = async (current: Challenge, body: typeof ConsentDelegationBody.Type) => { + setActionError(null); + const exit = await doConsentChallenge({ + params: { challengeId: current.id }, + payload: body, + reactivityKeys: delegatedCredentialWriteKeys, + }); + if (Exit.isSuccess(exit)) { + refreshCredentials(); + setChallengeId(""); + setChallengeOpen(false); + } else { + setActionError(failureMessage(exit, "The external credential could not be approved.")); + } + }; + + const rotate = async (credential: Credential) => { + if (rotatingId) return; + setActionError(null); + setRotatingId(credential.id); + const exit = await doRotate({ + params: { credentialId: credential.id }, + reactivityKeys: delegatedCredentialWriteKeys, + }); + setRotatingId(null); + if (Exit.isSuccess(exit)) { + setRotatedSecret(exit.value.value); + refreshCredentials(); + } else { + setActionError(failureMessage(exit, "The delegated key could not be rotated.")); + } + }; + + const revoke = async (credential: Credential) => { + if (!window.confirm(`Revoke ${credential.label}?`)) return; + setActionError(null); + const exit = await doRevoke({ + params: { credentialId: credential.id }, + reactivityKeys: delegatedCredentialWriteKeys, + }); + if (Exit.isFailure(exit)) { + setActionError(failureMessage(exit, "The delegated credential could not be revoked.")); + return; + } + refreshCredentials(); + refreshAudit(); + }; + + const revokeAll = async () => { + const target = + response?.capabilities.canFilterTargets && targetFilter !== "all" + ? { kind: "verified-human" as const, id: targetFilter } + : { kind: "self" as const }; + if ( + !window.confirm( + target.kind === "self" + ? "Revoke all delegated credentials for your account?" + : "Revoke all delegated credentials for this target?", + ) + ) + return; + setActionError(null); + const exit = await doRevokeAll({ + payload: { + target, + ...(disableTarget && target.kind === "verified-human" ? { disableTarget: true } : {}), + }, + reactivityKeys: delegatedCredentialWriteKeys, + }); + if (Exit.isFailure(exit)) { + setActionError(failureMessage(exit, "The delegated credentials could not be revoked.")); + return; + } + refreshCredentials(); + refreshAudit(); + }; + + const markNotificationRead = async (notificationId: string) => { + const exit = await doMarkNotificationRead({ + params: { notificationId }, + reactivityKeys: delegatedCredentialWriteKeys, + }); + if (Exit.isFailure(exit)) { + setActionError(failureMessage(exit, "The notification could not be marked read.")); + return; + } + refreshNotifications(); + }; + + return ( + + 0) ? ( +
+ {response.capabilities.localKeysEnabled && ( + + )} + {response.capabilities.externalAdapters.length > 0 && ( + + )} +
+ ) : null + } + /> + + {response && !response.capabilities.issuanceEnabled && ( +
+ Delegated credential issuance is disabled by this host administrator. +
+ )} + {response?.capabilities.issuanceEnabled && ( +
+ )} + {actionError && ( +
+

+ {actionError} +

+

+ If verification is required, sign in again or complete your host's verification, + then try again. + {cloudMfaAvailable && ( + <> + {" "} + + Verify MFA in Organization settings + + . + + )} +

+
+ )} + {!response && isAsyncResultLoading(credentials) && ( +

Loading delegated credentials…

+ )} + {AsyncResult.isFailure(credentials) && ( + + )} + {response && ( +
+ {rotatedSecret && ( +
+

Your rotated secret is shown once.

+
+ + + +
+
+ )} +
+

+ Credentials ({visibleCredentials.length}) +

+ {response.capabilities.canFilterTargets && ( + { + setTargetFilter(event.target.value); + setDisableTarget(false); + }} + > + All targets + {targetRows.map((target) => ( + + {target.email} + + ))} + + )} +
+
+ {visibleCredentials.length === 0 ? ( +

+ No delegated credentials for this filter. +

+ ) : ( + visibleCredentials.map((credential) => ( + void revoke(item)} + onRotate={(item) => void rotate(item)} + /> + )) + )} +
+
+ )} + +
+ {AsyncResult.isSuccess(notifications) && notifications.value.notifications.length > 0 && ( +
+

Notifications

+
+ {notifications.value.notifications.map((notification) => ( +
+
+

{notification.title}

+

{notification.message}

+
+ {!notification.readAt && ( + + )} +
+ ))} +
+
+ )} +
+
+

Your grants

+

+ Revoke every credential acting for your account. +

+
+ +
+ {response?.capabilities.canFilterTargets && targetFilter !== "all" && ( + + )} +
+ +
+

Audit

+
+ {AsyncResult.isSuccess(audit) && audit.value.events.length > 0 ? ( + audit.value.events.map((event) => ( +
+ {event.action} + {event.outcome} + + {new Date(event.occurredAt).toLocaleString()} + +
+ )) + ) : ( +

No audit events yet.

+ )} +
+
+ + + { + setChallengeOpen(open); + if (!open) setChallengeId(""); + }} + targets={targetRows} + adapters={response?.capabilities.externalAdapters ?? []} + challenge={challenge} + onStart={start} + onConsent={consent} + /> + + ); +} diff --git a/packages/react/src/routes/delegated-credentials.tsx b/packages/react/src/routes/delegated-credentials.tsx new file mode 100644 index 0000000000..419c7244d9 --- /dev/null +++ b/packages/react/src/routes/delegated-credentials.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { DelegatedCredentialsPage } from "../pages/delegated-credentials"; + +export const Route = createFileRoute("/{-$orgSlug}/delegated-credentials")({ + component: DelegatedCredentialsPage, +}); diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index 94c56036a0..21d71290d3 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './__root' import { Route as DotIndexRouteImport } from './index' import { Route as DotArtifactsRouteImport } from './artifacts' +import { Route as DotDelegatedCredentialsRouteImport } from './delegated-credentials' import { Route as DotPoliciesRouteImport } from './policies' import { Route as DotSecretsRouteImport } from './secrets' import { Route as DotToolkitsRouteImport } from './toolkits' @@ -35,6 +36,11 @@ const DotArtifactsRoute = DotArtifactsRouteImport.update({ path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDelegatedCredentialsRoute = DotDelegatedCredentialsRouteImport.update({ + id: '/{-$orgSlug}/delegated-credentials', + path: '/{-$orgSlug}/delegated-credentials', + getParentRoute: () => rootRouteImport, +} as any) const DotPoliciesRoute = DotPoliciesRouteImport.update({ id: '/{-$orgSlug}/policies', path: '/{-$orgSlug}/policies', @@ -110,6 +116,7 @@ const DotPluginsDotpluginIdDotsplatRoute = export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren @@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren @@ -145,6 +153,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren + '/{-$orgSlug}/delegated-credentials': typeof DotDelegatedCredentialsRoute '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren @@ -164,6 +173,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -181,6 +191,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -198,6 +209,7 @@ export interface FileRouteTypes { id: | '__root__' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/delegated-credentials' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -216,6 +228,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { DotArtifactsRoute: typeof DotArtifactsRouteWithChildren + DotDelegatedCredentialsRoute: typeof DotDelegatedCredentialsRoute DotPoliciesRoute: typeof DotPoliciesRoute DotSecretsRoute: typeof DotSecretsRoute DotToolkitsRoute: typeof DotToolkitsRouteWithChildren @@ -246,6 +259,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/delegated-credentials': { + id: '/{-$orgSlug}/delegated-credentials' + path: '/{-$orgSlug}/delegated-credentials' + fullPath: '/{-$orgSlug}/delegated-credentials' + preLoaderRoute: typeof DotDelegatedCredentialsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/policies': { id: '/{-$orgSlug}/policies' path: '/{-$orgSlug}/policies' @@ -366,6 +386,7 @@ const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { DotArtifactsRoute: DotArtifactsRouteWithChildren, + DotDelegatedCredentialsRoute: DotDelegatedCredentialsRoute, DotPoliciesRoute: DotPoliciesRoute, DotSecretsRoute: DotSecretsRoute, DotToolkitsRoute: DotToolkitsRouteWithChildren, From 299660d68c055d4b18be998dbfc2dfc2788a24c1 Mon Sep 17 00:00:00 2001 From: donmasakayan <1178461+donmasakayan@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:36:52 +1300 Subject: [PATCH 5/5] docs: document delegated credentials and verify local rollout --- .changeset/delegated-credentials.md | 6 + apps/docs/concepts/delegated-credentials.mdx | 120 +++ apps/docs/docs.json | 7 +- apps/docs/hosted/docker.mdx | 37 +- apps/host-cloudflare/README.md | 63 ++ e2e/RUNNING.md | 91 ++ e2e/local/trace-redaction.test.ts | 92 ++ e2e/selfhost/delegated-credentials.test.ts | 834 +++++++++++++++++++ e2e/setup/cloudflare.boot.ts | 2 + e2e/setup/selfhost.boot.ts | 4 + e2e/src/surfaces/browser.ts | 190 +++-- e2e/src/trace-redaction.ts | 149 ++++ 12 files changed, 1522 insertions(+), 73 deletions(-) create mode 100644 .changeset/delegated-credentials.md create mode 100644 apps/docs/concepts/delegated-credentials.mdx create mode 100644 e2e/local/trace-redaction.test.ts create mode 100644 e2e/selfhost/delegated-credentials.test.ts create mode 100644 e2e/src/trace-redaction.ts diff --git a/.changeset/delegated-credentials.md b/.changeset/delegated-credentials.md new file mode 100644 index 0000000000..bca468e725 --- /dev/null +++ b/.changeset/delegated-credentials.md @@ -0,0 +1,6 @@ +--- +"executor": minor +--- + +Add scoped delegated credentials with explicit machine identity, immutable +tool and connection limits, possession proof, expiry, revocation, and audit. diff --git a/apps/docs/concepts/delegated-credentials.mdx b/apps/docs/concepts/delegated-credentials.mdx new file mode 100644 index 0000000000..91abc06d17 --- /dev/null +++ b/apps/docs/concepts/delegated-credentials.mdx @@ -0,0 +1,120 @@ +--- +title: Delegated credentials +description: "Give a machine a bounded way to use Executor without giving it a human account." +--- + +A **delegated credential** lets a machine use a small, explicit part of a human +account's Executor workspace. Executor keeps the grant separate from the +human session and checks it at every tool boundary. The machine does not +become the human, and it does not inherit the human's administrator role. + +## The identities in a grant + +Every grant keeps four identities distinct: + +- the **creator** who starts or approves the grant +- the **machine actor** that presents the credential on each request +- the **effective subject** whose connections and membership are used +- the **grant** that fixes the consent and lifecycle + +The effective subject must be a verified, currently enabled member. An +administrator may create a grant for another verified member when the host +allows that operation, but the grant still runs with the target member's +authority. The target member does not receive the creator's +administrator authority. + +## Create and prove a credential + +An Executor-issued key is shown once when it is created. Store it in the +machine's secret manager; Executor stores only the reference needed to resolve +the grant. Rotate or revoke the key when it may have been exposed. + +An external credential, such as a Cloudflare Access service token, must first +complete a one-time possession challenge. The human starts the challenge and +gives the machine only the proof material it needs. Executor stores a digest +of the proof, never the bearer token or the service-token secret. The external +provider remains responsible for issuing and disabling that credential. + +## Scope is a consent snapshot + +Consent uses the account UI's toolkit and connection selections; callers do +not enter raw tool addresses or wildcards. Executor resolves those selections +to exact tool membership and stores an immutable scope snapshot. An omitted +connection limit means the frozen connections selected by the toolkit. An +explicit empty connection limit denies connection-backed calls. Adding a new +tool to an integration does not silently expand an existing grant. + +Delegated requests may list a catalog, read a schema, invoke an approved tool, +and continue an approved execution or approval. Account administration, +credential management, grant management, and other management operations are +outside the delegated surface. Normal policies and connection ownership still +apply to every call. + +## Expiry and revocation + +Grants use the deployment's expiry policy: the default is 30 days and the +standard maximum is 90 days, with the operator able to configure a shorter +maximum. +Revoke a single grant when one machine is offboarded; use revoke-all when a +member leaves or all of that member's machines must stop. Revocation and +membership are checked again when a request, approval, or continuation is +handled. A known revoked or expired credential fails closed and is never +treated as an anonymous request. + +Host operators control issuance and freshness with these settings. Durations +are integer milliseconds; the SDK hard maximum for a grant is 90 days. + +| Host | Issuance gate | Maximum grant TTL | Recent human-auth window | +| ---------- | --------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------- | +| Self-host | `EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE=true` | `EXECUTOR_DELEGATED_MAX_TTL_MS` (default 90 days; hard maximum 90 days) | `EXECUTOR_DELEGATED_RECENT_AUTH_MAX_AGE_MS` (default 5 minutes) | +| Cloud | `DELEGATED_ISSUANCE_ENABLED=true` | `DELEGATED_MAX_TTL_MS` (default 90 days; hard maximum 90 days) | `DELEGATED_RECENT_AUTH_MAX_AGE_MS` (default 5 minutes) | +| Cloudflare | `DELEGATED_CREDENTIAL_ISSUANCE=true` | `DELEGATED_MAX_TTL_MS` (default 90 days; hard maximum 90 days) | `DELEGATED_RECENT_AUTH_MAX_AGE_MS` (default 5 minutes) | + +Leave each issuance gate unset or set to any value other than `true` until the +host's account and proof adapters are ready. Self-host and Cloudflare reject +malformed or over-limit TTL settings; Cloud's invalid or non-positive TTL value +falls back to the 90-day hard maximum. A shorter cap is useful during a canary. + +Self-host measures recent human authentication from the Better Auth session +creation time. Cloud measures it from the signed administrator MFA proof. The +configured window applies to those host-specific assurance records, not to a +machine credential's age. + +Cloudflare human management also requires a signed recent-login claim in the +verified Access JWT. The default claim is `auth_time`; configure +`DELEGATED_RECENT_AUTH_CLAIM` when the identity provider emits another signed +claim. The verifier accepts the claim at the JWT top level or inside Access's +signed `custom` object. It never treats `iat` as a login event. A missing or +stale claim fails closed. + +Revoke-all normally revokes current grants while leaving the verified target +eligible for a later consent. An organization administrator can request +`disableTarget: true` when the host supports durable offboarding; the host then +marks the target unavailable after revocation commits. Hosts without that +offboarding callback reject the request, so removing a member from the host's +membership source remains the required fallback. + +The audit record contains grant and credential references, subjects, action, +tool, outcome, and request or execution identifiers. It does not contain +tokens, authorization headers, tool arguments, or tool results. + +## Account API naming + +The source contract in this release keeps personal API keys at +`/account/api-keys`, with list, create, and revoke operations. Delegated +credentials use the separate `/account/delegated-credentials` surface; they do +not replace or reinterpret personal API keys. + +## Roll out safely + +Keep issuance disabled while the verifier, audit events, revocation checks, and +session binding are being verified. Enable issuance only in a local or +restricted canary with short-lived grants. Before broad rollout, exercise +allowed and denied tools, empty scopes, cross-tenant requests, administrator +non-inheritance, active-session revocation, and member offboarding. + +To roll back, stop new issuance first, revoke affected grants, and block +delegated resolution while existing sessions are drained. Keep the grant +records and revocation tombstones available until no old session can present +them; removing the resolver must not turn a known credential into an absent +credential. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f05ef3ec2f..64d9b9e882 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -37,7 +37,12 @@ }, { "group": "Concepts", - "pages": ["concepts/integrations", "concepts/connections", "concepts/policies"] + "pages": [ + "concepts/integrations", + "concepts/connections", + "concepts/policies", + "concepts/delegated-credentials" + ] } ] }, diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 807951b07f..6b7d09dff9 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -53,23 +53,26 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`) Everything is optional: a bare run boots a working instance. The defaults below are the container defaults. -| Variable | Default | Purpose | -| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `PORT` | `4788` | HTTP port the server listens on. | -| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | -| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | -| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | -| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | -| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | -| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | -| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | -| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | -| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | -| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | -| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | -| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | -| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | -| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | +| Variable | Default | Purpose | +| ------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | +| `PORT` | `4788` | HTTP port the server listens on. | +| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | +| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | +| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | +| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | +| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | +| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | +| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | +| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | +| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | +| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | +| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | +| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | +| `EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE` | unset (disabled) | Explicitly enable delegated credential issuance after verifying the account and proof adapters. | +| `EXECUTOR_DELEGATED_MAX_TTL_MS` | `7776000000` (90 days) | Maximum delegated grant lifetime; cannot exceed the 90-day SDK hard maximum. | +| `EXECUTOR_DELEGATED_RECENT_AUTH_MAX_AGE_MS` | `300000` (5 minutes) | Maximum age of the Better Auth session creation time accepted for human management. | +| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 9700570289..136a0c7520 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -16,6 +16,63 @@ paths, different injected providers: Single-tenant: every Access-verified principal belongs to the one configured org. Members and credentials are managed in Cloudflare Access, not in-app. +## Delegated machine access + +When delegated binding is enabled, Cloudflare Access service tokens can be +bound to an Executor delegated grant. +The grant is a separate, bounded permission for a verified member: it names +the exact tools and optional connections the machine may use, has an expiry, +and is checked again for every call and continuation. A service token does not +inherit the Access user's administrator role. + +In an enabled deployment, the human starts the binding from the account's +delegated-credential surface. +Executor requires a one-time possession proof before the external token is +accepted. Store the Access client secret only in the machine's secret manager; +Executor stores a reference and proof digest, not the secret. Disable the +service token in Access and revoke the Executor grant during offboarding. + +The existing personal API-key routes remain separate. This host's source +contract exposes `/account/api-keys`; an enabled delegated surface uses +`/account/delegated-credentials`. + +Issuance is gated by `DELEGATED_CREDENTIAL_ISSUANCE`, which defaults to +disabled. Keep the gate unset during rollout and local runs that do not provide +the account and proof adapters; a missing adapter fails closed. The local +Cloudflare worker still uses dev auth and cannot stand in for Access proof. + +The worker accepts these delegated-credential settings (durations are integer +milliseconds): + +```text +DELEGATED_CREDENTIAL_ISSUANCE=true # explicit opt-in; default is disabled +DELEGATED_MAX_TTL_MS=7776000000 # default and hard maximum: 90 days +DELEGATED_RECENT_AUTH_MAX_AGE_MS=300000 # default: 5 minutes +DELEGATED_RECENT_AUTH_CLAIM=auth_time # signed Access/IdP claim name +``` + +`DELEGATED_RECENT_AUTH_CLAIM` may name a signed top-level JWT claim or a claim +inside Access's signed `custom` object. The default `auth_time` represents the +IdP authentication event. `iat` is token issuance time and is not accepted as +recent-login proof. Missing or stale assurance fails closed. A configured TTL +must be a positive integer no greater than the 90-day hard maximum; malformed +configuration stops worker startup. + +Revoke-all ordinarily leaves a verified member eligible to consent again. An +organization administrator may request `disableTarget: true` to revoke all +current grants and create a durable Access verification tombstone after the +revocation commits. Use that explicit action when offboarding a member; later +requests for that target fail closed until the host's verified-member registry +allows it again. + +Human management also requires a recent login assurance in the signed Access +JWT. Cloudflare's standard application token documents `iat` as token issuance +time, so this host never treats `iat` as a fresh-login signal. Configure the +identity provider to emit a signed Unix-seconds claim (for example `auth_time`, +including under Access's signed `custom` claim) and set +`DELEGATED_RECENT_AUTH_CLAIM` to that name. Requests without the claim fail +closed; `DELEGATED_RECENT_AUTH_MAX_AGE_MS` controls the accepted age. + ## Surfaces - `GET /` — the shared Executor web UI (Sources, Connections, Secrets, @@ -81,6 +138,12 @@ running `wrangler dev` if you need live data. `ENABLE_DEV_AUTH` is a dev-only escape hatch — never set it in a deployed environment (it disables the Access gate). +The local Cloudflare harness uses `ENABLE_DEV_AUTH=true`, which intentionally +represents one fixed administrator and cannot prove Access service-token +claims, issuer checks, or machine identity. Run the Access-specific checks +against a local Access-compatible issuer when one is available; do not point +the harness at a live Cloudflare tenant from an e2e run. + ## Notes - The QuickJS engine WASM is vendored into `src/quickjs-engine.wasm` (Workers diff --git a/e2e/RUNNING.md b/e2e/RUNNING.md index f16831addd..6811ea8250 100644 --- a/e2e/RUNNING.md +++ b/e2e/RUNNING.md @@ -105,6 +105,97 @@ const r = yield * session.call("execute", { code: "return 1 + 1;" }); // human-in-the-loop: session.approvePaused(r.text) resumes a paused execution ``` +## Delegated credential scenarios + +Delegated-credential scenarios must run against the local application targets +and the published `@executor-js/emulate` package. Start provider emulators with +the local `createEmulator({ service, port })` factory; do not create hosted +`/_emulate/instances` or use a live provider. Issuance is disabled by default. +The self-host gate is `EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE=true`, the Cloud +gate is `DELEGATED_ISSUANCE_ENABLED=true`, and the Cloudflare gate is +`DELEGATED_CREDENTIAL_ISSUANCE=true`. Keep all three unset for ordinary runs +and enable one only in an isolated local issuance run after that host's account +and proof adapters are wired; the fail-closed provider remains the expected +result while either adapter is missing. Host operators can cap grant lifetime +with `EXECUTOR_DELEGATED_MAX_TTL_MS` (self-host) or `DELEGATED_MAX_TTL_MS` +(Cloud/Cloudflare), and tune recent human-auth windows with +`EXECUTOR_DELEGATED_RECENT_AUTH_MAX_AGE_MS` (self-host) or +`DELEGATED_RECENT_AUTH_MAX_AGE_MS` (Cloud/Cloudflare). Cloudflare additionally +uses `DELEGATED_RECENT_AUTH_CLAIM` for its signed Access freshness claim and +never uses `iat` as a login event. + +The coverage contract requires issuance and one-time reveal, exact allowed and +denied tool calls, proof and replay rejection, tenant isolation, administrator +non-inheritance, grant revocation while a session exists, and session binding +through approval or resume. Each host must use its local auth/emulator path; +Cloudflare's default `ENABLE_DEV_AUTH` target proves one fixed admin and does +not prove Access JWT or service-token claims. + +The current runnable delegated scenarios are in +`selfhost/delegated-credentials.test.ts`. The default path proves the account +API fails closed, while the opt-in path covers local issuance, a zero-tool +consent snapshot, masked listing, and revocation through the real browser and +account API when the self-host route tree and adapter are enabled. +The SDK enforcement tests cover the deterministic allow/deny, tenant, admin, +revocation, and session-binding edges without a network. The remaining host +and MCP journeys below are coverage requirements, not claims that their +adapters are already runnable: + +| Target | Browser/API journey | MCP journey | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Cloud | Pending cloud account adapter: create a local key through the API, then verify masked listing, target and tenant isolation. | Pending cloud machine-auth wiring: allowed/denied calls, proof replay, and revocation during an existing session. | +| Self-host | Runnable when the generated host route is present: local issuance, masked listing, empty snapshot, and revocation. Target selection, member non-inheritance, revoke-all, and audit remain follow-up coverage. | Pending machine-auth/session binding: allowed/denied calls, proof replay, and approval/resume binding. | +| Cloudflare | Pending worker account adapter; the default dev-auth target is insufficient to claim Access-managed issuance or member behavior. | Explicitly skipped until a local Access-compatible issuer exists; never use a live tenant. | + +Run the target suite from this directory with the narrowest target while the +delegated scenario is enabled in that target's configuration: + +```sh +bun run test:cloud +bun run test:selfhost +bun run test:cloudflare +``` + +The self-host delegated issuance journey is opt-in and runs as a narrow +scenario after its generated host route and adapter are enabled: + +```sh +EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE=true \ + bun run test:selfhost -- selfhost/delegated-credentials.test.ts +``` + +The local archive-safety checks do not boot a host or contact a provider: + +```sh +bun run test:local -- local/trace-redaction.test.ts +``` + +Set `TMPDIR` to any writable temporary directory when the host environment +requires an explicit location. + +Each run writes `runs///result.json` relative to `e2e/` (the +repository path is `e2e/runs///result.json`). Browser coverage +also writes `trace.zip`, `session.mp4`, and step screenshots. Build and serve the +browsable matrix with `bun run viewer:build` followed by `bun run serve`; the +run page links to the trace viewer. Check the trace and request ledger before +sharing evidence. Authorization headers, issued keys, proof material, and +credential bodies must be redacted or absent from captured artifacts. +When a test receives a one-time key, call the browser session's `redact` hook +for the in-memory value before the session closes. That rewrites textual trace +entries and request or response metadata; binary screenshots and video remain +byte-identical. Keep the value out of the DOM, screenshots, video, result JSON, +and logs as well. +The published emulator's local request ledger also redacts authorization +headers and sensitive credential fields; delegated scenarios check that its +serialized entries contain none of the machine secret, provider secret, or +proof nonce. + +If a browser assertion must inspect the one-time value itself, open a separate +`browser.session(identity, drive, { sensitive: true })`. That opt-in session +does not start Playwright tracing or video and does not save screenshots. Keep +the assertion in memory, then use a normal session for the masked listing and +the post-issuance browser journey. + ## Telemetry scenarios (cloud) The suite boots a motel OTLP store and points the target's real exporter at diff --git a/e2e/local/trace-redaction.test.ts b/e2e/local/trace-redaction.test.ts new file mode 100644 index 0000000000..7ad6836131 --- /dev/null +++ b/e2e/local/trace-redaction.test.ts @@ -0,0 +1,92 @@ +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "@effect/vitest"; + +import { redactTraceArchive, TraceRedactionError } from "../src/trace-redaction"; +import { collectIdentitySecrets } from "../src/surfaces/browser"; + +const execFileAsync = promisify(execFile); + +const makeArchive = async (directory: string, archive: string, secret: string): Promise => { + const encoded = Buffer.from(secret).toString("base64"); + const encodedUrl = Buffer.from(secret).toString("base64url"); + writeFileSync( + join(directory, "trace.txt"), + [secret, encodeURIComponent(secret), encoded, encodedUrl].join("\n"), + ); + writeFileSync(join(directory, "screenshot.bin"), Buffer.from([0xff, 0x00, 0x01, 0xfe])); + await execFileAsync("zip", ["-q", archive, "trace.txt", "screenshot.bin"], { + cwd: directory, + }); +}; + +const readEntry = async (archive: string, name: string): Promise => { + const result = await execFileAsync("unzip", ["-p", archive, name], { encoding: null }); + return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout); +}; + +it("redacts text entries while preserving binary trace assets", async () => { + const directory = mkdtempSync(join(tmpdir(), "executor-redaction-test-")); + const archive = join(directory, "trace.zip"); + const secret = "e2e-redaction-secret-value"; + try { + await makeArchive(directory, archive, secret); + const beforeBinary = readFileSync(join(directory, "screenshot.bin")); + + await redactTraceArchive(archive, [secret]); + + const text = await readEntry(archive, "trace.txt"); + const afterBinary = await readEntry(archive, "screenshot.bin"); + for (const value of [ + secret, + encodeURIComponent(secret), + Buffer.from(secret).toString("base64"), + Buffer.from(secret).toString("base64url"), + ]) { + expect(text.includes(value)).toBe(false); + } + expect(afterBinary.equals(beforeBinary)).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("removes an archive when redaction cannot read it", async () => { + const directory = mkdtempSync(join(tmpdir(), "executor-redaction-test-")); + const archive = join(directory, "invalid.zip"); + try { + writeFileSync(archive, "not a zip archive"); + + await expect( + redactTraceArchive(archive, ["e2e-redaction-secret-value"]), + ).rejects.toBeInstanceOf(TraceRedactionError); + expect(existsSync(archive)).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("collects every browser identity credential for trace redaction", () => { + const identity = { + label: "member", + headers: { + authorization: "Bearer identity-value", + cookie: "session=session-cookie; csrf=csrf-value", + origin: "http://localhost", + }, + cookies: [{ name: "session", value: "session-cookie" }], + credentials: { email: "member@example.test", password: "password-value" }, + }; + const registered = collectIdentitySecrets(identity); + expect(registered.includes(identity.headers.authorization)).toBe(true); + expect(registered.includes("identity-value")).toBe(true); + expect(registered.includes(identity.headers.cookie)).toBe(true); + expect(registered.includes("session-cookie")).toBe(true); + expect(registered.includes("csrf-value")).toBe(true); + expect(registered.includes(identity.headers.origin)).toBe(false); + expect(registered.includes(identity.cookies[0]!.value)).toBe(true); + expect(registered.includes(identity.credentials.password)).toBe(true); +}); diff --git a/e2e/selfhost/delegated-credentials.test.ts b/e2e/selfhost/delegated-credentials.test.ts new file mode 100644 index 0000000000..71d0173242 --- /dev/null +++ b/e2e/selfhost/delegated-credentials.test.ts @@ -0,0 +1,834 @@ +// Selfhost-only: the local issuance path is deliberately opt-in. This scenario +// drives the real account API and browser page when the test instance enables +// EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE=true. The one-time value is read only +// in a sensitive browser session, which produces no trace, screenshots, or +// video; subsequent recordings exercise the masked list and revocation state. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; + +import { expect } from "@effect/vitest"; +import { Cause, Effect, Exit } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import { createEmulator, type Emulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, + microsoftCatalog, + microsoftGraphAdapter, +} from "@executor-js/plugin-openapi/providers/microsoft"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; +import { HttpClientError } from "effect/unstable/http"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { createInvitedIdentity } from "../targets/selfhost"; +import { Api, Browser, Target } from "../src/services"; +import type { Identity } from "../src/target"; +import { revisit, visit } from "../src/surfaces/browser"; + +const issuanceEnabled = process.env.EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE === "true"; +const delegatedApi = composePluginApi([ + openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), + toolkitsPlugin(), +] as const); + +const availablePort = Effect.callback((resume) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resume(Effect.succeed(port))); + }); +}); + +const microsoftEmulator = Effect.acquireRelease( + Effect.gen(function* () { + const port = yield* availablePort; + return yield* Effect.promise(() => createEmulator({ service: "microsoft", port })); + }), + (emulator: Emulator) => Effect.promise(() => emulator.close()).pipe(Effect.ignore), +); + +const withOrigin = (identity: Identity, baseUrl: string): Identity => ({ + ...identity, + headers: { ...identity.headers, origin: new URL(baseUrl).origin }, +}); + +const redactIdentity = (redact: (secret: string) => void, identity: Identity): void => { + for (const [name, value] of Object.entries(identity.headers ?? {})) { + if (/authorization|cookie|token|api[-_]?key/i.test(name)) redact(value); + } + for (const cookie of identity.cookies ?? []) redact(cookie.value); + if (identity.credentials?.password) redact(identity.credentials.password); +}; + +const containsSecret = (value: unknown, secret: string): boolean => + secret.length > 0 && JSON.stringify(value).includes(secret); + +const apiFailureLabel = (operation: string, error: unknown): string => { + const status = HttpClientError.isHttpClientError(error) ? error.response?.status : undefined; + return status === undefined ? `${operation} failed` : `${operation} failed with HTTP ${status}`; +}; + +const toolExpression = (address: string): string => + address + .replace(/^tools\./, "") + .split(".") + .map((segment) => `[${JSON.stringify(segment)}]`) + .join(""); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const executeErrorKinds = new Set([ + "syntax_error", + "type_error", + "reference_error", + "range_error", + "tool_error", + "timeout", + "resource_limit", + "serialization_error", + "thrown", +]); + +const sdkErrorTags = new Set([ + "ConnectionNotFoundError", + "DelegationDeniedError", + "ExecutionToolError", + "NoHandlerError", + "PluginNotLoadedError", + "StorageError", + "ToolBlockedError", + "ToolInvocationError", + "ToolNotFoundError", +]); + +const executionFailureLabel = (structured: unknown): string => { + if (!isRecord(structured)) return "approved emulator call returned an unclassified error"; + const errorKind = structured.errorKind; + if (typeof errorKind === "string" && executeErrorKinds.has(errorKind)) { + return `approved emulator call returned execution error kind ${errorKind}`; + } + const error = structured.error; + if (isRecord(error)) { + const tag = error._tag; + if (typeof tag === "string" && sdkErrorTags.has(tag)) { + return `approved emulator call returned SDK error ${tag}`; + } + const status = error.status; + if (typeof status === "number" && Number.isInteger(status)) { + return `approved emulator call returned HTTP status ${status}`; + } + } + if (typeof error === "string") { + for (const tag of sdkErrorTags) { + if (error.includes(tag)) return `approved emulator call returned SDK error ${tag}`; + } + const status = /(?:HTTP|status)\s*[:=]?\s*(\d{3})/i.exec(error)?.[1]; + if (status !== undefined) return `approved emulator call returned HTTP status ${status}`; + } + return "approved emulator call returned an unclassified error"; +}; + +const requiredString = (value: string | undefined | null, label: string): string => { + if (!value) throw new Error(`local emulator returned no ${label}`); + return value; +}; + +const mcpRequest = async (input: { + readonly url: string; + readonly bearer: string; + readonly sessionId?: string; + readonly id: number; + readonly method: string; + readonly params?: Record; +}): Promise => { + const headers = new Headers({ + authorization: `Bearer ${input.bearer}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }); + if (input.sessionId) { + headers.set("mcp-session-id", input.sessionId); + headers.set("mcp-protocol-version", "2025-03-26"); + } + return fetch(input.url, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: input.id, + method: input.method, + ...(input.params === undefined ? {} : { params: input.params }), + }), + }); +}; + +const initializeMcp = async (url: string, bearer: string): Promise => { + const response = await mcpRequest({ + url, + bearer, + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "delegated-e2e", version: "1" }, + }, + }); + expect(response.status, "delegated MCP initialize succeeds").toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId !== null && sessionId.length > 0, "delegated MCP returns a session id").toBe( + true, + ); + return sessionId ?? ""; +}; + +scenario( + "Delegated credentials · selfhost keeps issuance disabled by default", + { + skip: issuanceEnabled ? "local issuance is explicitly enabled for this run" : undefined, + }, + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(AccountHttpApi, { + ...identity, + headers: { ...identity.headers, origin: new URL(target.baseUrl).origin }, + }); + + const listed = yield* client.account.listDelegatedCredentials({ query: {} }); + expect(listed.capabilities.issuanceEnabled, "ordinary selfhost runs fail closed").toBe(false); + const attempted = yield* Effect.exit( + client.account.createDelegatedKey({ + payload: { + label: "disabled delegated key", + target: { kind: "self" }, + scope: { scopes: ["invoke"], toolkitIds: [] }, + expiresAt: "2099-01-01T00:00:00.000Z", + }, + }), + ); + expect(Exit.isFailure(attempted), "disabled issuance rejects before creating a key").toBe(true); + }), +); + +scenario( + "Delegated credentials · selfhost issues, masks, and revokes a local key", + { + timeout: 180_000, + skip: issuanceEnabled + ? undefined + : "local delegated-key issuance is disabled; set EXECUTOR_DELEGATED_CREDENTIAL_ISSUANCE=true", + }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(AccountHttpApi, { + ...identity, + headers: { ...identity.headers, origin: new URL(target.baseUrl).origin }, + }); + const label = `delegated-e2e-${randomBytes(4).toString("hex")}`; + let credentialId: string | undefined; + let oneTimeValue = ""; + + const cleanup = Effect.gen(function* () { + const listed = yield* client.account + .listDelegatedCredentials({ query: {} }) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (listed) { + yield* Effect.forEach( + listed.credentials.filter((credential) => credential.label === label), + (credential) => + client.account + .revokeDelegatedCredential({ params: { credentialId: credential.id } }) + .pipe(Effect.ignore), + { discard: true }, + ); + } + if (credentialId) { + yield* client.account + .revokeDelegatedCredential({ params: { credentialId } }) + .pipe(Effect.ignore); + } + }).pipe(Effect.ignore); + + yield* Effect.gen(function* () { + // The reveal is intentionally isolated from all browser artifacts. The + // value remains in memory only long enough to prove that the UI received + // a one-time secret; no assertion includes it in an error diff. + yield* browser.session( + identity, + async ({ page, step, redact }) => { + await step("Open delegated credentials", async () => { + await visit(page, "/default/delegated-credentials"); + await page + .getByRole("heading", { name: "Delegated credentials", exact: true }) + .waitFor(); + }); + + await step("Issue a local key with an empty toolkit snapshot", async () => { + await page.getByRole("button", { name: "Create local key" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Label").fill(label); + // The dialog defaults to invoke with no toolkit IDs. Keep the + // consent empty so this flow also covers the deny-by-default scope. + await dialog.getByRole("button", { name: "Create key" }).click(); + await dialog.getByText("Copy this secret now. It will not be shown again.").waitFor(); + oneTimeValue = await dialog.locator("input[readonly]").inputValue(); + redact(oneTimeValue); + expect(oneTimeValue.length > 0, "the UI receives a one-time value").toBe(true); + }); + }, + { sensitive: true }, + ); + + const listed = yield* client.account.listDelegatedCredentials({ query: {} }); + const created = listed.credentials.find((credential) => credential.label === label); + expect(created !== undefined, "the created credential is listed").toBe(true); + credentialId = created?.id; + expect(credentialId !== undefined, "the created credential has an id").toBe(true); + expect( + JSON.stringify(listed).includes(oneTimeValue), + "listing never returns the secret", + ).toBe(false); + + yield* browser.session(identity, async ({ page, step, redact }) => { + redactIdentity(redact, identity); + await step("Review the masked credential row", async () => { + await visit(page, "/default/delegated-credentials"); + await page.getByText(label, { exact: true }).waitFor(); + const body = (await page.locator("body").textContent()) ?? ""; + expect( + body.includes(oneTimeValue), + "the one-time value is absent from the listing UI", + ).toBe(false); + await page.getByText("active", { exact: true }).first().waitFor(); + }); + }); + + if (credentialId === undefined) return; + yield* client.account.revokeDelegatedCredential({ params: { credentialId } }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Review the revoked state", async () => { + await visit(page, "/default/delegated-credentials"); + await page.getByText("No delegated credentials for this filter.").waitFor(); + }); + await step("The revoked credential stays absent after refresh", async () => { + await revisit(page); + const body = (await page.locator("body").textContent()) ?? ""; + expect(body.includes(label), "revocation removes the active row").toBe(false); + expect(body.includes(oneTimeValue), "revocation UI never contains the secret").toBe( + false, + ); + }); + }); + }).pipe(Effect.ensuring(cleanup)); + }), +); + +scenario( + "Delegated credentials · a member key keeps an approved emulator connection scoped through MCP revocation", + { + timeout: 240_000, + skip: issuanceEnabled + ? undefined + : "local delegated-key issuance is disabled; enable it for the member external-key flow", + }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const owner = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, owner, { + role: "member", + emailPrefix: "delegated-member", + }), + ); + const ownerClient = yield* makeApiClient(delegatedApi, withOrigin(owner, target.baseUrl)); + const memberClient = yield* makeApiClient(delegatedApi, withOrigin(member, target.baseUrl)); + const memberAccount = yield* makeApiClient( + AccountHttpApi, + withOrigin(member, target.baseUrl), + ); + const emulator = yield* microsoftEmulator; + const integration = IntegrationSlug.make(`delegated-ms-${randomBytes(4).toString("hex")}`); + const oauthClient = OAuthClientSlug.make(`delegated-oauth-${randomBytes(4).toString("hex")}`); + const connection = ConnectionName.make("memberApproved"); + const toolkitName = `delegated-toolkit-${randomBytes(4).toString("hex")}`; + let toolkitId: string | undefined; + let machineKeyId: string | undefined; + let credentialId: string | undefined; + + const cleanup = Effect.gen(function* () { + if (credentialId) { + yield* memberAccount.account + .revokeDelegatedCredential({ params: { credentialId } }) + .pipe(Effect.ignore); + } + if (machineKeyId) { + yield* memberAccount.account + .revokeApiKey({ params: { apiKeyId: machineKeyId } }) + .pipe(Effect.ignore); + } + yield* ownerClient.connections + .remove({ + params: { owner: "org", integration, name: connection }, + }) + .pipe(Effect.ignore); + yield* ownerClient.oauth + .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* ownerClient.openapi + .removeSpec({ params: { slug: integration } }) + .pipe(Effect.ignore); + if (toolkitId) { + yield* ownerClient.toolkits.remove({ params: { toolkitId } }).pipe(Effect.ignore); + } + }).pipe(Effect.ignore); + + yield* Effect.ensuring( + Effect.gen(function* () { + const emulatorCredential = yield* Effect.promise(() => + emulator.credentials.mint({ + type: "oauth-client-credentials", + name: "delegated member emulator connection", + }), + ); + const authorizationUrl = requiredString( + emulatorCredential.authorization_url, + "authorization URL", + ); + const tokenUrl = requiredString(emulatorCredential.token_url, "token URL"); + const clientId = requiredString(emulatorCredential.client_id, "client id"); + const clientSecret = requiredString(emulatorCredential.client_secret, "client secret"); + + yield* ownerClient.openapi.addSpec({ + payload: { + spec: { kind: "url", url: emulator.openapiUrl }, + slug: integration, + name: "Delegated Microsoft emulator", + baseUrl: emulator.url, + family: "microsoft", + authenticationTemplate: [ + { + slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, + kind: "oauth2", + authorizationUrl, + tokenUrl, + scopes: ["https://graph.microsoft.com/.default"], + }, + ], + }, + }); + yield* ownerClient.oauth.createClient({ + payload: { + owner: "org", + slug: oauthClient, + authorizationUrl, + tokenUrl, + grant: "client_credentials", + clientId, + clientSecret, + }, + }); + const started = yield* ownerClient.oauth.start({ + payload: { + client: oauthClient, + clientOwner: "org", + owner: "org", + name: connection, + integration, + template: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), + identityLabel: "Delegated member approved", + }, + }); + expect(started.status, "the local emulator connection is established").toBe("connected"); + + const listedConnections = yield* ownerClient.connections.list({ query: { integration } }); + const connectionLabel = + listedConnections.find((item) => String(item.name) === String(connection)) + ?.identityLabel ?? "Delegated member approved"; + expect( + listedConnections.some((item) => String(item.name) === String(connection)), + `the emulator connection is listed (${listedConnections + .map((item) => String(item.name)) + .join(",")})`, + ).toBe(true); + const availableTools = yield* ownerClient.tools.list({ + query: { integration, connection, includeBlocked: "true" }, + }); + const allowedTool = availableTools.find((tool) => + String(tool.name).includes("graphUserList"), + ); + const excludedTool = availableTools.find((tool) => + String(tool.name).includes("messageSendMail"), + ); + expect( + allowedTool !== undefined, + `the emulator exposes a read tool (${availableTools + .map((tool) => String(tool.name)) + .join(",")})`, + ).toBe(true); + expect( + excludedTool !== undefined, + `the emulator exposes an excluded write tool (${availableTools + .map((tool) => String(tool.name)) + .join(",")})`, + ).toBe(true); + if (!allowedTool || !excludedTool) return; + + const toolkit = yield* ownerClient.toolkits.create({ + payload: { owner: "org", name: toolkitName }, + }); + toolkitId = toolkit.id; + const connectionPattern = `${integration}.org.${connection}.*`; + const allowedPattern = String(allowedTool.address).replace(/^tools\./, ""); + const excludedPattern = String(excludedTool.address).replace(/^tools\./, ""); + yield* ownerClient.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: connectionPattern }, + }); + yield* ownerClient.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { pattern: allowedPattern, action: "approve" }, + }); + yield* ownerClient.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { pattern: excludedPattern, action: "block" }, + }); + const memberToolkits = yield* memberClient.toolkits.list(); + expect( + memberToolkits.toolkits.some((item) => item.id === toolkit.id), + `the member can see the approved org toolkit (${memberToolkits.toolkits + .map((item) => String(item.id)) + .join(",")})`, + ).toBe(true); + + const listedForSnapshot = yield* ownerClient.tools.list({ + query: { integration, connection, includeBlocked: "true" }, + }); + expect( + listedForSnapshot.some((tool) => String(tool.address) === String(allowedTool.address)), + "the approved tool remains in the catalog before consent", + ).toBe(true); + const memberToolsBeforeConsent = yield* memberClient.tools.list({ + query: { integration, connection, includeBlocked: "true" }, + }); + expect( + memberToolsBeforeConsent.length > 0, + `the member can see the approved org connection (${memberToolsBeforeConsent + .map((tool) => String(tool.name)) + .join(",")})`, + ).toBe(true); + + const createdMachineKey = yield* memberAccount.account.createApiKey({ + payload: { name: "delegated member machine" }, + }); + machineKeyId = createdMachineKey.id; + const machineSecret = createdMachineKey.value; + const machine = { + label: "delegated member machine", + headers: { authorization: `Bearer ${machineSecret}` }, + } satisfies Identity; + const machineClient = yield* makeApiClient(delegatedApi, machine); + const machineAccount = yield* makeApiClient(AccountHttpApi, machine); + const machineLabel = "delegated member machine"; + let challengeId = ""; + let challengeNonce = ""; + let challengeProofType = ""; + yield* browser.session( + member, + async ({ page, step, redact }) => { + redactIdentity(redact, member); + await step("Choose the named toolkit and start the challenge", async () => { + await visit(page, "/default/delegated-credentials"); + await page + .getByRole("heading", { name: "Delegated credentials", exact: true }) + .waitFor(); + await page.getByRole("button", { name: "External challenge" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Adapter").selectOption("better-auth-api-key"); + await dialog.getByLabel("Label").fill(machineLabel); + const toolkitCheckbox = dialog.getByRole("checkbox", { + name: new RegExp(toolkitName), + }); + await toolkitCheckbox.waitFor(); + await toolkitCheckbox.check(); + const connectionAccess = dialog.getByLabel("Connection access"); + await connectionAccess.selectOption("selected"); + expect( + await connectionAccess.inputValue(), + "the consent form keeps selected connection mode open", + ).toBe("selected"); + const connectionCheckbox = dialog.getByRole("checkbox", { + name: new RegExp(String(connectionLabel)), + }); + await connectionCheckbox.waitFor(); + await connectionCheckbox.check(); + await dialog.getByRole("button", { name: "Start challenge" }).click(); + const proofNonce = dialog.getByLabel("Proof payload"); + await proofNonce.waitFor(); + const proofPayload = JSON.parse(await proofNonce.inputValue()) as { + readonly nonce?: unknown; + readonly proofType?: unknown; + }; + challengeNonce = typeof proofPayload.nonce === "string" ? proofPayload.nonce : ""; + challengeProofType = + typeof proofPayload.proofType === "string" ? proofPayload.proofType : ""; + redact(challengeNonce); + expect( + challengeNonce.length > 0, + "the challenge dialog exposes its proof nonce", + ).toBe(true); + expect(challengeProofType, "the visible proof type matches API-key proof").toBe( + "api-key", + ); + const proofEndpoint = dialog.getByLabel("Proof endpoint"); + await proofEndpoint.waitFor(); + const challengeText = dialog.getByText(/^Challenge /); + await challengeText.waitFor(); + const rendered = await challengeText.textContent(); + challengeId = rendered?.replace(/^Challenge\s+/, "").trim() ?? ""; + expect(challengeId.length > 0, "the UI displays the challenge reference").toBe( + true, + ); + expect( + await proofEndpoint.inputValue(), + "the proof endpoint includes the challenge reference", + ).toContain(`/challenges/${challengeId}/proof`); + }); + + await step("Prove possession of the machine API key", async () => { + const dialog = page.getByRole("dialog"); + const proven = await Effect.runPromise( + machineAccount.account.completeDelegationProof({ + params: { challengeId }, + payload: { nonce: challengeNonce, proofType: challengeProofType }, + }), + ); + expect(proven.status, "the member machine proves API-key possession").toBe( + "proof-complete", + ); + const replay = await Effect.runPromise( + Effect.exit( + machineAccount.account.completeDelegationProof({ + params: { challengeId }, + payload: { nonce: challengeNonce, proofType: challengeProofType }, + }), + ), + ); + expect(Exit.isFailure(replay), "proof replay is rejected").toBe(true); + await dialog.getByText("Proof status: proof-complete", { exact: true }).waitFor(); + }); + + await step("Give final consent for the named toolkit", async () => { + const dialog = page.getByRole("dialog"); + const toolkitCheckbox = dialog.getByRole("checkbox", { + name: new RegExp(toolkitName), + }); + await toolkitCheckbox.waitFor(); + expect( + await toolkitCheckbox.isChecked(), + "the named toolkit remains selected for consent", + ).toBe(true); + const connectionCheckbox = dialog.getByRole("checkbox", { + name: new RegExp(String(connectionLabel)), + }); + expect( + await connectionCheckbox.isChecked(), + "the named connection remains selected for consent", + ).toBe(true); + await dialog.getByRole("button", { name: "Give final consent" }).click(); + }); + }, + { sensitive: true }, + ); + + const granted = yield* memberAccount.account.listDelegatedCredentials({ query: {} }); + const created = granted.credentials.find( + (credential) => credential.label === machineLabel, + ); + credentialId = created?.id; + expect(credentialId !== undefined, "human consent creates a durable grant").toBe(true); + + const machineToolsResult = yield* Effect.exit( + machineClient.tools + .list({ query: { integration, connection } }) + .pipe(Effect.mapError((error) => apiFailureLabel("delegated GET /api/tools", error))), + ); + expect( + Exit.isSuccess(machineToolsResult), + Exit.isFailure(machineToolsResult) + ? String(Cause.squash(machineToolsResult.cause)) + : "delegated GET /api/tools succeeds", + ).toBe(true); + if (Exit.isFailure(machineToolsResult)) return; + const machineTools = machineToolsResult.value; + expect( + machineTools.some((tool) => String(tool.address) === String(allowedTool.address)), + "delegated discovery includes the approved tool", + ).toBe(true); + expect( + machineTools.some((tool) => String(tool.address) === String(excludedTool.address)), + "delegated discovery excludes the blocked tool", + ).toBe(false); + + const allowedExpression = toolExpression(String(allowedTool.address)); + const allowedExecution = yield* machineClient.executions.execute({ + payload: { + code: `const result = await tools${allowedExpression}({}); return { ok: result.ok };`, + }, + }); + expect(allowedExecution.status, "delegated execution reaches the emulator").toBe( + "completed", + ); + if (allowedExecution.status !== "completed") return; + expect( + allowedExecution.isError, + allowedExecution.isError + ? executionFailureLabel(allowedExecution.structured) + : "the approved emulator call succeeds", + ).toBe(false); + + const blockedExpression = toolExpression(String(excludedTool.address)); + const blockedExecution = yield* machineClient.executions.execute({ + payload: { + code: `const result = await tools${blockedExpression}({}); return { ok: result.ok };`, + }, + }); + expect(blockedExecution.status, "excluded direct execution returns a result").toBe( + "completed", + ); + if (blockedExecution.status !== "completed") return; + expect( + blockedExecution.isError, + "excluded direct execution cannot bypass the grant", + ).toBe(true); + + const blockedSchema = yield* Effect.exit( + machineClient.tools.schema({ query: { address: excludedTool.address } }), + ); + expect(Exit.isFailure(blockedSchema), "excluded schema access is denied").toBe(true); + + const sessionId = yield* Effect.promise(() => + initializeMcp(target.mcpUrl, machineSecret), + ); + const listedMcp = yield* Effect.promise(() => + mcpRequest({ + url: target.mcpUrl, + bearer: machineSecret, + sessionId, + id: 2, + method: "tools/list", + }), + ); + expect(listedMcp.status, "the delegated MCP session lists tools").toBe(200); + const calledMcp = yield* Effect.promise(() => + mcpRequest({ + url: target.mcpUrl, + bearer: machineSecret, + sessionId, + id: 3, + method: "tools/call", + params: { + name: "execute", + arguments: { + code: `const result = await tools${allowedExpression}({}); return { ok: result.ok };`, + }, + }, + }), + ); + expect(calledMcp.status, "the delegated MCP invocation reaches the approved tool").toBe( + 200, + ); + + yield* browser.session(member, async ({ page, step, redact }) => { + redactIdentity(redact, member); + await step("Review and revoke the member grant in the browser", async () => { + await visit(page, "/default/delegated-credentials"); + await page + .getByRole("heading", { name: "Delegated credentials", exact: true }) + .waitFor(); + await page.getByRole("heading", { name: "Audit", exact: true }).waitFor(); + await page.getByText("grant.create", { exact: true }).waitFor(); + await page.getByText("proof.complete", { exact: true }).waitFor(); + await page.getByText(machineLabel, { exact: true }).waitFor(); + const confirmation = page.waitForEvent("dialog").then(async (nativeDialog) => { + await nativeDialog.accept(); + return nativeDialog; + }); + await page.getByRole("button", { name: "Revoke", exact: true }).click(); + const nativeDialog = await confirmation; + expect(nativeDialog.type(), "the browser revoke control asks for confirmation").toBe( + "confirm", + ); + await page.getByText("No delegated credentials for this filter.").waitFor(); + await page.getByText("grant.revoke", { exact: true }).waitFor(); + }); + }); + + const afterRevoke = yield* Effect.exit( + machineClient.tools.list({ query: { integration, connection } }), + ); + expect(Exit.isFailure(afterRevoke), "revocation blocks a new machine request").toBe(true); + + const afterMcpRevoke = yield* Effect.promise(() => + mcpRequest({ + url: target.mcpUrl, + bearer: machineSecret, + sessionId, + id: 4, + method: "tools/list", + }), + ); + // Self-host MCP authentication collapses revoked delegated bearers to + // Unauthorized so the client receives its standard 401 challenge. + expect( + afterMcpRevoke.status, + "revocation closes the existing MCP session on its next request", + ).toBe(401); + + yield* browser.session(member, async ({ page, step, redact }) => { + redactIdentity(redact, member); + await step("Review the revoked audit outcome", async () => { + await visit(page, "/default/delegated-credentials"); + await page.getByText("grant.revoke", { exact: true }).waitFor(); + await revisit(page); + await page.getByText("No delegated credentials for this filter.").waitFor(); + }); + }); + + const emulatorLedger = yield* Effect.promise(() => emulator.ledger.list()); + const ledgerText = JSON.stringify(emulatorLedger); + expect( + containsSecret(ledgerText, machineSecret), + "the emulator ledger does not retain the machine secret", + ).toBe(false); + expect( + containsSecret(ledgerText, clientSecret), + "the emulator ledger does not retain the emulator client secret", + ).toBe(false); + expect( + containsSecret(ledgerText, challengeNonce), + "the emulator ledger does not retain the proof nonce", + ).toBe(false); + }), + cleanup, + ); + }), + ), +); diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index 109c85b6ed..b429d6d0d1 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -48,6 +48,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise Promise) => Promise; + /** Mark a one-time secret for removal from the trace archive. Keep it out of the DOM. */ + readonly redact: (secret: string) => void; } export interface BrowserSurface { readonly session: ( identity: Identity, drive: (session: BrowserSession) => Promise, + options?: BrowserSessionOptions, ) => Effect.Effect; } +export interface BrowserSessionOptions { + /** + * Do not produce snapshots, screenshots, video, or a Playwright trace. + * Use only for a step that must inspect a one-time secret in memory. + */ + readonly sensitive?: boolean; +} + +/** Values carried by an identity that must never survive in recorded traces. */ +export const collectIdentitySecrets = (identity: Identity): readonly string[] => { + const values = new Set(); + const add = (value: unknown): void => { + if (typeof value === "string" && value.length > 0) values.add(value); + }; + for (const [name, value] of Object.entries(identity.headers ?? {})) { + if (!/authorization|cookie|token|api[-_]?key/i.test(name)) continue; + add(value); + if (/cookie/i.test(name)) { + for (const pair of value.split(";")) { + const equals = pair.indexOf("="); + if (equals >= 0) add(pair.slice(equals + 1).trim()); + } + } else { + const separator = value.indexOf(" "); + if (separator >= 0) add(value.slice(separator + 1).trim()); + } + } + for (const cookie of identity.cookies ?? []) add(cookie.value); + add(identity.credentials?.password); + return [...values]; +}; + const slug = (text: string): string => text .toLowerCase() @@ -161,11 +197,13 @@ export const clickToReveal = async ( // acquireUseRelease so a vitest timeout (fiber interruption) still closes the // browser and flushes video + trace — a bare promise would leak Chromium. export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({ - session: (identity, drive) => + session: (identity, drive, options) => Effect.acquireUseRelease( Effect.promise(async () => { + const sensitive = options?.sensitive === true; const videoTmp = join(dir, ".video-tmp"); - mkdirSync(videoTmp, { recursive: true }); + if (!sensitive) mkdirSync(videoTmp, { recursive: true }); + const redactions = new Set(collectIdentitySecrets(identity)); // Watchable mode (E2E_FILM / E2E_DESK): slow each Playwright action so // the recording is readable instead of flickering through states at @@ -189,17 +227,21 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const context = await browser.newContext({ colorScheme: "dark", viewport: { width: 1280, height: 800 }, - recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + ...(sensitive + ? {} + : { recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } } }), baseURL: target.baseUrl, }); - await context.tracing.start({ - screenshots: true, - snapshots: true, - sources: true, - }); + if (!sensitive) { + await context.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }); + } // Bake a synthetic URL bar into the recording so a shared session.mp4 // (and the step screenshots) shows which page each moment was on. - await installRecordingUrlBar(context); + if (!sensitive) await installRecordingUrlBar(context); if (identity.cookies?.length) { await context.addCookies( identity.cookies.map((cookie) => ({ @@ -211,13 +253,15 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const page = await context.newPage(); // The session video's clock starts with the page; anchor it for the // run's focus timeline (scripts/film.ts cuts on these). - markRecordingStart(dir, "browser"); + if (!sensitive) markRecordingStart(dir, "browser"); // Main-frame navigations feed the viewer's synthetic URL bar — the // recording itself is chromeless, so this is the only place the // address the developer "typed" survives. - page.on("framenavigated", (frame) => { - if (frame === page.mainFrame()) markNavigation(dir, frame.url()); - }); + if (!sensitive) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) markNavigation(dir, frame.url()); + }); + } // Harvest distributed-trace ids: every app API request carries a W3C // traceparent (Effect's HttpClient), and each id names one // click→server→DB trace in whatever OTLP store the run exported to @@ -228,33 +272,35 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface // without leaving the run page. const traceIds: Array = []; const inflight = new Map(); - page.on("request", (request) => { - const traceparent = request.headers()["traceparent"]; - const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null; - if (match?.[1]) { - const entry: (typeof traceIds)[number] = { - id: match[1], - at: Date.now(), - url: request.url(), - source: "browser", - }; - traceIds.push(entry); - inflight.set(request, entry); - } - }); - page.on("requestfinished", async (request) => { - const entry = inflight.get(request); - if (!entry) return; - inflight.delete(request); - entry.ms = Date.now() - entry.at; - entry.status = (await request.response().catch(() => null))?.status(); - }); - page.on("requestfailed", (request) => { - const entry = inflight.get(request); - if (!entry) return; - inflight.delete(request); - entry.ms = Date.now() - entry.at; - }); + if (!sensitive) { + page.on("request", (request) => { + const traceparent = request.headers()["traceparent"]; + const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null; + if (match?.[1]) { + const entry: (typeof traceIds)[number] = { + id: match[1], + at: Date.now(), + url: request.url(), + source: "browser", + }; + traceIds.push(entry); + inflight.set(request, entry); + } + }); + page.on("requestfinished", async (request) => { + const entry = inflight.get(request); + if (!entry) return; + inflight.delete(request); + entry.ms = Date.now() - entry.at; + entry.status = (await request.response().catch(() => null))?.status(); + }); + page.on("requestfailed", (request) => { + const entry = inflight.get(request); + if (!entry) return; + inflight.delete(request); + entry.ms = Date.now() - entry.at; + }); + } return { browser, context, @@ -262,43 +308,76 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface videoTmp, shots: { count: 0 }, traceIds, + redactions, + sensitive, }; }), - ({ page, context, shots }) => + ({ page, context, shots, redactions, sensitive }) => Effect.promise(async () => { const step = async (label: string, action: (page: Page) => Promise) => { // Acting on the page IS focusing the browser window — and when // filming, enterFocus lingers a beat on whatever the developer was // looking at before tabbing here. - await enterFocus(dir, "browser"); - await context.tracing.group(label); + if (!sensitive) await enterFocus(dir, "browser"); + if (!sensitive) await context.tracing.group(label); try { await action(page); } finally { - await context.tracing.groupEnd(); + if (!sensitive) await context.tracing.groupEnd(); + } + if (!sensitive) { + await page.screenshot({ + path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`), + }); } - await page.screenshot({ - path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`), - }); // Hold this step's result on screen so the film is readable (the // consent screen, the success page, …). No-op unless filming. await beat(); }; try { - await drive({ page, step }); + await drive({ + page, + step, + redact: (secret) => { + if (secret.length > 0) redactions.add(secret); + }, + }); } catch (error) { // Freeze the scene: the artifact dir shows the screen at failure. - await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); + if (!sensitive) { + await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); + } throw error; } }), - ({ browser, context, page, videoTmp, traceIds }) => + ({ browser, context, page, videoTmp, traceIds, redactions, sensitive }) => Effect.promise(async () => { - appendTraces(dir, traceIds); - await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); - const video = page.video(); - await context.close(); // flushes the recording - await browser.close(); + let cleanupFailure: unknown; + const tracePath = join(dir, "trace.zip"); + const video = sensitive ? undefined : page.video(); + try { + appendTraces(dir, traceIds); + if (!sensitive) { + await context.tracing.stop({ path: tracePath }); + await redactTraceArchive(tracePath, [...redactions]); + } + } catch (error) { + cleanupFailure = error; + // If redaction or trace finalization failed, the archive may still + // contain a bearer value. Remove it before closing the session. + rmSync(tracePath, { force: true }); + } finally { + try { + await context.close(); // flushes the recording + } catch (error) { + cleanupFailure ??= error; + } + try { + await browser.close(); + } catch (error) { + cleanupFailure ??= error; + } + } const recordedPath = await video?.path().catch(() => undefined); if (recordedPath) { try { @@ -324,6 +403,7 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface } } rmSync(videoTmp, { recursive: true, force: true }); + if (cleanupFailure) throw cleanupFailure; }), ), }); diff --git a/e2e/src/trace-redaction.ts b/e2e/src/trace-redaction.ts new file mode 100644 index 0000000000..906971685c --- /dev/null +++ b/e2e/src/trace-redaction.ts @@ -0,0 +1,149 @@ +import { execFile } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const REDACTED = new TextEncoder().encode("[REDACTED]"); + +export class TraceRedactionError extends Error { + constructor(message: string) { + super(message); + this.name = "TraceRedactionError"; + } +} + +const filesUnder = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? filesUnder(path) : [path]; + }); + +const indexOfBytes = ( + source: Uint8Array, + needle: Uint8Array, + from: number, +): number => { + if (needle.length === 0) return from; + outer: for (let offset = from; offset <= source.length - needle.length; offset += 1) { + for (let index = 0; index < needle.length; index += 1) { + if (source[offset + index] !== needle[index]) continue outer; + } + return offset; + } + return -1; +}; + +const replaceAll = ( + source: Uint8Array, + needle: Uint8Array, +): Uint8Array => { + if (needle.length === 0) return source; + const chunks: Uint8Array[] = []; + let start = 0; + while (start < source.length) { + const found = indexOfBytes(source, needle, start); + if (found < 0) { + chunks.push(source.subarray(start)); + break; + } + chunks.push(source.subarray(start, found), REDACTED); + start = found + needle.length; + } + const length = chunks.reduce((total, chunk) => total + chunk.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +}; + +const variants = (value: string): Uint8Array[] => { + const base64 = Buffer.from(value).toString("base64"); + const base64url = Buffer.from(value).toString("base64url"); + return [...new Set([value, base64, base64url, encodeURIComponent(value)])].map((variant) => + new TextEncoder().encode(variant), + ); +}; + +const startsWithBytes = ( + contents: Uint8Array, + prefix: readonly number[], + offset = 0, +): boolean => prefix.every((byte, index) => contents[offset + index] === byte); + +const isBinaryAsset = (path: string, contents: Uint8Array): boolean => { + if (/\.(?:gif|jpe?g|mp4|mov|png|webm|webp|woff2?|zip)$/i.test(path)) return true; + if (startsWithBytes(contents, [0x89, 0x50, 0x4e, 0x47])) return true; // PNG + if (startsWithBytes(contents, [0xff, 0xd8, 0xff])) return true; // JPEG + if (startsWithBytes(contents, [0x47, 0x49, 0x46, 0x38])) return true; // GIF + if ( + startsWithBytes(contents, [0x52, 0x49, 0x46, 0x46]) && + startsWithBytes(contents, [0x57, 0x45, 0x42, 0x50], 8) + ) { + return true; // WebP + } + if (startsWithBytes(contents, [0x1a, 0x45, 0xdf, 0xa3])) return true; // WebM/Matroska + if (startsWithBytes(contents, [0x50, 0x4b, 0x03, 0x04])) return true; // ZIP + return startsWithBytes(contents, [0x66, 0x74, 0x79, 0x70], 4); // MP4 +}; + +const isText = (contents: Uint8Array): boolean => { + if (contents.some((byte) => byte === 0)) return false; + try { + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(contents); + const roundTrip = new TextEncoder().encode(decoded); + if (roundTrip.length !== contents.length) return false; + return roundTrip.every((byte, index) => byte === contents[index]); + } catch { + return false; + } +}; + +/** Rewrite text entries in a Playwright trace archive, removing registered values. */ +export const redactTraceArchive = async ( + archive: string, + secrets: readonly string[], +): Promise => { + const values = [...new Set(secrets.filter((value) => value.length > 0))]; + if (values.length === 0) return; + + const directory = mkdtempSync(join(tmpdir(), "executor-e2e-trace-")); + try { + if (!existsSync(archive)) throw new TraceRedactionError("trace archive was not created"); + await execFileAsync("unzip", ["-qq", archive, "-d", directory]); + for (const path of filesUnder(directory)) { + if (!statSync(path).isFile()) continue; + let contents: Uint8Array = readFileSync(path); + if (isBinaryAsset(path, contents) || !isText(contents)) continue; + for (const value of values) { + for (const needle of variants(value)) contents = replaceAll(contents, needle); + } + writeFileSync(path, contents); + } + unlinkSync(archive); + await execFileAsync("zip", ["-q", "-r", archive, "."], { cwd: directory }); + } catch (error) { + // A partially rewritten archive can still contain the bearer value. Never + // leave it behind when a tool or archive operation fails. + rmSync(archive, { force: true }); + if (error instanceof TraceRedactionError) throw error; + throw new TraceRedactionError( + `failed to redact trace archive (${error instanceof Error ? error.name : "unknown error"})`, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +};