From d781b22b1d234cf1b658f85879391cf8bb7c316d Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 7 Apr 2026 10:22:09 -0700 Subject: [PATCH 1/7] feat(docs): add Authorization REST API integration guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 745 +++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 docs/guides/authorization-rest-api.mdx diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx new file mode 100644 index 00000000..cb804c01 --- /dev/null +++ b/docs/guides/authorization-rest-api.mdx @@ -0,0 +1,745 @@ +--- +title: Authorization REST API Guide +sidebar_position: 6 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Authorization REST API Guide + +:::info What you'll learn +How to call the OpenTDF Authorization Service directly over HTTP — without an SDK — for server-side policy decisions in any language. +::: + +The OpenTDF SDKs wrap these APIs for Go, Java, and browser-based JavaScript. If you're building a **server-side integration** in Node.js, Python, Ruby, or another language without an SDK, you can call the Authorization Service REST API directly. The platform's gRPC services are exposed over HTTP via [gRPC-Gateway](https://grpc-ecosystem.github.io/grpc-gateway/), so every endpoint accepts standard JSON `POST` requests. + +This guide covers the full integration pattern: authentication, health checks, authorization decisions, and production best practices. For detailed type definitions and SDK-based examples, see the [Authorization SDK reference](/sdks/authorization). + +## Architecture + +```mermaid +sequenceDiagram + participant App as Your Application + participant IdP as Identity Provider (Keycloak) + participant Platform as OpenTDF Platform + + App->>IdP: Client credentials grant + IdP-->>App: Access token + App->>Platform: POST /authorization.v2.AuthorizationService/GetDecision + Note right of App: Bearer token + JSON body + Platform-->>App: { decision: "DECISION_PERMIT" } +``` + +Your application authenticates with the IdP once, caches the token, and makes authorization calls as needed. The platform validates the token on each request. + +## Authentication {#authentication} + +Obtain an access token using the [OAuth2 client credentials grant](https://oauth.net/2/grant-types/client-credentials/). You'll need a client ID and secret registered in your IdP (Keycloak is the reference implementation). + + + + +```bash +curl -X POST "${OIDC_ENDPOINT}/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" +``` + + + + +```typescript +async function getClientToken( + oidcEndpoint: string, + clientId: string, + clientSecret: string, +): Promise<{ accessToken: string; expiresAt: number }> { + const response = await fetch( + `${oidcEndpoint}/protocol/openid-connect/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }), + }, + ); + + if (!response.ok) { + throw new Error(`Token request failed: ${response.status}`); + } + + const data = await response.json(); + return { + accessToken: data.access_token, + // Refresh 30 seconds before actual expiry + expiresAt: Date.now() + (data.expires_in - 30) * 1000, + }; +} +``` + + + + +```python +import requests +import time + +def get_client_token(oidc_endpoint: str, client_id: str, client_secret: str): + response = requests.post( + f"{oidc_endpoint}/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + ) + response.raise_for_status() + data = response.json() + return { + "access_token": data["access_token"], + # Refresh 30 seconds before actual expiry + "expires_at": time.time() + data["expires_in"] - 30, + } +``` + + + + +**Token caching:** The `expires_in` field tells you how long the token is valid (in seconds). Cache the token and refresh it before expiry — subtracting a 30-second buffer avoids race conditions on expiry boundaries. + +See the [Authentication Decision Guide](/guides/authentication-guide) for help choosing the right auth method for your environment. + +--- + +## Health Check {#health-check} + +Before making authorization calls, verify the platform is reachable: + +```bash +curl "${PLATFORM_URL}/healthz" +``` + +**Expected response:** + +```json +{ "status": "SERVING" } +``` + +Any other status or a connection failure means the platform is unavailable. See [Best Practices](#fail-closed) for how to handle this. + +--- + +## Endpoint Reference {#endpoints} + +All authorization endpoints accept `POST` requests with `Content-Type: application/json` and a `Bearer` token in the `Authorization` header. + +| Endpoint | API Version | Description | +|----------|-------------|-------------| +| `/authorization.v2.AuthorizationService/GetDecision` | v2 | Single entity + action + resource decision | +| `/authorization.v2.AuthorizationService/GetDecisionBulk` | v2 | Batch decisions for multiple entities/resources | +| `/authorization.v2.AuthorizationService/GetEntitlements` | v2 | List all attribute values an entity can access | +| `/authorization.AuthorizationService/GetDecisions` | v1 (legacy) | Batch decisions (v1 format) | + +:::tip Use v2 endpoints for new integrations +The v2 API has a cleaner request structure and supports per-resource decisions. The v1 API is still supported but considered legacy. +::: + +--- + +## GetDecision {#get-decision} + +Check whether a specific entity can perform an action on a resource. This is the core enforcement point. + +**Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecision` + + + + +```bash +curl -X POST "${PLATFORM_URL}/authorization.v2.AuthorizationService/GetDecision" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "entityIdentifier": { + "entityChain": { + "entities": [ + { + "ephemeralId": "user-check", + "emailAddress": "alice@example.com" + } + ] + } + }, + "action": { "name": "read" }, + "resource": { + "ephemeralId": "room-123", + "attributeValues": { + "fqns": [ + "https://example.com/attr/clearance/value/confidential" + ] + } + } + }' +``` + + + + +```typescript +const response = await fetch( + `${platformUrl}/authorization.v2.AuthorizationService/GetDecision`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + entityIdentifier: { + entityChain: { + entities: [ + { + ephemeralId: 'user-check', + emailAddress: 'alice@example.com', + }, + ], + }, + }, + action: { name: 'read' }, + resource: { + ephemeralId: 'room-123', + attributeValues: { + fqns: [ + 'https://example.com/attr/clearance/value/confidential', + ], + }, + }, + }), + }, +); + +const result = await response.json(); +const permitted = result.decision?.decision === 'DECISION_PERMIT'; +``` + + + + +```python +response = requests.post( + f"{platform_url}/authorization.v2.AuthorizationService/GetDecision", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + json={ + "entityIdentifier": { + "entityChain": { + "entities": [ + { + "ephemeralId": "user-check", + "emailAddress": "alice@example.com", + } + ] + } + }, + "action": {"name": "read"}, + "resource": { + "ephemeralId": "room-123", + "attributeValues": { + "fqns": [ + "https://example.com/attr/clearance/value/confidential" + ] + }, + }, + }, +) + +result = response.json() +permitted = result.get("decision", {}).get("decision") == "DECISION_PERMIT" +``` + + + + +**Response:** + +```json +{ + "decision": { + "ephemeralResourceId": "room-123", + "decision": "DECISION_PERMIT", + "requiredObligations": [] + } +} +``` + +The `decision` field is either `DECISION_PERMIT` or `DECISION_DENY`. If `requiredObligations` is non-empty, your application must enforce those obligations (e.g., watermarking, audit logging). See [Obligations](/sdks/obligations) for details. + +--- + +## GetDecisionBulk {#get-decision-bulk} + +Evaluate multiple entities and resources in a single call. Each request entry can include multiple resources, and the response provides per-resource decisions. + +**Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecisionBulk` + + + + +```bash +curl -X POST "${PLATFORM_URL}/authorization.v2.AuthorizationService/GetDecisionBulk" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "decisionRequests": [ + { + "entityIdentifier": { + "entityChain": { + "entities": [{ "ephemeralId": "user-1", "emailAddress": "alice@example.com" }] + } + }, + "action": { "name": "read" }, + "resources": [ + { + "ephemeralId": "room-a", + "attributeValues": { + "fqns": ["https://example.com/attr/clearance/value/confidential"] + } + }, + { + "ephemeralId": "room-b", + "attributeValues": { + "fqns": ["https://example.com/attr/clearance/value/public"] + } + } + ] + }, + { + "entityIdentifier": { + "entityChain": { + "entities": [{ "ephemeralId": "user-2", "emailAddress": "bob@example.com" }] + } + }, + "action": { "name": "read" }, + "resources": [ + { + "ephemeralId": "room-a", + "attributeValues": { + "fqns": ["https://example.com/attr/clearance/value/confidential"] + } + } + ] + } + ] + }' +``` + + + + +```typescript +const response = await fetch( + `${platformUrl}/authorization.v2.AuthorizationService/GetDecisionBulk`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + decisionRequests: [ + { + entityIdentifier: { + entityChain: { + entities: [{ ephemeralId: 'user-1', emailAddress: 'alice@example.com' }], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: 'room-a', + attributeValues: { + fqns: ['https://example.com/attr/clearance/value/confidential'], + }, + }, + { + ephemeralId: 'room-b', + attributeValues: { + fqns: ['https://example.com/attr/clearance/value/public'], + }, + }, + ], + }, + { + entityIdentifier: { + entityChain: { + entities: [{ ephemeralId: 'user-2', emailAddress: 'bob@example.com' }], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: 'room-a', + attributeValues: { + fqns: ['https://example.com/attr/clearance/value/confidential'], + }, + }, + ], + }, + ], + }), + }, +); + +const result = await response.json(); + +for (const resp of result.decisionResponses) { + for (const rd of resp.resourceDecisions) { + console.log(`${rd.ephemeralResourceId}: ${rd.decision}`); + } +} +``` + + + + +**Response:** + +```json +{ + "decisionResponses": [ + { + "allPermitted": true, + "resourceDecisions": [ + { "ephemeralResourceId": "room-a", "decision": "DECISION_PERMIT", "requiredObligations": [] }, + { "ephemeralResourceId": "room-b", "decision": "DECISION_PERMIT", "requiredObligations": [] } + ] + }, + { + "allPermitted": false, + "resourceDecisions": [ + { "ephemeralResourceId": "room-a", "decision": "DECISION_DENY", "requiredObligations": [] } + ] + } + ] +} +``` + +### Batching Strategy {#batching} + +For large-scale evaluations (e.g., re-evaluating room membership for all users), send requests in batches rather than one massive payload: + +- **Batch size:** 100–200 decision requests per call +- **Concurrency:** 2–4 parallel requests +- **Why:** Keeps individual request latency manageable and avoids timeouts + +```typescript +import pLimit from 'p-limit'; + +const BATCH_SIZE = 200; +const limit = pLimit(4); + +// Split requests into batches +const batches = []; +for (let i = 0; i < allRequests.length; i += BATCH_SIZE) { + batches.push(allRequests.slice(i, i + BATCH_SIZE)); +} + +// Execute batches with concurrency limit +const results = await Promise.all( + batches.map((batch) => + limit(() => + fetch(`${platformUrl}/authorization.v2.AuthorizationService/GetDecisionBulk`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ decisionRequests: batch }), + }).then((r) => r.json()), + ), + ), +); +``` + +--- + +## GetEntitlements {#get-entitlements} + +Returns all attribute values an entity is entitled to access, without checking against a specific resource. Use this for building UIs that show available data or pre-filtering content. + +**Endpoint:** `POST /authorization.v2.AuthorizationService/GetEntitlements` + + + + +```bash +curl -X POST "${PLATFORM_URL}/authorization.v2.AuthorizationService/GetEntitlements" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{ + "entityIdentifier": { + "entityChain": { + "entities": [ + { + "ephemeralId": "user-1", + "emailAddress": "alice@example.com" + } + ] + } + } + }' +``` + + + + +```typescript +const response = await fetch( + `${platformUrl}/authorization.v2.AuthorizationService/GetEntitlements`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + entityIdentifier: { + entityChain: { + entities: [ + { + ephemeralId: 'user-1', + emailAddress: 'alice@example.com', + }, + ], + }, + }, + }), + }, +); + +const result = await response.json(); + +for (const entitlement of result.entitlements) { + for (const [fqn, actions] of Object.entries( + entitlement.actionsPerAttributeValueFqn, + )) { + console.log(`${fqn}: ${actions.actions.map((a) => a.name).join(', ')}`); + } +} +``` + + + + +**Response:** + +```json +{ + "entitlements": [ + { + "ephemeralId": "user-1", + "actionsPerAttributeValueFqn": { + "https://example.com/attr/clearance/value/public": { + "actions": [{ "name": "read" }, { "name": "decrypt" }] + }, + "https://example.com/attr/clearance/value/confidential": { + "actions": [{ "name": "read" }] + } + } + } + ] +} +``` + +--- + +## Building Attribute FQNs {#attribute-fqns} + +Attribute value FQNs (Fully Qualified Names) identify specific attribute values in the platform. They follow this pattern: + +``` +https://{namespace}/attr/{attribute-key}/value/{value} +``` + +**Examples:** + +| Namespace | Attribute | Value | FQN | +|-----------|-----------|-------|-----| +| `example.com` | `clearance` | `confidential` | `https://example.com/attr/clearance/value/confidential` | +| `opentdf.io` | `department` | `finance` | `https://opentdf.io/attr/department/value/finance` | +| `mycompany.com` | `region` | `eu-west` | `https://mycompany.com/attr/region/value/eu-west` | + +If your application stores attributes as key-value pairs, build FQNs like this: + +```typescript +function buildAttributeFqns( + namespace: string, + attributes: { key: string; values: string[] }[], +): string[] { + return attributes.flatMap((attr) => + attr.values.map( + (value) => `https://${namespace}/attr/${attr.key}/value/${value}`, + ), + ); +} + +// Example: +buildAttributeFqns('example.com', [ + { key: 'clearance', values: ['confidential', 'secret'] }, + { key: 'department', values: ['finance'] }, +]); +// [ +// "https://example.com/attr/clearance/value/confidential", +// "https://example.com/attr/clearance/value/secret", +// "https://example.com/attr/department/value/finance", +// ] +``` + +--- + +## Building Entity Identifiers {#entity-identifiers} + +Every authorization call requires an entity identifier — the user or service you're asking about. The platform supports several identifier types: + +| Type | JSON field | Use case | +|------|-----------|----------| +| Email | `emailAddress` | Most common for human users | +| Client ID | `clientId` | Service accounts / non-person entities | +| Username | `userName` | When username is the primary identifier | +| JWT Token | `token` | Let the platform resolve the entity from a JWT | + +**Entity chain structure:** + +```json +{ + "entityIdentifier": { + "entityChain": { + "entities": [ + { + "ephemeralId": "some-correlation-id", + "emailAddress": "alice@example.com" + } + ] + } + } +} +``` + +To use a different identifier type, replace `emailAddress` with the appropriate field: + +```json +{ "ephemeralId": "svc-1", "clientId": "my-service-account" } +``` + +```json +{ "ephemeralId": "user-1", "userName": "alice" } +``` + +The `ephemeralId` is a correlation ID you assign — it appears in the response so you can match results to requests. + +For full details on all entity identifier options, see [EntityIdentifier](/sdks/authorization#entityidentifier) in the SDK reference. + +--- + +## Best Practices {#best-practices} + +### Fail Closed {#fail-closed} + +If the platform is unreachable or returns an error, **deny access by default**. Never fall back to "allow" when you can't verify authorization. + +```typescript +async function canAccess( + platformUrl: string, + token: string, + request: object, +): Promise { + try { + const response = await fetch( + `${platformUrl}/authorization.v2.AuthorizationService/GetDecision`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(request), + }, + ); + + if (!response.ok) { + return false; // Platform error — deny + } + + const result = await response.json(); + return result.decision?.decision === 'DECISION_PERMIT'; + } catch { + return false; // Network error — deny + } +} +``` + +### Token Caching {#token-caching} + +Don't request a new token for every authorization call. Cache the token and refresh it before it expires: + +```typescript +let tokenCache: { accessToken: string; expiresAt: number } | null = null; + +async function getToken(): Promise { + if (tokenCache && Date.now() < tokenCache.expiresAt) { + return tokenCache.accessToken; + } + tokenCache = await getClientToken(oidcEndpoint, clientId, clientSecret); + return tokenCache.accessToken; +} +``` + +### Request Timeouts {#timeouts} + +Set timeouts on all HTTP calls to avoid hanging requests. A 10-second timeout is a reasonable default for authorization calls: + +```typescript +const controller = new AbortController(); +const timeout = setTimeout(() => controller.abort(), 10000); + +try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + // handle response +} finally { + clearTimeout(timeout); +} +``` + +### Use GetDecisionBulk for Multiple Checks {#prefer-bulk} + +If you need to check authorization for multiple users or resources, use [GetDecisionBulk](#get-decision-bulk) instead of calling [GetDecision](#get-decision) in a loop. A single bulk request with 100 entries is significantly faster than 100 individual requests. + +### Handle Obligations {#obligations} + +When `requiredObligations` is non-empty in a permit response, your application is responsible for enforcing those obligations (e.g., watermarking, audit logging, DRM). A permit with unfulfilled obligations should be treated as a deny. See [Obligations](/sdks/obligations) for details. + +--- + +## Production Checklist {#production-checklist} + +- [ ] **TLS everywhere** — all connections to the platform and IdP use HTTPS +- [ ] **Secrets in a vault** — client ID and secret stored securely, not in code +- [ ] **Token caching** — tokens are cached and refreshed before expiry +- [ ] **Fail closed** — access is denied when the platform is unreachable +- [ ] **Request timeouts** — all HTTP calls have explicit timeouts +- [ ] **Health monitoring** — periodic `/healthz` checks with alerting +- [ ] **Batch where possible** — use `GetDecisionBulk` for multi-user/resource checks +- [ ] **Obligation enforcement** — `requiredObligations` are checked and fulfilled +- [ ] **Logging** — authorization decisions are logged for audit +- [ ] **Secret rotation** — client secrets are rotated on a regular schedule From a0485cf11f5063382db81cb63792914747997721 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 23 Apr 2026 15:22:23 -0700 Subject: [PATCH 2/7] fix(docs): address review feedback on authorization REST API guide - Fix token entity identifier structure (top-level alternative to entityChain, not a field within it) - Use versioned REST path /v1/authorization for legacy GetDecisions endpoint - Add audience configuration warning for OIDC tokens - Document DECISION_UNSPECIFIED alongside PERMIT and DENY - Note 200-request hard limit on GetDecisionBulk - Document index-matched bulk responses with no entity info - Update vendored OpenAPI specs (authorization, kasregistry) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 59 ++++++++++++++++++++------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index cb804c01..a14b1013 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -111,6 +111,10 @@ def get_client_token(oidc_endpoint: str, client_id: str, client_secret: str): +:::warning Audience configuration +Your client credentials token must include an `audience` claim that matches your OpenTDF platform's URL. If the audience doesn't match, the platform will reject the token. Configure this in your IdP client settings — in Keycloak, set the client's audience mapper to include the platform URL. +::: + **Token caching:** The `expires_in` field tells you how long the token is valid (in seconds). Cache the token and refresh it before expiry — subtracting a 30-second buffer avoids race conditions on expiry boundaries. See the [Authentication Decision Guide](/guides/authentication-guide) for help choosing the right auth method for your environment. @@ -144,7 +148,7 @@ All authorization endpoints accept `POST` requests with `Content-Type: applicati | `/authorization.v2.AuthorizationService/GetDecision` | v2 | Single entity + action + resource decision | | `/authorization.v2.AuthorizationService/GetDecisionBulk` | v2 | Batch decisions for multiple entities/resources | | `/authorization.v2.AuthorizationService/GetEntitlements` | v2 | List all attribute values an entity can access | -| `/authorization.AuthorizationService/GetDecisions` | v1 (legacy) | Batch decisions (v1 format) | +| `/v1/authorization` | v1 (legacy) | Batch decisions (v1 format) | :::tip Use v2 endpoints for new integrations The v2 API has a cleaner request structure and supports per-resource decisions. The v1 API is still supported but considered legacy. @@ -280,7 +284,15 @@ permitted = result.get("decision", {}).get("decision") == "DECISION_PERMIT" } ``` -The `decision` field is either `DECISION_PERMIT` or `DECISION_DENY`. If `requiredObligations` is non-empty, your application must enforce those obligations (e.g., watermarking, audit logging). See [Obligations](/sdks/obligations) for details. +The `decision` field will be one of: + +| Value | Meaning | +|-------|---------| +| `DECISION_PERMIT` | Access allowed | +| `DECISION_DENY` | Access denied | +| `DECISION_UNSPECIFIED` | The platform could not evaluate the request — treat as deny | + +If `requiredObligations` is non-empty on a permit response, your application must enforce those obligations (e.g., watermarking, audit logging). See [Obligations](/sdks/obligations) for details. --- @@ -432,11 +444,16 @@ for (const resp of result.decisionResponses) { } ``` +:::caution Response ordering is index-matched +The `decisionResponses` array is **ordered to match the input `decisionRequests`** — the first response corresponds to the first request, and so on. The response does not include entity identifier information, so you must rely on this positional correspondence to associate decisions with entities. Use the `ephemeralResourceId` on each resource decision to match back to specific resources within a request. +::: +``` + ### Batching Strategy {#batching} -For large-scale evaluations (e.g., re-evaluating room membership for all users), send requests in batches rather than one massive payload: +The platform enforces a **maximum of 200 decision requests per `GetDecisionBulk` call** (each with up to 1,000 resources). For large-scale evaluations (e.g., re-evaluating room membership for all users), split requests into batches: -- **Batch size:** 100–200 decision requests per call +- **Batch size:** Up to 200 decision requests per call (hard limit) - **Concurrency:** 2–4 parallel requests - **Why:** Keeps individual request latency manageable and avoids timeouts @@ -607,16 +624,17 @@ buildAttributeFqns('example.com', [ ## Building Entity Identifiers {#entity-identifiers} -Every authorization call requires an entity identifier — the user or service you're asking about. The platform supports several identifier types: +Every authorization call requires an entity identifier — the user or service you're asking about. The `entityIdentifier` field accepts one of the following (mutually exclusive): -| Type | JSON field | Use case | -|------|-----------|----------| -| Email | `emailAddress` | Most common for human users | -| Client ID | `clientId` | Service accounts / non-person entities | -| Username | `userName` | When username is the primary identifier | -| JWT Token | `token` | Let the platform resolve the entity from a JWT | +### Option 1: Entity Chain -**Entity chain structure:** +Use `entityChain` when you know the entity's email, client ID, or username. Each entity in the chain has an `ephemeralId` (a correlation ID you assign) and one identifier field: + +| Field | Use case | +|-------|----------| +| `emailAddress` | Most common for human users | +| `clientId` | Service accounts / non-person entities | +| `userName` | When username is the primary identifier | ```json { @@ -633,7 +651,7 @@ Every authorization call requires an entity identifier — the user or service y } ``` -To use a different identifier type, replace `emailAddress` with the appropriate field: +To use a different identifier type, swap the identifier field: ```json { "ephemeralId": "svc-1", "clientId": "my-service-account" } @@ -643,6 +661,21 @@ To use a different identifier type, replace `emailAddress` with the appropriate { "ephemeralId": "user-1", "userName": "alice" } ``` +### Option 2: JWT Token + +Use `token` to let the platform resolve the entity from a JWT. This is a **top-level alternative** to `entityChain` — it replaces the entire `entityChain` block: + +```json +{ + "entityIdentifier": { + "token": { + "ephemeralId": "token-correlation-id", + "jwt": "eyJhbGciOiJSUzI1NiIs..." + } + } +} +``` + The `ephemeralId` is a correlation ID you assign — it appears in the response so you can match results to requests. For full details on all entity identifier options, see [EntityIdentifier](/sdks/authorization#entityidentifier) in the SDK reference. From bed273df3c2ca9de1cbbe32b6d2778878ad689a9 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 23 Apr 2026 15:27:20 -0700 Subject: [PATCH 3/7] fix(docs): fix broken best-practices anchor link in health check section Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index a14b1013..f2c85079 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -135,7 +135,7 @@ curl "${PLATFORM_URL}/healthz" { "status": "SERVING" } ``` -Any other status or a connection failure means the platform is unavailable. See [Best Practices](#fail-closed) for how to handle this. +Any other status or a connection failure means the platform is unavailable. See [Best Practices](#best-practices) for how to handle this. --- From bea813f94e893f61d6b4871bb95677d7808eb549 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 23 Apr 2026 15:33:09 -0700 Subject: [PATCH 4/7] fix(docs): make endpoint code examples collapsible and fix stray code fence Wrap GetDecision, GetDecisionBulk, and GetEntitlements tab examples in
/ blocks so the page is easier to scan. Also wrap the batching strategy TypeScript snippet and remove a stray ``` after the index-matching caution admonition. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index f2c85079..d3b3724f 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -162,6 +162,9 @@ Check whether a specific entity can perform an action on a resource. This is the **Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecision` +
+Example request + @@ -272,6 +275,8 @@ permitted = result.get("decision", {}).get("decision") == "DECISION_PERMIT" +
+ **Response:** ```json @@ -302,6 +307,9 @@ Evaluate multiple entities and resources in a single call. Each request entry ca **Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecisionBulk` +
+Example request + @@ -422,6 +430,8 @@ for (const resp of result.decisionResponses) { +
+ **Response:** ```json @@ -447,7 +457,6 @@ for (const resp of result.decisionResponses) { :::caution Response ordering is index-matched The `decisionResponses` array is **ordered to match the input `decisionRequests`** — the first response corresponds to the first request, and so on. The response does not include entity identifier information, so you must rely on this positional correspondence to associate decisions with entities. Use the `ephemeralResourceId` on each resource decision to match back to specific resources within a request. ::: -``` ### Batching Strategy {#batching} @@ -457,6 +466,9 @@ The platform enforces a **maximum of 200 decision requests per `GetDecisionBulk` - **Concurrency:** 2–4 parallel requests - **Why:** Keeps individual request latency manageable and avoids timeouts +
+Batching example (TypeScript) + ```typescript import pLimit from 'p-limit'; @@ -486,6 +498,8 @@ const results = await Promise.all( ); ``` +
+ --- ## GetEntitlements {#get-entitlements} @@ -494,6 +508,9 @@ Returns all attribute values an entity is entitled to access, without checking a **Endpoint:** `POST /authorization.v2.AuthorizationService/GetEntitlements` +
+Example request + @@ -556,6 +573,8 @@ for (const entitlement of result.entitlements) { +
+ **Response:** ```json From db4fae0841633695a2bccbaa1400f010c9a22675 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 23 Apr 2026 15:37:06 -0700 Subject: [PATCH 5/7] fix(docs): add cross-links to OpenAPI reference pages Link each endpoint section and the endpoint reference table to the corresponding auto-generated OpenAPI schema pages under /OpenAPI-clients/authorization/v2/. Also add a top-level link to the OpenAPI reference in the intro paragraph. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index d3b3724f..65a47d76 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -14,7 +14,7 @@ How to call the OpenTDF Authorization Service directly over HTTP — without an The OpenTDF SDKs wrap these APIs for Go, Java, and browser-based JavaScript. If you're building a **server-side integration** in Node.js, Python, Ruby, or another language without an SDK, you can call the Authorization Service REST API directly. The platform's gRPC services are exposed over HTTP via [gRPC-Gateway](https://grpc-ecosystem.github.io/grpc-gateway/), so every endpoint accepts standard JSON `POST` requests. -This guide covers the full integration pattern: authentication, health checks, authorization decisions, and production best practices. For detailed type definitions and SDK-based examples, see the [Authorization SDK reference](/sdks/authorization). +This guide covers the full integration pattern: authentication, health checks, authorization decisions, and production best practices. For detailed type definitions and SDK-based examples, see the [Authorization SDK reference](/sdks/authorization). For request/response schemas, see the [Authorization OpenAPI reference](/OpenAPI-clients/authorization/v2). ## Architecture @@ -143,12 +143,12 @@ Any other status or a connection failure means the platform is unavailable. See All authorization endpoints accept `POST` requests with `Content-Type: application/json` and a `Bearer` token in the `Authorization` header. -| Endpoint | API Version | Description | -|----------|-------------|-------------| -| `/authorization.v2.AuthorizationService/GetDecision` | v2 | Single entity + action + resource decision | -| `/authorization.v2.AuthorizationService/GetDecisionBulk` | v2 | Batch decisions for multiple entities/resources | -| `/authorization.v2.AuthorizationService/GetEntitlements` | v2 | List all attribute values an entity can access | -| `/v1/authorization` | v1 (legacy) | Batch decisions (v1 format) | +| Endpoint | API Version | Description | Schema | +|----------|-------------|-------------|--------| +| `/authorization.v2.AuthorizationService/GetDecision` | v2 | Single entity + action + resource decision | [OpenAPI](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-decision) | +| `/authorization.v2.AuthorizationService/GetDecisionBulk` | v2 | Batch decisions for multiple entities/resources | [OpenAPI](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-decision-bulk) | +| `/authorization.v2.AuthorizationService/GetEntitlements` | v2 | List all attribute values an entity can access | [OpenAPI](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-entitlements) | +| `/v1/authorization` | v1 (legacy) | Batch decisions (v1 format) | [OpenAPI](/OpenAPI-clients/authorization/v1/authorization-authorization-service-get-decisions) | :::tip Use v2 endpoints for new integrations The v2 API has a cleaner request structure and supports per-resource decisions. The v1 API is still supported but considered legacy. @@ -158,7 +158,7 @@ The v2 API has a cleaner request structure and supports per-resource decisions. ## GetDecision {#get-decision} -Check whether a specific entity can perform an action on a resource. This is the core enforcement point. +Check whether a specific entity can perform an action on a resource. This is the core enforcement point. See the [OpenAPI schema](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-decision) for full request/response type definitions. **Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecision` @@ -303,7 +303,7 @@ If `requiredObligations` is non-empty on a permit response, your application mus ## GetDecisionBulk {#get-decision-bulk} -Evaluate multiple entities and resources in a single call. Each request entry can include multiple resources, and the response provides per-resource decisions. +Evaluate multiple entities and resources in a single call. Each request entry can include multiple resources, and the response provides per-resource decisions. See the [OpenAPI schema](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-decision-bulk) for full request/response type definitions. **Endpoint:** `POST /authorization.v2.AuthorizationService/GetDecisionBulk` @@ -504,7 +504,7 @@ const results = await Promise.all( ## GetEntitlements {#get-entitlements} -Returns all attribute values an entity is entitled to access, without checking against a specific resource. Use this for building UIs that show available data or pre-filtering content. +Returns all attribute values an entity is entitled to access, without checking against a specific resource. Use this for building UIs that show available data or pre-filtering content. See the [OpenAPI schema](/OpenAPI-clients/authorization/v2/authorization-v-2-authorization-service-get-entitlements) for full request/response type definitions. **Endpoint:** `POST /authorization.v2.AuthorizationService/GetEntitlements` From ed01b18169c70a55be775a1d09e4a14d514d4e49 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 24 Apr 2026 11:26:08 -0700 Subject: [PATCH 6/7] fix(docs): add cross-links, curl batching example, and FQN naming rules link Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 43 ++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index 65a47d76..ace377b1 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -460,14 +460,36 @@ The `decisionResponses` array is **ordered to match the input `decisionRequests` ### Batching Strategy {#batching} -The platform enforces a **maximum of 200 decision requests per `GetDecisionBulk` call** (each with up to 1,000 resources). For large-scale evaluations (e.g., re-evaluating room membership for all users), split requests into batches: +The platform enforces a **maximum of 200 decision requests per [`GetDecisionBulk`](#get-decision-bulk) call** (each with up to 1,000 resources). For large-scale evaluations (e.g., re-evaluating room membership for all users), split requests into batches: - **Batch size:** Up to 200 decision requests per call (hard limit) - **Concurrency:** 2–4 parallel requests - **Why:** Keeps individual request latency manageable and avoids timeouts
-Batching example (TypeScript) +Batching examples + + + + +```bash +# Split a large request list into batches of 200 and send sequentially +BATCH_SIZE=200 +TOTAL=${#ALL_REQUESTS[@]} + +for (( i=0; i + ```typescript import pLimit from 'p-limit'; @@ -498,6 +520,9 @@ const results = await Promise.all( ); ``` + + +
--- @@ -599,7 +624,7 @@ for (const entitlement of result.entitlements) { ## Building Attribute FQNs {#attribute-fqns} -Attribute value FQNs (Fully Qualified Names) identify specific attribute values in the platform. They follow this pattern: +Attribute value FQNs (Fully Qualified Names) identify specific attribute values in the platform. For naming rules (allowed characters, casing), see [Creating attributes](/components/cli/policy/attributes/create). FQNs follow this pattern: ``` https://{namespace}/attr/{attribute-key}/value/{value} @@ -787,11 +812,11 @@ When `requiredObligations` is non-empty in a permit response, your application i - [ ] **TLS everywhere** — all connections to the platform and IdP use HTTPS - [ ] **Secrets in a vault** — client ID and secret stored securely, not in code -- [ ] **Token caching** — tokens are cached and refreshed before expiry -- [ ] **Fail closed** — access is denied when the platform is unreachable -- [ ] **Request timeouts** — all HTTP calls have explicit timeouts -- [ ] **Health monitoring** — periodic `/healthz` checks with alerting -- [ ] **Batch where possible** — use `GetDecisionBulk` for multi-user/resource checks -- [ ] **Obligation enforcement** — `requiredObligations` are checked and fulfilled +- [ ] **[Token caching](#token-caching)** — tokens are cached and refreshed before expiry +- [ ] **[Fail closed](#fail-closed)** — access is denied when the platform is unreachable +- [ ] **[Request timeouts](#timeouts)** — all HTTP calls have explicit timeouts +- [ ] **[Health monitoring](#health-check)** — periodic `/healthz` checks with alerting +- [ ] **[Batch where possible](#prefer-bulk)** — use `GetDecisionBulk` for multi-user/resource checks +- [ ] **[Obligation enforcement](/components/policy/obligations)** — `requiredObligations` are checked and fulfilled - [ ] **Logging** — authorization decisions are logged for audit - [ ] **Secret rotation** — client secrets are rotated on a regular schedule From 864728da0696b8752efcea2f888821b4dff1c71f Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 27 Apr 2026 11:12:10 -0700 Subject: [PATCH 7/7] fix(docs): address jakedoublev review feedback on REST API guide Replace incorrect gRPC-Gateway reference with ConnectRPC (HTTP/1.1 and HTTP/2 supported natively, no gateway needed). Add JWT validation warning for integrator PEPs passing tokens to the authorization service. Also includes previously staged v1-to-v2 migration details section. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/authorization-rest-api.mdx | 80 +++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/guides/authorization-rest-api.mdx b/docs/guides/authorization-rest-api.mdx index ace377b1..7434cbdb 100644 --- a/docs/guides/authorization-rest-api.mdx +++ b/docs/guides/authorization-rest-api.mdx @@ -12,7 +12,7 @@ import TabItem from '@theme/TabItem'; How to call the OpenTDF Authorization Service directly over HTTP — without an SDK — for server-side policy decisions in any language. ::: -The OpenTDF SDKs wrap these APIs for Go, Java, and browser-based JavaScript. If you're building a **server-side integration** in Node.js, Python, Ruby, or another language without an SDK, you can call the Authorization Service REST API directly. The platform's gRPC services are exposed over HTTP via [gRPC-Gateway](https://grpc-ecosystem.github.io/grpc-gateway/), so every endpoint accepts standard JSON `POST` requests. +The OpenTDF SDKs wrap these APIs for Go, Java, and browser-based JavaScript. If you're building a **server-side integration** in Node.js, Python, Ruby, or another language without an SDK, you can call the Authorization Service directly over HTTP. The platform uses [ConnectRPC](https://connectrpc.com/), which natively supports HTTP/1.1 and HTTP/2, so every endpoint accepts standard JSON `POST` requests without requiring a separate gateway. This guide covers the full integration pattern: authentication, health checks, authorization decisions, and production best practices. For detailed type definitions and SDK-based examples, see the [Authorization SDK reference](/sdks/authorization). For request/response schemas, see the [Authorization OpenAPI reference](/OpenAPI-clients/authorization/v2). @@ -151,9 +151,81 @@ All authorization endpoints accept `POST` requests with `Content-Type: applicati | `/v1/authorization` | v1 (legacy) | Batch decisions (v1 format) | [OpenAPI](/OpenAPI-clients/authorization/v1/authorization-authorization-service-get-decisions) | :::tip Use v2 endpoints for new integrations -The v2 API has a cleaner request structure and supports per-resource decisions. The v1 API is still supported but considered legacy. +The v2 API has a cleaner request structure and supports per-resource decisions. Use `GetDecision` for single checks and `GetDecisionBulk` for batch — both are v2, so you don't need to mix API versions. The v1 API is still supported but considered legacy, and its request shape differs significantly from v2. ::: +
+Migrating from v1 to v2 + +The v1 and v2 APIs have different request structures. Here's a side-by-side comparison: + +**v1 `GetDecisions`** — entities and resources are separate top-level arrays, cross-joined by the platform: + +```json +{ + "decisionRequests": [{ + "actions": [{ "standard": "STANDARD_ACTION_DECRYPT" }], + "entityChains": [ + { + "id": "ec1", + "entities": [{ "emailAddress": "alice@example.com" }] + } + ], + "resourceAttributes": [ + { + "resourceAttributesId": "resource-1", + "attributeValueFqns": [ + "https://example.com/attr/clearance/value/confidential" + ] + } + ] + }] +} +``` + +**v2 `GetDecision`** — one entity, one action, one resource per request: + +```json +{ + "entityIdentifier": { + "entityChain": { + "entities": [ + { "ephemeralId": "ec1", "emailAddress": "alice@example.com" } + ] + } + }, + "action": { "name": "decrypt" }, + "resource": { + "ephemeralId": "resource-1", + "attributeValues": { + "fqns": [ + "https://example.com/attr/clearance/value/confidential" + ] + } + } +} +``` + +**Key differences:** + +| | v1 | v2 | +|---|---|---| +| **Actions** | `actions: [{ standard: "STANDARD_ACTION_DECRYPT" }]` (enum) | `action: { name: "decrypt" }` (string) | +| **Entities** | `entityChains` (top-level array, cross-joined with resources) | `entityIdentifier.entityChain` (scoped to one request) | +| **Resources** | `resourceAttributes[].attributeValueFqns` | `resource.attributeValues.fqns` | +| **Correlation** | `entityChainId` + `resourceAttributesId` in response | `ephemeralResourceId` in response | +| **Bulk** | Multiple entities/resources in one `DecisionRequest` (cross-product) | Use `GetDecisionBulk` with explicit per-entity requests | + +**Migration steps:** + +1. Replace `STANDARD_ACTION_*` enums with action name strings (e.g., `"decrypt"`, `"transmit"`) +2. Move entity identification into `entityIdentifier.entityChain` and add `ephemeralId` to each entity +3. Move resource FQNs from `resourceAttributes[].attributeValueFqns` to `resource.attributeValues.fqns` +4. For multi-entity or multi-resource checks, switch from a single v1 `GetDecisions` call to v2 `GetDecisionBulk` +5. Update response handling: v2 uses `decision.decision` and `decision.ephemeralResourceId` instead of `entityChainId`/`resourceAttributesId` + +
+ --- ## GetDecision {#get-decision} @@ -722,6 +794,10 @@ Use `token` to let the platform resolve the entity from a JWT. This is a **top-l The `ephemeralId` is a correlation ID you assign — it appears in the response so you can match results to requests. +:::warning Validate JWTs at your enforcement layer +If your application acts as a Policy Enforcement Point (PEP) and receives JWT tokens from clients to pass to the platform via the `token` identifier, **validate the token yourself first**. Check the signature, expiry, issuer, and audience claims before forwarding it to the authorization service. Don't rely solely on the platform to reject invalid tokens — enforcing validation at your PEP layer follows the principle of least privilege and prevents forwarding tampered or expired tokens. +::: + For full details on all entity identifier options, see [EntityIdentifier](/sdks/authorization#entityidentifier) in the SDK reference. ---