Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/auth/grant-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:*`).
Expand Down
60 changes: 49 additions & 11 deletions src/utils/openapi-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Access, string> = {
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<string, any> };
Expand All @@ -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;
Expand All @@ -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}`;
};

/**
Expand Down
Loading