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/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/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/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/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/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..271560a680 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. @@ -466,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 1224b72bba..d52f436bbf 100644 --- a/packages/core/sdk/src/pending-approval.test.ts +++ b/packages/core/sdk/src/pending-approval.test.ts @@ -1,12 +1,27 @@ 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 = { + 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 +43,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", () => @@ -54,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 72c76fa7ef..3eb210aaa5 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,17 @@ 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; + /** 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: ( + executionId: string, + matches?: (approval: PendingApproval) => boolean, + ) => Effect.Effect; /** Drop a record without running it (decline/cancel). */ readonly discard: (executionId: string) => Effect.Effect; } @@ -87,17 +117,22 @@ 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)), - 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 +140,28 @@ 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; + const claimed = yield* blobs.claim(claimNamespace, executionId, claimMarker); + if (!claimed) return null; + yield* blobs.delete(namespace, executionId); + return approval; + }), + discard: (executionId) => blobs.delete(namespace, executionId), }; }; 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/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 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, }; };