From 85a1a3a516792899e4699feecffb5d77bec4118a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 25 Aug 2026 12:48:40 -0600 Subject: [PATCH] feat(backend): add clerk.policy.check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register `policy: new PolicyAPI(request)` on the backend client and ship its first method. `check` posts an operation to `POST /v1/agent_actions` and returns the resulting `AgentAction`, never blocking and never throwing on an API outcome. `PolicyAPI` returns the native `{ data, errors }` union rather than throwing, which it obtains by unwrapping the legacy request wrapper locally. The conversion is total: `requestFn` never throws, the wrapper throws exactly `ClerkAPIResponseError`, and every field of the errors arm survives that construction. Anything else reaching the `catch` is rethrown. `CheckParams` is split into per-`tokenType` arms so the contract's obligations are compile-time facts. `actorId` is required everywhere and derived nowhere: an inbound token's client id is ambiguous between the exchanging party and the authorized client, and only the resource server knows which topology it is in. The `oauth_token` arm derives `subjectId` and `authorizedClientId`, which its token verifiably names, and accepts neither. There is no session arm. `deepSnakecaseBodyParamKeys` stays unset so `parameters` keys reach the API byte-for-byte — the spelling `check()` uses is the spelling the field registry and every policy leaf must use. --- .changeset/shaggy-melons-repeat.md | 20 + .../src/api/__tests__/PolicyApi.test.ts | 486 ++++++++++++++++++ .../backend/src/api/endpoints/PolicyApi.ts | 326 ++++++++++++ packages/backend/src/api/endpoints/index.ts | 1 + packages/backend/src/api/factory.ts | 5 + packages/backend/src/index.ts | 11 + 6 files changed, 849 insertions(+) create mode 100644 .changeset/shaggy-melons-repeat.md create mode 100644 packages/backend/src/api/__tests__/PolicyApi.test.ts create mode 100644 packages/backend/src/api/endpoints/PolicyApi.ts diff --git a/.changeset/shaggy-melons-repeat.md b/.changeset/shaggy-melons-repeat.md new file mode 100644 index 00000000000..913c65bc587 --- /dev/null +++ b/.changeset/shaggy-melons-repeat.md @@ -0,0 +1,20 @@ +--- +'@clerk/backend': minor +--- + +Add the experimental `clerk.policy.check()` method, which checks an agent's proposed operation against your instance's policy and returns the `AgentAction` recording what was decided. It never blocks and never throws on an API outcome: transport and validation failures arrive on an `errors` arm, and a policy outcome that needs a human arrives as `status: 'pending'` with an `approval.url` to send them to. + +```ts +const { data, errors } = await clerkClient.policy.check({ + auth, // an authenticated `oauth_token` auth object + actorId: 'https://acme.example.com/mcp', + operation: 'api/v1/refund', + parameters: { charge_id: 'ch_9x', refund_amount: 25000, currency: 'usd' }, +}) +``` + +Branch on `data.status`, never on `data.decision.effect` — an action that could not be routed to a reviewer is created `denied` while its decision still reads `require_approval`. + +`actorId` is required on every call and is never derived from a credential: an inbound token's client id is ambiguous between the party that exchanged it and the party the human authorized, and only your application knows which topology it is in. Passing an `oauth_token` auth object does derive `subjectId` and `authorizedClientId`, which that token verifiably names; `api_key` and `m2m_token` callers pass `subjectId` themselves. There is no session arm — a session caller passes the party fields explicitly and omits `auth`. + +Parameter keys are sent byte-for-byte. The spelling used in `check()` is the spelling your field-registry declaration and every policy leaf must use, and there is no server-side guard for a mismatch: `refundAmount` and `refund_amount` are different fields, and a leaf addressing the wrong one never matches and never errors. diff --git a/packages/backend/src/api/__tests__/PolicyApi.test.ts b/packages/backend/src/api/__tests__/PolicyApi.test.ts new file mode 100644 index 00000000000..ed4670a993d --- /dev/null +++ b/packages/backend/src/api/__tests__/PolicyApi.test.ts @@ -0,0 +1,486 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import type { + AuthenticatedMachineObject, + InvalidTokenAuthObject, + SignedInAuthObject, + SignedOutAuthObject, + UnauthenticatedMachineObject, +} from '../../tokens/authObjects'; +import type { CheckParams, ExplicitCheckParams, OpaqueMachineCheckParams } from '../endpoints/PolicyApi'; +import { createBackendApiClient } from '../factory'; + +describe('PolicyAPI', () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'deadbeef', + }); + + const mockDecision = { + object: 'agent_action_decision', + id: 'agtdec_2h9K', + action_id: 'agtact_2h9K', + policy_id: 'pol_2h9K', + policy_revision: 3, + rule_id: 'rule_large_refunds', + effect: 'require_approval', + reason: null, + evaluation: 'ok', + evaluation_errors: null, + created_at: 1735689600000, + }; + + const mockAgentAction = { + object: 'agent_action', + id: 'agtact_2h9K', + status: 'pending', + actor_id: 'https://acme.example.com/mcp', + subject_id: 'user_2h9K', + organization_id: null, + authorized_client_id: 'client_codex', + operation: 'api/v1/refund', + parameters: { chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }, + description: 'Refunding an order the customer disputed.', + parameters_display: [{ key: 'refundAmount', label: 'Refund amount', value: '$250.00' }], + idempotency_key: 'refund-ch_9x', + approval: { + role: 'org:admin', + url: 'https://accounts.acme.com/action-approval/agtact_2h9K', + expires_at: 1735693200000, + }, + resolution: null, + decision: mockDecision, + created_at: 1735689600000, + updated_at: 1735689600000, + }; + + const apiKeyAuth: AuthenticatedMachineObject<'api_key'> = { + id: 'ak_2h9K', + subject: 'user_key_owner', + scopes: [], + getToken: () => Promise.resolve('ak_2h9K'), + has: () => false, + debug: () => ({}), + tokenType: 'api_key', + isAuthenticated: true, + name: 'Acme backend key', + claims: null, + userId: 'user_key_owner', + orgId: null, + }; + + const m2mAuth: AuthenticatedMachineObject<'m2m_token'> = { + id: 'mt_2h9K', + subject: 'mch_2h9K', + scopes: [], + getToken: () => Promise.resolve('mt_2h9K'), + has: () => false, + debug: () => ({}), + tokenType: 'm2m_token', + isAuthenticated: true, + claims: null, + machineId: 'mch_2h9K', + }; + + const oauthAuth: AuthenticatedMachineObject<'oauth_token'> = { + id: 'oat_2h9K', + // Deliberately different from `userId` below. The derivation table names `auth.userId` as + // the source of `subject_id`; identical values here would let `auth.subject` pass too. + subject: 'user_from_the_subject_field', + scopes: ['profile'], + getToken: () => Promise.resolve('oat_2h9K'), + has: () => false, + debug: () => ({}), + tokenType: 'oauth_token', + isAuthenticated: true, + userId: 'user_2h9K', + clientId: 'client_codex', + }; + + const unauthenticatedMachineAuth: UnauthenticatedMachineObject<'api_key'> = { + id: null, + subject: null, + scopes: null, + getToken: () => Promise.resolve(null), + has: () => false, + debug: () => ({}), + tokenType: 'api_key', + isAuthenticated: false, + name: null, + claims: null, + userId: null, + orgId: null, + }; + + // These three are annotated rather than left as inferred literals on purpose. An unannotated + // literal widens `tokenType` to `string`, which is assignable to no arm of `CheckParams` under + // any shape — so the `@ts-expect-error` assertions below would keep passing even if a session + // arm were reintroduced. The annotation is what makes them discriminating. + const invalidTokenAuth: InvalidTokenAuthObject = { + isAuthenticated: false, + tokenType: null, + getToken: () => Promise.resolve(null), + has: () => false, + debug: () => ({}), + }; + + const signedOutAuth: SignedOutAuthObject = { + ...invalidTokenAuth, + tokenType: 'session_token', + sessionClaims: null, + sessionId: null, + sessionStatus: null, + actor: null, + userId: null, + orgId: null, + orgRole: null, + orgSlug: null, + orgPermissions: null, + factorVerificationAge: null, + }; + + const signedInAuth = { + ...signedOutAuth, + isAuthenticated: true, + sessionId: 'sess_2h9K', + userId: 'user_2h9K', + } as unknown as SignedInAuthObject; + + /** Captures the body BAPI received, so a test can assert on the wire spelling. */ + const respondWith = (response: unknown, status = 200) => { + let body: any; + server.use( + http.post( + 'https://api.clerk.test/v1/agent_actions', + validateHeaders(async ({ request }) => { + body = await request.json(); + return HttpResponse.json(response as any, { status }); + }), + ), + ); + return () => body; + }; + + describe('check', () => { + it('sends a snake_cased body and returns a camelCased resource', async () => { + const body = respondWith(mockAgentAction); + + const { data, errors } = await apiClient.policy.check({ + actorId: 'https://acme.example.com/mcp', + subjectId: 'user_2h9K', + organizationId: 'org_2h9K', + authorizedClientId: 'client_codex', + operation: 'api/v1/refund', + parameters: { chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }, + description: 'Refunding an order the customer disputed.', + idempotencyKey: 'refund-ch_9x', + }); + + expect(body()).toEqual({ + actor_id: 'https://acme.example.com/mcp', + subject_id: 'user_2h9K', + organization_id: 'org_2h9K', + authorized_client_id: 'client_codex', + operation: 'api/v1/refund', + parameters: { chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }, + description: 'Refunding an order the customer disputed.', + idempotency_key: 'refund-ch_9x', + }); + + expect(errors).toBeNull(); + expect(data?.id).toBe('agtact_2h9K'); + expect(data?.status).toBe('pending'); + expect(data?.actorId).toBe('https://acme.example.com/mcp'); + expect(data?.subjectId).toBe('user_2h9K'); + expect(data?.authorizedClientId).toBe('client_codex'); + expect(data?.idempotencyKey).toBe('refund-ch_9x'); + expect(data?.approval?.expiresAt).toBe(1735693200000); + expect(data?.resolution).toBeNull(); + expect(data?.decision.policyRevision).toBe(3); + expect(data?.decision.ruleId).toBe('rule_large_refunds'); + expect(data?.createdAt).toBe(1735689600000); + }); + + it('passes parameter keys through verbatim in both directions', async () => { + const body = respondWith(mockAgentAction); + + const { data } = await apiClient.policy.check({ + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'api/v1/refund', + parameters: { chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }, + }); + + // The spelling used in check() is the spelling the field registry and every policy + // leaf must use, so a camelCase key must survive the request untouched. + expect(body().parameters).toEqual({ chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }); + expect(data?.parameters).toEqual({ chargeId: 'ch_9x', refundAmount: 25000, currency: 'usd' }); + expect(data?.parametersDisplay[0].key).toBe('refundAmount'); + }); + + it('sends a null subject_id when the explicit arm declares no bound human', async () => { + const body = respondWith({ ...mockAgentAction, subject_id: null }); + + const { data } = await apiClient.policy.check({ + actorId: 'oa_2h9K', + subjectId: null, + operation: 'api/v1/refund', + }); + + expect(body()).toEqual({ + actor_id: 'oa_2h9K', + subject_id: null, + operation: 'api/v1/refund', + }); + expect(data?.subjectId).toBeNull(); + }); + + it('derives nothing from an api_key auth object', async () => { + const body = respondWith(mockAgentAction); + + await apiClient.policy.check({ + auth: apiKeyAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'api/v1/refund', + }); + + // An api_key's subject is the key's owner, not the human the agent acts for, so the + // caller's subjectId wins and the auth object contributes nothing. + expect(body()).toEqual({ + actor_id: 'oa_2h9K', + subject_id: 'user_2h9K', + operation: 'api/v1/refund', + }); + }); + + it('derives nothing from an m2m_token auth object', async () => { + const body = respondWith(mockAgentAction); + + await apiClient.policy.check({ + auth: m2mAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + authorizedClientId: 'client_codex', + operation: 'api/v1/refund', + }); + + // machineId is a machine, not an agent, so it never becomes actor_id. + expect(body()).toEqual({ + actor_id: 'oa_2h9K', + subject_id: 'user_2h9K', + authorized_client_id: 'client_codex', + operation: 'api/v1/refund', + }); + }); + + it('derives subject_id and authorized_client_id from an oauth_token auth object', async () => { + const body = respondWith(mockAgentAction); + + await apiClient.policy.check({ + auth: oauthAuth, + actorId: 'https://acme.example.com/mcp', + operation: 'api/v1/refund', + }); + + // actor_id and authorized_client_id differ under the MCP topology: the token names the + // client the human consented to, never the agent that exchanged it. + expect(body()).toEqual({ + actor_id: 'https://acme.example.com/mcp', + subject_id: 'user_2h9K', + authorized_client_id: 'client_codex', + operation: 'api/v1/refund', + }); + }); + + it('returns the fail-closed downgrade with a denied status and a require_approval effect', async () => { + respondWith({ + ...mockAgentAction, + status: 'denied', + approval: null, + decision: { + ...mockDecision, + effect: 'require_approval', + reason: 'No reviewer could be resolved for the subject.', + }, + }); + + const { data } = await apiClient.policy.check({ + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'api/v1/refund', + }); + + expect(data?.status).toBe('denied'); + expect(data?.decision.effect).toBe('require_approval'); + expect(data?.decision.reason).toBe('No reviewer could be resolved for the subject.'); + expect(data?.approval).toBeNull(); + }); + + it('returns an idempotency mismatch rather than throwing it', async () => { + respondWith( + { + errors: [ + { + code: 'agent_action_idempotency_mismatch', + message: 'Idempotency key reused with different content', + long_message: 'An agent action already exists for this idempotency key with different parameters.', + }, + ], + }, + 409, + ); + + const response = await apiClient.policy.check({ + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'api/v1/refund', + idempotencyKey: 'refund-ch_9x', + }); + + expect(response.data).toBeNull(); + // `status` lives on the errors arm alone, so branching on `errors` is what reaches it. + if (!response.errors) { + throw new Error('Expected the errors arm'); + } + expect(response.status).toBe(409); + expect(response.errors[0].code).toBe('agent_action_idempotency_mismatch'); + expect(response.errors[0].message).toBe('Idempotency key reused with different content'); + }); + + it('throws on a signed-out auth object', async () => { + const params: CheckParams = { + // @ts-expect-error A signed-out auth object does not inhabit `CheckParams`. + auth: signedOutAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'op', + }; + + await expect(apiClient.policy.check(params)).rejects.toThrow( + 'clerk.policy.check() requires an authenticated machine auth object', + ); + }); + + it('throws on an unauthenticated machine auth object', async () => { + const params: CheckParams = { + // @ts-expect-error An unauthenticated machine object does not inhabit `CheckParams`. + auth: unauthenticatedMachineAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'op', + }; + + await expect(apiClient.policy.check(params)).rejects.toThrow( + 'clerk.policy.check() requires an authenticated machine auth object', + ); + }); + + it('throws on an invalid-token auth object', async () => { + const params: CheckParams = { + // @ts-expect-error An invalid-token auth object does not inhabit `CheckParams`. + auth: invalidTokenAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'op', + }; + + await expect(apiClient.policy.check(params)).rejects.toThrow( + 'clerk.policy.check() requires an authenticated machine auth object', + ); + }); + + it('throws when auth is present but resolved to null', async () => { + const params: CheckParams = { + // @ts-expect-error `null` inhabits no arm; a caller who passed `auth` meant to derive from it. + auth: null, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'op', + }; + + // Not a silent fall through to the explicit arm, where a dropped subject becomes a + // fail-closed `denied` that never reaches a reviewer. + await expect(apiClient.policy.check(params)).rejects.toThrow( + 'clerk.policy.check() requires an authenticated machine auth object', + ); + }); + + it('accepts no signed-in auth object, and throws if one reaches it untyped', async () => { + const params: CheckParams = { + // @ts-expect-error No arm accepts a `SignedInAuthObject`: no session claim names an agent. + auth: signedInAuth, + actorId: 'oa_2h9K', + subjectId: 'user_2h9K', + operation: 'op', + }; + + await expect(apiClient.policy.check(params)).rejects.toThrow( + 'clerk.policy.check() requires an authenticated machine auth object', + ); + }); + }); + + describe('CheckParams', () => { + it('requires actorId on the explicit arm', () => { + // @ts-expect-error `actorId` is required on every arm. + const params: ExplicitCheckParams = { subjectId: 'user_2h9K', operation: 'api/v1/refund' }; + + expect(params).toBeDefined(); + }); + + it('requires actorId on the opaque machine arm', () => { + // @ts-expect-error `actorId` is required on every arm. + const params: OpaqueMachineCheckParams = { auth: apiKeyAuth, subjectId: 'user_2h9K', operation: 'op' }; + + expect(params).toBeDefined(); + }); + + it('requires actorId on the oauth_token arm', () => { + // @ts-expect-error `actorId` is required on every arm. + const params: CheckParams = { auth: oauthAuth, operation: 'api/v1/refund' }; + + expect(params).toBeDefined(); + }); + + it('requires subjectId on the explicit arm', () => { + // @ts-expect-error `subjectId` is required, so a session caller cannot omit it and land on a fail-closed denial. + const params: ExplicitCheckParams = { actorId: 'oa_2h9K', operation: 'api/v1/refund' }; + + expect(params).toBeDefined(); + }); + + it('requires subjectId on the opaque machine arm', () => { + // @ts-expect-error No opaque machine credential names a human, so `subjectId` is required. + const params: OpaqueMachineCheckParams = { auth: apiKeyAuth, actorId: 'oa_2h9K', operation: 'op' }; + + expect(params).toBeDefined(); + }); + + it('does not accept subjectId on the oauth_token arm', () => { + // @ts-expect-error Derived from the token's `userId`, so it is not overridable. + const params: CheckParams = { + auth: oauthAuth, + actorId: 'oa_2h9K', + subjectId: 'user_someone_else', + operation: 'op', + }; + + expect(params).toBeDefined(); + }); + + it('does not accept authorizedClientId on the oauth_token arm', () => { + // @ts-expect-error Derived from the token's `clientId`, so it is not overridable. + const params: CheckParams = { + auth: oauthAuth, + actorId: 'oa_2h9K', + authorizedClientId: 'client_attacker', + operation: 'op', + }; + + expect(params).toBeDefined(); + }); + }); +}); diff --git a/packages/backend/src/api/endpoints/PolicyApi.ts b/packages/backend/src/api/endpoints/PolicyApi.ts new file mode 100644 index 00000000000..1177b202a85 --- /dev/null +++ b/packages/backend/src/api/endpoints/PolicyApi.ts @@ -0,0 +1,326 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIError } from '@clerk/shared/types'; + +import type { AuthenticatedMachineObject, AuthObject } from '../../tokens/authObjects'; +import { isMachineTokenType } from '../../tokens/machine'; +import type { ClerkBackendApiRequestOptions } from '../request'; +import type { AgentAction } from '../resources/AgentAction'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/agent_actions'; + +/** + * The response shape returned by every method on `clerk.policy`. Unlike every other + * endpoint class in this package, `PolicyAPI` returns errors instead of throwing them. + * + * Transport failure and policy outcome are separate axes: branch on `errors` first, then + * switch on `data.status`. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export type PolicyApiResponse = + | { + data: T; + errors: null; + } + | { + data: null; + errors: ClerkAPIError[]; + /** The HTTP status of the failed response. This is how a caller distinguishes a 409 `agent_action_idempotency_mismatch` from a 422 validation failure. */ + status?: number; + /** The HTTP status text of the failed response. */ + statusText?: string; + /** The Clerk trace ID of the failed response, for support requests. */ + clerkTraceId?: string; + /** The value of the response's `Retry-After` header, in seconds, when one was sent. */ + retryAfter?: number; + }; + +/** + * The fields every `check()` call carries, regardless of how the parties are established. + * Module-private, like {@link ActorIdParam}: the three arms it composes into are the public + * surface, so it carries no `@experimental` or typedoc annotations of its own. + */ +type CheckPayload = { + /** The operation being attempted, matching the `operation` a policy rule and a field-registry row address. Required, max 255 characters. */ + operation: string; + /** + * The operation's arguments. Keys are sent to the API byte-for-byte — the spelling used + * here is the spelling the field registry and every policy leaf must use. + * + * @default {} + */ + parameters?: Record; + /** + * Application-authored context shown to the reviewer. Never mirror user or agent input + * into this field. + * + * @default undefined + */ + description?: string; + /** + * Content-bound deduplication key, scoped to `(instance, actorId, idempotencyKey)`. Reusing + * a key with different content returns an `agent_action_idempotency_mismatch` error rather + * than a replay. + * + * @default undefined + */ + idempotencyKey?: string; +}; + +/** Shared by all three arms, so that omitting `actorId` is a compile error however the parties are established. */ +type ActorIdParam = { + /** + * The identifier of the agent that attempted the operation: an OAuth application id + * (`oa_…`), or a CIMD `client_id` URL. The shape is documented and never validated, so a + * later identifier form does not need an SDK release. + * + * Always supplied by you, on every arm. No Clerk credential unambiguously names the agent: + * an inbound token's client id is ambiguous between the party that exchanged it and the + * party the human authorized, and only your application knows which topology it is in. + */ + actorId: string; +}; + +/** + * `api_key` and `m2m_token` callers. Neither credential names an agent, so `actorId` is + * required; neither names a human either, so `subjectId` is required as well — an action + * created without a subject downgrades fail-closed to `denied` before it is ever reviewed. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * @generateWithEmptyComment + */ +export type OpaqueMachineCheckParams = CheckPayload & + ActorIdParam & { + /** The verified machine auth object for the request. An `api_key`'s subject is the key's owner and an `m2m_token`'s is a machine, so neither party field is derived from it. */ + auth: AuthenticatedMachineObject<'api_key' | 'm2m_token'>; + /** The ID of the user the agent is acting on behalf of. Required: no opaque machine credential names one. */ + subjectId: string; + /** + * The ID of the organization the operation is scoped to. + * + * @default undefined + */ + organizationId?: string; + /** + * The ID of the OAuth application the agent was authorized through. A display and audit + * snapshot only; no policy rule addresses it. + * + * @default undefined + */ + authorizedClientId?: string; + }; + +/** + * `oauth_token` callers. The token verifiably names the human it was issued for and the + * client that human consented to, so `subjectId` and `authorizedClientId` are derived from + * it and are **not** accepted here. It names no agent, so `actorId` is still required. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * @generateWithEmptyComment + */ +export type OAuthMachineCheckParams = CheckPayload & + ActorIdParam & { + /** The verified OAuth machine auth object for the request. `subjectId` is derived from its `userId` and `authorizedClientId` from its `clientId`. */ + auth: AuthenticatedMachineObject<'oauth_token'>; + /** + * The ID of the organization the operation is scoped to. + * + * @default undefined + */ + organizationId?: string; + subjectId?: never; + authorizedClientId?: never; + }; + +/** + * Callers outside the auth flow, and every session caller — `check` accepts no signed-in + * auth object, because no session claim names an agent. A session caller passes + * `subjectId: auth.userId` and `organizationId: auth.orgId` alongside the `actorId` its own + * application knows. + * + * The party fields here are unverifiable assertions made by your backend; see the trust + * model in the API contract. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * @generateWithEmptyComment + */ +export type ExplicitCheckParams = CheckPayload & + ActorIdParam & { + auth?: never; + /** + * The ID of the user the agent is acting on behalf of. Required, and nullable: `null` + * declares that this actor has no bound human, which is the mode `subject.id` + + * `exists: false` policy leaves address. It is required rather than optional so a + * session caller cannot omit it by accident and land on a fail-closed `denied`. + */ + subjectId: string | null; + /** + * The ID of the organization the operation is scoped to. + * + * @default undefined + */ + organizationId?: string; + /** + * The ID of the OAuth application the agent was authorized through. A display and audit + * snapshot only; no policy rule addresses it. + * + * @default undefined + */ + authorizedClientId?: string; + }; + +/** + * The parameters accepted by `clerk.policy.check()`, split by how the parties to the check + * are established. The arms are keyed on the auth object's `tokenType` so that the contract's + * obligations are compile-time facts: omitting `actorId` fails to compile on every arm, as + * does omitting `subjectId` anywhere it is not derived. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * @generateWithEmptyComment + */ +export type CheckParams = OpaqueMachineCheckParams | OAuthMachineCheckParams | ExplicitCheckParams; + +/** + * The auth object arms `check` accepts are the authenticated machine ones. A signed-out, + * unauthenticated, or invalid-token object fails the premise of the call, and so does a + * session object — no session claim names an agent, so there is nothing to derive from one. + * + * This is a type error as well, since none of those inhabit {@link CheckParams}. The runtime + * check is for JavaScript callers and for values that crossed a boundary untyped. + */ +function assertAuthenticatedMachineObject(auth: AuthObject | null): asserts auth is AuthenticatedMachineObject { + if (!auth || !auth.isAuthenticated || !auth.tokenType || !isMachineTokenType(auth.tokenType)) { + throw new Error( + 'clerk.policy.check() requires an authenticated machine auth object (`api_key`, `m2m_token`, or `oauth_token`). Session, signed-out, and invalid-token auth objects name no agent — pass the party fields explicitly instead, omitting `auth`.', + ); + } +} + +/** + * Checks an agent's proposed operation against the instance's policy, and reads back the + * Agent Actions that result. + * + * Every method returns a {@link PolicyApiResponse} rather than throwing on an API failure. + * + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * @generateWithEmptyComment + */ +export class PolicyAPI extends AbstractAPI { + /** + * Derives the party fields the caller's credential establishes and passes through the ones + * it does not. Only the `oauth_token` arm derives anything: its token verifiably names the + * human it was issued for and the client that human consented to. + * + * `actorId` is never derived on any arm. + */ + #toCheckBodyParams(params: CheckParams): Record { + const { auth, operation, parameters, description, idempotencyKey, actorId, organizationId } = params; + + const payload = { operation, parameters, description, idempotencyKey, actorId, organizationId }; + + // Strictly `undefined`, not falsy: a caller who passed an `auth` that resolved to `null` + // meant to derive from it, so that is an insufficient-auth throw rather than a silent fall + // through to the explicit arm, where a dropped `subject_id` becomes a fail-closed `denied`. + if (auth === undefined) { + return { ...payload, subjectId: params.subjectId, authorizedClientId: params.authorizedClientId }; + } + + assertAuthenticatedMachineObject(auth); + + if (auth.tokenType === 'oauth_token') { + return { ...payload, subjectId: auth.userId, authorizedClientId: auth.clientId }; + } + + return { ...payload, subjectId: params.subjectId, authorizedClientId: params.authorizedClientId }; + } + + /** + * Issues a request through the inherited request function, restoring the native + * `{ data, errors }` union that `requestFn` produces before `withLegacyRequestReturn` + * discards it. Every method on this class goes through here rather than calling + * `this.request` directly. + * + * The conversion is lossless: `requestFn` converts every failure into the errors arm and + * never throws, so the legacy wrapper is the only thing that can throw and it throws + * exactly one type, carrying through the fields of that arm it was given. Anything else + * reaching the `catch` is a bug in a layer this surface does not own, and folding it into + * an `errors` arm would disguise it — so it is rethrown. + * + * On a paginated route the wrapper resolves to `{ data, totalCount }`; a caller types `T` + * as `PaginatedResourceResponse<…>` so that shape lands intact inside `data`, the way + * `M2MTokenApi.list` types its own request. + * + * Delete this together with the legacy shim: once `buildRequest` stops wrapping, + * `PolicyAPI` can take `requestFn` directly and this method has no reason to exist. + */ + async #requestWithErrors(options: ClerkBackendApiRequestOptions): Promise> { + try { + return { data: await this.request(options), errors: null }; + } catch (error) { + if (isClerkAPIResponseError(error)) { + return { + data: null, + errors: error.errors, + status: error.status, + statusText: error.message, + clerkTraceId: error.clerkTraceId, + retryAfter: error.retryAfter, + }; + } + throw error; + } + } + + /** + * Checks an operation against the instance's policy, creating an Agent Action that records + * what was attempted and what the policy decided. Every call creates one, including for + * operations the policy immediately allows. + * + * This never blocks: an operation that needs a human returns `status: 'pending'` with an + * `approval.url` to send them to. Await the answer with `waitForApproval`. + * + * **The parameter key spelling used here is the exact spelling the field-registry + * declaration and every policy leaf must use.** `parameters.refundAmount` and + * `parameters.refund_amount` are different fields, and a mismatch is a policy leaf that + * never matches and never errors. There is no server-side guard for this. + * + * Branch on `data.status`, never on `data.decision.effect` — an action that could not be + * routed to a reviewer is created `denied` while its decision still reads + * `require_approval`, and a caller keying off the effect would wait forever. + * + * @param params - The operation to check, and the parties to attribute it to. `actorId` is + * required on every arm; passing an `oauth_token` auth object derives the subject and the + * authorized client from it. + * @returns A {@link PolicyApiResponse} whose `data` is the created [`AgentAction`](https://clerk.com/docs/reference/backend/types/backend-agent-action). + * @throws An `Error` if `auth` is present but is not an authenticated machine auth object. + * @example + * Check an operation on behalf of the human an OAuth token names + * ```ts + * import { createClerkClient } from '@clerk/backend'; + * const clerkClient = createClerkClient(...) + * + * const { data, errors } = await clerkClient.policy.check({ + * auth, // an authenticated `oauth_token` auth object + * actorId: 'https://acme.example.com/mcp', + * operation: 'api/v1/refund', + * parameters: { charge_id: 'ch_9x', refund_amount: 25000, currency: 'usd' }, + * }) + * + * if (errors) { + * // transport, auth, validation, or idempotency-mismatch failure + * } else if (data.status === 'pending') { + * // send a human to data.approval.url + * } + * ``` + */ + public async check(params: CheckParams): Promise> { + const bodyParams = this.#toCheckBodyParams(params); + + return this.#requestWithErrors({ + method: 'POST', + path: basePath, + bodyParams, + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index 90549811170..040631ef9a7 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -53,6 +53,7 @@ export * from './OrganizationPermissionApi'; export * from './OrganizationRoleApi'; export * from './OAuthApplicationsApi'; export * from './PhoneNumberApi'; +export * from './PolicyApi'; export * from './ProxyCheckApi'; export * from './RedirectUrlApi'; export * from './RoleSetApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index c9b6f700f0f..731c88b3226 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -23,6 +23,7 @@ import { OrganizationPermissionAPI, OrganizationRoleAPI, PhoneNumberAPI, + PolicyAPI, ProxyCheckAPI, RedirectUrlAPI, RoleSetAPI, @@ -108,6 +109,10 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { organizationPermissions: new OrganizationPermissionAPI(request), organizationRoles: new OrganizationRoleAPI(request), phoneNumbers: new PhoneNumberAPI(request), + /** + * @experimental This is an experimental API for the Agent Approvals feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ + policy: new PolicyAPI(request), proxyChecks: new ProxyCheckAPI(request), redirectUrls: new RedirectUrlAPI(request), roleSets: new RoleSetAPI(request), diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 27d8cfeff3d..22743aa696d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -187,6 +187,17 @@ export type { BillingSubscriptionItem, } from './api/resources'; +/** + * Endpoint params and responses + */ +export type { + CheckParams, + ExplicitCheckParams, + OAuthMachineCheckParams, + OpaqueMachineCheckParams, + PolicyApiResponse, +} from './api/endpoints/PolicyApi'; + /** * Webhooks event types */