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 24d0a8a..b042fba 100644 --- a/src/utils/openapi-permissions.ts +++ b/src/utils/openapi-permissions.ts @@ -12,7 +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'; @@ -24,9 +28,20 @@ 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[] }; +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 }; @@ -37,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; @@ -64,17 +98,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 ') : 'not documented'; + const filtered = filters.length ? ` · results filtered by ${filters.join(' and ')}` : ''; + + return `> ${label} ${required}${filtered}`; }; /**