From f1eee4380e21992a337e790f1f1a1e935dfdf535 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:40:32 +0000 Subject: [PATCH 1/2] feat(api-reference): render filter grants as 'results filtered by' Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C2A97XBNr735zXYDgBMs45 --- src/utils/openapi-permissions.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/utils/openapi-permissions.ts b/src/utils/openapi-permissions.ts index 24d0a8a..4557c5d 100644 --- a/src/utils/openapi-permissions.ts +++ b/src/utils/openapi-permissions.ts @@ -13,6 +13,8 @@ * - action: workflow:execution:task:update_assigned * * An empty list means no specific grant is needed (any authenticated caller). + * + * A grant with `filter: true` is not required: list/search operations use it to filter results. */ export const PERMISSIONS_EXTENSION = 'x-epilot-permissions'; @@ -24,6 +26,7 @@ const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch' export interface Grant { action: string; resource?: string; + filter?: boolean; } export type PermissionRequirement = Grant | { anyOf: Grant[] }; @@ -64,17 +67,21 @@ export const formatPermissions = (permissions: unknown): string | null => { const label = `**[Required permissions](${PERMISSIONS_REFERENCE_URL}):**`; - if (permissions.length === 0) { - return `> ${label} none – any authenticated caller`; - } - - const requirements = permissions.map(formatRequirement).filter(Boolean); + const isFilter = (permission: unknown) => isGrant(permission) && permission.filter === true; + const requirements = permissions + .filter((permission) => !isFilter(permission)) + .map(formatRequirement) + .filter(Boolean); + const filters = permissions.filter(isFilter).map(formatGrant); - if (!requirements.length) { + if (permissions.length > 0 && !requirements.length && !filters.length) { return null; } - return `> ${label} ${requirements.join(' and ')}`; + const required = requirements.length ? requirements.join(' and ') : 'none – any authenticated caller'; + const filtered = filters.length ? ` · results filtered by ${filters.join(' and ')}` : ''; + + return `> ${label} ${required}${filtered}`; }; /** From 1bdef06d815ea5c51d155910f8e7fbbc2e869c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:19:37 +0000 Subject: [PATCH 2/2] feat(api-reference): render access entries of x-epilot-permissions { access: org|self|owner|internal|portal } renders as e.g. 'any user in the organization' or 'own user or `user:edit`'. An empty list now reads 'not documented'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C2A97XBNr735zXYDgBMs45 --- docs/auth/grant-actions.md | 2 ++ src/utils/openapi-permissions.ts | 41 ++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/auth/grant-actions.md b/docs/auth/grant-actions.md index d53eda9..7a9ba36 100644 --- a/docs/auth/grant-actions.md +++ b/docs/auth/grant-actions.md @@ -16,6 +16,8 @@ x-epilot-permissions: resource: '{slug}' ``` +Operations that don't check a specific grant declare the access they allow instead: `access: org` (any user in the organization), `self` (own user only), `owner` (resource owner), `internal` (internal epilot services only) or `portal` (portal users). List and search operations mark grants that filter results with `filter: true`. + ## Entity Entity permissions are scoped per schema using the `resource` field (e.g. `contact:*`, `opportunity:*`). diff --git a/src/utils/openapi-permissions.ts b/src/utils/openapi-permissions.ts index 4557c5d..b042fba 100644 --- a/src/utils/openapi-permissions.ts +++ b/src/utils/openapi-permissions.ts @@ -12,9 +12,11 @@ * - action: workflow:execution:task:update * - action: workflow:execution:task:update_assigned * - * An empty list means no specific grant is needed (any authenticated caller). + * An empty list means no permission check is documented for the operation. * * A grant with `filter: true` is not required: list/search operations use it to filter results. + * + * `{ access: org | self | owner | internal | portal }` documents intentional access without a grant. */ export const PERMISSIONS_EXTENSION = 'x-epilot-permissions'; @@ -29,7 +31,17 @@ export interface Grant { filter?: boolean; } -export type PermissionRequirement = Grant | { anyOf: Grant[] }; +export type Access = 'org' | 'self' | 'owner' | 'internal' | 'portal'; + +export type PermissionRequirement = Grant | { access: Access } | { anyOf: (Grant | { access: Access })[] }; + +const ACCESS_LABELS: Record = { + org: 'any user in the organization', + self: 'own user', + owner: 'resource owner', + internal: 'internal epilot services only', + portal: 'portal users', +}; // eslint-disable-next-line @typescript-eslint/no-explicit-any type OpenAPIDocument = { paths?: Record }; @@ -40,17 +52,36 @@ const isGrant = (value: unknown): value is Grant => const formatGrant = (grant: Grant) => grant.resource ? `\`${grant.action}\` on \`${grant.resource}\`` : `\`${grant.action}\``; +const formatAccess = (requirement: unknown): string | null => { + const access = (requirement as { access?: unknown })?.access; + + return typeof access === 'string' && access in ACCESS_LABELS ? ACCESS_LABELS[access as Access] : null; +}; + const formatRequirement = (requirement: unknown): string | null => { if (isGrant(requirement)) { return formatGrant(requirement); } + const access = formatAccess(requirement); + + if (access) { + return access; + } + const anyOf = (requirement as { anyOf?: unknown })?.anyOf; if (Array.isArray(anyOf)) { - const grants = anyOf.filter(isGrant).map(formatGrant); + const alternatives = anyOf + .map((alternative) => (isGrant(alternative) ? formatGrant(alternative) : formatAccess(alternative))) + .filter(Boolean); + const onlyGrants = anyOf.every(isGrant); + + if (!alternatives.length) { + return null; + } - return grants.length ? `one of ${grants.join(' or ')}` : null; + return onlyGrants ? `one of ${alternatives.join(' or ')}` : alternatives.join(' or '); } return null; @@ -78,7 +109,7 @@ export const formatPermissions = (permissions: unknown): string | null => { return null; } - const required = requirements.length ? requirements.join(' and ') : 'none – any authenticated caller'; + const required = requirements.length ? requirements.join(' and ') : 'not documented'; const filtered = filters.length ? ` · results filtered by ${filters.join(' and ')}` : ''; return `> ${label} ${required}${filtered}`;