diff --git a/docs/content/modeling/agents/task-based-authorization.mdx b/docs/content/modeling/agents/task-based-authorization.mdx index c320755756..4e9d2cbfe4 100644 --- a/docs/content/modeling/agents/task-based-authorization.mdx +++ b/docs/content/modeling/agents/task-based-authorization.mdx @@ -11,6 +11,7 @@ import { ProductName, ProductNameFormat, RelatedSection, + TupleViewer, } from '@components/Docs'; # Modeling Task-Based Authorization for Agents @@ -47,23 +48,13 @@ A `tool` represents a capability (e.g., `slack_send_message`), and a `tool_resou For example, you can grant `task:1` access to send any Slack message, while restricting `task:2` to a specific channel: -```yaml -tuples: - # Any task can list Slack channels - - user: task:* - relation: can_call - object: tool:slack_list_channels - - # task:1 can send any Slack message - - user: task:1 - relation: can_call - object: tool:slack_send_message - - # task:2 can only send messages to channel XGA14FG - - user: task:2 - relation: can_call - object: tool_resource:slack_send_message/XGA14FG -``` + When checking whether `task:2` can call `tool_resource:slack_send_message/XGA14FG`, send a [contextual tuple](../../interacting/contextual-tuples.mdx) linking the resource to its tool. This avoids storing a tuple for every channel — you provide the tool-to-resource relationship at query time. @@ -103,11 +94,11 @@ type organization type project relations define organization: [organization] - + # relations for users define owner: [user] define member: [user] - + # relations for tasks define read: [task] define write: [task] @@ -168,21 +159,13 @@ The `can_call` relation accepts three types of assignments: Each task is linked to its session and agent when created: -```yaml -tuples: - # Link task to its agent and session - - user: task:1 - relation: task - object: agent:1 - - user: task:1 - relation: task - object: session:1 - - # Grant session-level access - - user: session:1#task - relation: can_call - object: tool:slack_send_message -``` + ## Expiration and call count @@ -209,27 +192,24 @@ condition max_call_count(max_tool_calls: int, current_tool_count: int) { The `expiration` condition grants access for a fixed duration from the grant time. The `max_call_count` condition limits how many times the tool can be called. When writing the tuple, you provide the condition parameters: -```yaml -tuples: - # task:1 can call the tool for 10 minutes - - user: task:1 - relation: can_call - object: tool:slack_send_message - condition: - name: expiration - context: - grant_time: "2026-03-22T00:00:00Z" - grant_duration: 10m - - # task:2 can call the tool up to 2 times - - user: task:2 - relation: can_call - object: tool:slack_send_message - condition: - name: max_call_count - context: - max_tool_calls: 2 -``` + When checking access, pass the current time or current call count in the request context. @@ -255,15 +235,12 @@ type tool The `can_call` relation requires both that the task has been granted access **and** that the agent making the call is linked to the task. When the task is created, link it to its agent: -```yaml -tuples: - - user: task:1 - relation: task - object: agent:1 - - user: task:1 - relation: can_call - object: tool:slack_send_message -``` + At check time, send a contextual tuple identifying the calling agent. If the agent is linked to the task, the check returns `true`: @@ -336,6 +313,6 @@ Mapping user intent to the right set of permissions is an active area of researc title: 'Contextual Tuples', description: 'Learn how to use contextual tuples to send dynamic context at query time', link: '../../interacting/contextual-tuples', - } + }, ]} /> diff --git a/src/components/Docs/SnippetViewer/TupleViewer.tsx b/src/components/Docs/SnippetViewer/TupleViewer.tsx new file mode 100644 index 0000000000..e5b9e1eba7 --- /dev/null +++ b/src/components/Docs/SnippetViewer/TupleViewer.tsx @@ -0,0 +1,220 @@ +/** + * TupleViewer — displays OpenFGA relationship tuples in a styled code block. + * + * - Renders tuples with column-aligned keys in italic muted color. + * - Exposes CSS hook classes so shared styles can control the block surface, keys, copy button, and responsive layout. + * - Copy button writes valid YAML list format to the clipboard. + * - Optional `rightColumnTuples` renders two columns via CSS grid with a mobile fallback. + */ +import React, { useMemo, useRef } from 'react'; +import { usePrismTheme } from '@docusaurus/theme-common'; +import { CodeBlockContextProvider, createCodeBlockMetadata } from '@docusaurus/theme-common/internal'; +import CopyButton from '@theme/CodeBlock/Buttons/CopyButton'; +import type { RelationshipCondition } from '../RelationshipTuples/Viewer'; + +interface Tuple { + user: string; + relation: string; + object: string; + condition?: RelationshipCondition; +} + +interface TupleViewerProps { + tuples: Tuple[]; + rightColumnTuples?: Tuple[]; +} + +const PAD = 'condition'.length; +const INNER_PAD = 'context'.length; +const keyStyle: React.CSSProperties = { fontStyle: 'italic' }; + +function Key({ name, pad }: { name: string; pad: number }): JSX.Element { + return ( + + {name.padEnd(pad)} + + ); +} + +function formatDisplayValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function formatYamlValue(value: unknown): string { + if (value === null) { + return 'null'; + } + + if (typeof value === 'string') { + return JSON.stringify(value); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +function getTupleKey(tuple: Tuple): string { + return JSON.stringify([tuple.user, tuple.relation, tuple.object, tuple.condition ?? null]); +} + +function TupleBlock({ tuple }: { tuple: Tuple }): JSX.Element { + const contextEntries = Object.entries(tuple.condition?.context ?? {}); + + return ( +
+
+ : {tuple.user} +
+
+ : {tuple.relation} +
+
+ : {tuple.object} +
+ {tuple.condition && ( + <> +
+ : +
+
+
+ : {tuple.condition.name} +
+ {contextEntries.length > 0 && ( + <> +
+ : +
+
+ {contextEntries.map(([key, value]) => ( +
+ : {formatDisplayValue(value)} +
+ ))} +
+ + )} +
+ + )} +
+ ); +} + +function TupleList({ tuples }: { tuples: Tuple[] }): JSX.Element { + const seenTuples = new Map(); + + return ( + <> + {tuples.map((tuple) => { + const baseKey = getTupleKey(tuple); + const count = seenTuples.get(baseKey) ?? 0; + seenTuples.set(baseKey, count + 1); + const tupleKey = count === 0 ? baseKey : `${baseKey}:${count}`; + + return ; + })} + + ); +} + +function formatYaml(tuple: Tuple): string { + const lines = [`- user: ${tuple.user}`, ` relation: ${tuple.relation}`, ` object: ${tuple.object}`]; + + if (tuple.condition) { + lines.push(` condition:`); + lines.push(` name: ${tuple.condition.name}`); + + const contextEntries = Object.entries(tuple.condition.context ?? {}); + if (contextEntries.length > 0) { + lines.push(` context:`); + } + + for (const [key, value] of contextEntries) { + lines.push(` ${key}: ${formatYamlValue(value)}`); + } + } + + return lines.join('\n'); +} + +function TupleViewerCopyButton({ text }: { text: string }): JSX.Element { + const codeBlockRef = useRef(null); + const metadata = useMemo( + () => + createCodeBlockMetadata({ + code: text, + className: 'language-yaml', + language: 'yaml', + defaultLanguage: undefined, + metastring: undefined, + magicComments: [], + title: null, + showLineNumbers: false, + }), + [text], + ); + + return ( + undefined, + }} + > +
+ +
+
+ ); +} + +export function TupleViewer({ tuples, rightColumnTuples }: TupleViewerProps): JSX.Element { + const { plain } = usePrismTheme(); + const allTuples = rightColumnTuples ? [...tuples, ...rightColumnTuples] : tuples; + const yaml = allTuples.map(formatYaml).join('\n'); + + return ( +
+
+ {rightColumnTuples ? ( +
+
+ +
+
+ +
+
+ ) : ( + + )} +
+ +
+ ); +} diff --git a/src/components/Docs/SnippetViewer/index.ts b/src/components/Docs/SnippetViewer/index.ts index 19efebe2d1..f21a41cd0e 100644 --- a/src/components/Docs/SnippetViewer/index.ts +++ b/src/components/Docs/SnippetViewer/index.ts @@ -11,3 +11,4 @@ export { SupportedLanguage, languageLabelMap } from './SupportedLanguage'; export * from './StreamedListObjectsRequestViewer'; export * from './WriteRequestViewer'; export * from './WriteAuthzModelViewer'; +export * from './TupleViewer'; diff --git a/src/css/custom.css b/src/css/custom.css index d855cfbaac..bad775d014 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -35,6 +35,7 @@ html[data-theme='dark'] { --ifm-background-color: #272b33; --ifm-navbar-background-color: var(--ifm-background-color); --ifm-footer-background-color: var(--ifm-background-color); + --tuple-viewer-content-background: rgb(19, 21, 25); } table td { @@ -89,6 +90,76 @@ table td { order: 2; } +.tuple-viewer { + background-color: rgb(40, 42, 54); + box-shadow: var(--ifm-global-shadow-lw); +} +.tuple-viewer__body { + background-color: var(--tuple-viewer-content-background, inherit); + border-radius: inherit; + white-space: pre; +} + +.tuple-viewer__key { + color: rgba(248, 248, 242, 0.68); +} + +.tuple-viewer__tuple-block + .tuple-viewer__tuple-block { + margin-top: 1.1rem; +} + +.tuple-viewer__nested-block { + margin-top: 0.15rem; + margin-left: 2ch; + padding-left: 1.25ch; + border-left: 1px solid rgba(248, 248, 242, 0.1); +} + +.tuple-viewer__context-block { + margin-top: 0.1rem; +} + +.tuple-viewer__button-group { + display: flex; + column-gap: 0.2rem; + position: absolute; + top: calc(var(--ifm-pre-padding) / 2); + right: calc(var(--ifm-pre-padding) / 2); +} + +.tuple-viewer__button-group button { + display: flex; + align-items: center; + background: rgb(40, 42, 54); + color: var(--prism-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + padding: 6.4px; + line-height: 0; + transition: opacity var(--ifm-transition-fast) ease-in-out; + opacity: 0; +} + +.tuple-viewer__button-group button:focus-visible, +.tuple-viewer__button-group button:hover { + opacity: 1 !important; +} + +.tuple-viewer:hover .tuple-viewer__button-group button { + opacity: 0.4; +} + +.tuple-viewer__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 2.5ch; +} + +.tuple-viewer__column--secondary { + padding-left: 2ch; + border-left: 1px solid rgba(248, 248, 242, 0.1); +} + /* Mobile Layout */ @media (max-width: 996px) { @@ -138,4 +209,16 @@ table td { width: 100%; min-width: 0; } + + .tuple-viewer__grid { + grid-template-columns: 1fr; + gap: 1em 0; + } + + .tuple-viewer__column--secondary { + padding-left: 0; + border-left: 0; + padding-top: 1em; + border-top: 1px solid rgba(248, 248, 242, 0.1); + } }