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
8 changes: 8 additions & 0 deletions docs/auth/grant-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Complete reference of all permission grant actions supported by the epilot permi

Actions follow a `{domain}:{operation}` pattern. Use `{domain}:*` to grant all operations in a domain.

Operations in the [API reference](/api/entity) list the grants they require under **Required permissions**, declared in the OpenAPI spec via the `x-epilot-permissions` extension:

```yaml
x-epilot-permissions:
- action: entity:create
resource: '{slug}'
```

## Entity

Entity permissions are scoped per schema using the `resource` field (e.g. `contact:*`, `opportunity:*`).
Expand Down
45 changes: 42 additions & 3 deletions src/components/RedocPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,61 @@
import DocPageStyles from '@docusaurus/theme-classic/lib-next/theme/DocPage/styles.module.css';
import ApiSidebar from '@site/src/components/ApiSidebar';
import { enrichSpecWithPermissions } from '@site/src/utils/openapi-permissions';
import Layout from '@theme/Layout';
import Redoc from '@theme/Redoc';
import { ApiDocProps as Props } from 'docusaurus-theme-redoc/src/types/common';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Loading, loadAndBundleSpec } from 'redoc';

import styles from './RedocPage.module.css';

type SpecState = { status: 'loading' } | { status: 'loaded'; spec: Record<string, unknown> } | { status: 'failed' };

/**
* Loads the spec in the browser (so the reference always shows the latest published spec)
* and renders `x-epilot-permissions` into operation descriptions.
*/
function useEnrichedSpec(specUrl: string): SpecState {
const [state, setState] = useState<SpecState>({ status: 'loading' });

useEffect(() => {
let cancelled = false;

setState({ status: 'loading' });
loadAndBundleSpec(specUrl)
.then((spec) => {
const enriched = enrichSpecWithPermissions(spec) as unknown as Record<string, unknown>;

if (!cancelled) setState({ status: 'loaded', spec: enriched });
})
.catch((error) => {
console.warn(`Could not enrich ${specUrl} with permissions`, error);

if (!cancelled) setState({ status: 'failed' });
});

return () => {
cancelled = true;
};
}, [specUrl]);

return state;
}

function RedocPage({ layoutProps, spec: propSpec }: Props): JSX.Element {
const { title = 'API Docs', description = 'Open API Reference Docs for the API' } = layoutProps || {};

const specUrl: string | undefined = propSpec.type === 'url' ? propSpec.content : undefined;
const specUrl: string = (propSpec.type === 'url' ? propSpec.content : undefined) || propSpec.specUrl;
const specState = useEnrichedSpec(specUrl);

return (
<Layout {...layoutProps} title={title} description={description} pageClassName={DocPageStyles.docPage}>
<ApiSidebar />
<main className={styles.redocContainer}>
<Redoc specUrl={specUrl || propSpec.specUrl} style={{ width: '100%' }} />
{specState.status === 'loading' && <Loading color="#4C4CFF" />}
{specState.status === 'loaded' && <Redoc spec={specState.spec} specUrl={specUrl} style={{ width: '100%' }} />}
{/* Fall back to plain Redoc, which shows its own error when the spec cannot be loaded */}
{specState.status === 'failed' && <Redoc specUrl={specUrl} style={{ width: '100%' }} />}
</main>
</Layout>
);
Expand Down
113 changes: 113 additions & 0 deletions src/utils/openapi-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Renders the `x-epilot-permissions` OpenAPI extension into operation descriptions,
* so the API reference shows which permissions (role grants) each operation requires.
*
* Redoc ignores unknown `x-` extensions, so we prepend a short markdown block to the
* operation description instead. The extension is a list of grants that are all required:
*
* x-epilot-permissions:
* - action: entity:create
* resource: '{slug}'
* - anyOf:
* - action: workflow:execution:task:update
* - action: workflow:execution:task:update_assigned
*
* An empty list means no specific grant is needed (any authenticated caller).
*/

export const PERMISSIONS_EXTENSION = 'x-epilot-permissions';

export const PERMISSIONS_REFERENCE_URL = '/docs/auth/grant-actions';

const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];

export interface Grant {
action: string;
resource?: string;
}

export type PermissionRequirement = Grant | { anyOf: Grant[] };

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type OpenAPIDocument = { paths?: Record<string, any> };

const isGrant = (value: unknown): value is Grant =>
typeof value === 'object' && value !== null && typeof (value as Grant).action === 'string';

const formatGrant = (grant: Grant) =>
grant.resource ? `\`${grant.action}\` on \`${grant.resource}\`` : `\`${grant.action}\``;

const formatRequirement = (requirement: unknown): string | null => {
if (isGrant(requirement)) {
return formatGrant(requirement);
}

const anyOf = (requirement as { anyOf?: unknown })?.anyOf;

if (Array.isArray(anyOf)) {
const grants = anyOf.filter(isGrant).map(formatGrant);

return grants.length ? `one of ${grants.join(' or ')}` : null;
}

return null;
};

/**
* Returns the markdown shown at the top of an operation description,
* or null when the operation does not declare its permissions.
*/
export const formatPermissions = (permissions: unknown): string | null => {
if (!Array.isArray(permissions)) {
return 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);

if (!requirements.length) {
return null;
}

return `> ${label} ${requirements.join(' and ')}`;
};

/**
* Returns a copy of the spec with required permissions rendered into each operation description.
*/
export const enrichSpecWithPermissions = <T extends OpenAPIDocument>(spec: T): T => {
if (!spec?.paths) {
return spec;
}

const paths = Object.fromEntries(
Object.entries(spec.paths).map(([path, pathItem]) => {
if (!pathItem || typeof pathItem !== 'object') {
return [path, pathItem];
}

const enrichedPathItem = { ...pathItem };

for (const method of HTTP_METHODS) {
const operation = pathItem[method];
const permissions = formatPermissions(operation?.[PERMISSIONS_EXTENSION]);

if (permissions) {
enrichedPathItem[method] = {
...operation,
description: [permissions, operation.description].filter(Boolean).join('\n\n'),
};
}
}

return [path, enrichedPathItem];
}),
);

return { ...spec, paths };
};
Loading