From fb3a74dcb3796aef6a53dc2282cd99be367b2e7e Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 20 Mar 2026 14:55:57 -0700 Subject: [PATCH 1/7] feat(docs): add production deployment guide Two-part guide covering local development setup and production deployment of OpenTDF Platform. Based on real-world experience deploying the GLP-1 Tracker demo app to Railway. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/production-deployment-guide.md | 730 +++++++++++++++++++++ 1 file changed, 730 insertions(+) create mode 100644 docs/guides/production-deployment-guide.md diff --git a/docs/guides/production-deployment-guide.md b/docs/guides/production-deployment-guide.md new file mode 100644 index 00000000..ebffe595 --- /dev/null +++ b/docs/guides/production-deployment-guide.md @@ -0,0 +1,730 @@ +--- +title: "Building with OpenTDF: From Local Development to Production" +sidebar_position: 2 +--- + +# Building with OpenTDF: From Local Development to Production + +:::info What You'll Learn +This guide walks you through the full lifecycle of building an OpenTDF-powered application: +- **Part 1: Local Development** — Set up OpenTDF and build an application that encrypts data with attribute-based access control +- **Part 2: Production Deployment** — What changes when you deploy to a real environment + +We use a medical practice as a running example: a clinic where patients encrypt health data and only authorized staff can decrypt it. +::: + +## Part 1: Local Development + +### What You're Building + +```text +┌─────────────────────────────────────────────────┐ +│ Your Application │ +│ (uses OpenTDF SDK to encrypt/decrypt) │ +└──────────┬──────────────────────┬───────────────┘ + │ gRPC/HTTP │ OIDC + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ OpenTDF Platform │ │ Identity Provider │ +│ (single binary) │ │ (Keycloak) │ +│ │ │ │ +│ ┌───────────────┐ │ │ - User accounts │ +│ │ KAS │ │ │ - Roles & groups │ +│ │ Policy │ │ │ - JWT issuance │ +│ │ Authorization │ │ │ │ +│ │ Entity Res. │ │ └──────────────────────┘ +│ └───────────────┘ │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ PostgreSQL │ +│ (policy storage) │ +└─────────────────────┘ +``` + +OpenTDF Platform is a **single Go binary** that bundles KAS (Key Access Server), Policy, Authorization, and Entity Resolution services. It connects to PostgreSQL for storage and an OIDC provider (Keycloak) for authentication. + +### Prerequisites + +- Docker and Docker Compose +- Go 1.21+ (to run the platform from source — needed for provisioning) +- OpenSSL (for generating KAS encryption keys) +- The platform source code: `git clone https://github.com/opentdf/platform.git` + +:::warning Why do I need the source code? +The `provision keycloak` command that bootstraps your identity provider requires files only in the source tree. The container image does not include them. Until provisioning is available as a standalone tool, you need the source checkout. +::: + +### Step 1: Start PostgreSQL and Keycloak + +Generate TLS keys and start infrastructure: + +```bash +cd platform +./.github/scripts/init-temp-keys.sh +docker compose up -d +``` + +Wait for both to be healthy: +```bash +curl -sf http://localhost:8888/auth/realms/master | head -1 +docker compose exec opentdfdb pg_isready +``` + +:::tip Apple M4 users +Keycloak (Java) may crash with SIGILL. Set `export JAVA_OPTS_APPEND="-XX:UseSVE=0"` before running Docker. +::: + +### Step 2: Provision the Identity Provider + +:::danger Order matters +The platform **cannot start** until the OIDC realm exists. Provision Keycloak BEFORE starting the platform. +::: + +```bash +go run ./service provision keycloak +``` + +This creates the `opentdf` realm, clients (`opentdf`, `opentdf-sdk`, `tdf-entity-resolution`, `cli-client`), roles (`opentdf-admin`, `opentdf-standard`), and sample users. + +#### Create your application users + +```bash +# Get admin token +ADMIN_TOKEN=$(curl -sf -X POST http://localhost:8888/auth/realms/master/protocol/openid-connect/token \ + -d "grant_type=password" -d "client_id=admin-cli" \ + -d "username=admin" -d "password=changeme" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +# Create a user +curl -sf -X POST http://localhost:8888/auth/admin/realms/opentdf/users \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"username":"alice","enabled":true,"email":"alice@example.com","emailVerified":true}' + +# Set password +ALICE_ID=$(curl -sf "http://localhost:8888/auth/admin/realms/opentdf/users?username=alice" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") +curl -sf -X PUT "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/reset-password" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"type":"password","value":"alice123","temporary":false}' + +# Add opentdf-standard role (required for SDK access) +STD_ROLE_ID=$(curl -sf http://localhost:8888/auth/admin/realms/opentdf/roles/opentdf-standard \ + -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +curl -sf -o /dev/null -X POST "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/role-mappings/realm" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d "[{\"id\":\"$STD_ROLE_ID\",\"name\":\"opentdf-standard\"}]" +``` + +**Important**: All users need the `opentdf-standard` realm role, or the browser SDK will get `403` when listing KAS servers. Add it to `default-roles-opentdf` so new users get it automatically. + +#### Configure browser client + +For browser-based apps, update `cli-client` (the public client created by provisioning): + +In Keycloak admin → `opentdf` realm → Clients → `cli-client`: +- Valid redirect URIs: `http://localhost:*` +- Web origins: `+` + +### Step 3: Configure and Start the Platform + +Create `opentdf.yaml`: + +```yaml +logger: + level: info + type: text + output: stdout # Only 'stdout' is valid in the container image + +services: + kas: + registered_kas_uri: http://localhost:8080 + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: e1 + alg: ec:secp256r1 + legacy: true + - kid: r1 + alg: rsa:2048 + - kid: r1 + alg: rsa:2048 + legacy: true + entityresolution: + mode: claims # See "Understanding ERS Modes" below + url: http://localhost:8888/auth + clientid: "tdf-entity-resolution" + clientsecret: "secret" + realm: "opentdf" + legacykeycloak: true + inferid: + from: + email: true + username: true + +server: + tls: + enabled: false + auth: + enabled: true + enforceDPoP: false + audience: "http://localhost:8080" + issuer: http://localhost:8888/auth/realms/opentdf + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + cors: + enabled: true + allowedorigins: + - "http://localhost:5173" # Vite dev server + - "http://localhost:3000" + allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + exposedheaders: [Link] + allowcredentials: true + maxage: 3600 + grpc: + reflectionEnabled: true + cryptoProvider: + type: standard + standard: + keys: + - kid: r1 + alg: rsa:2048 + private: kas-private.pem + cert: kas-cert.pem + - kid: e1 + alg: ec:secp256r1 + private: kas-ec-private.pem + cert: kas-ec-cert.pem + port: 8080 +``` + +#### Understanding ERS Modes + +The Entity Resolution Service mode controls **what subject mapping selectors match against**. + +| Mode | Selectors target | Use | +|------|-----------------|-----| +| `claims` | JWT token claims directly | Simpler — `.preferred_username`, `.client_id`, `.realm_access.roles[]` | +| `keycloak` (default) | Keycloak Admin API user object | When matching Keycloak user attributes: `.attributes.department[]` | + +**Use `claims` mode** unless you specifically need Keycloak Admin API data. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax. + +:::warning .sub selector doesn't work in claims mode +The `.sub` JWT claim resolves correctly in `otdfctl dev selectors generate` but does NOT evaluate in subject mapping conditions. Use `.preferred_username` instead. This is a [known issue](https://github.com/opentdf/platform/issues/3188). +::: + +#### Start the platform + +```bash +go run ./service start +``` + +Verify: +```bash +curl -sf http://localhost:8080/healthz +# {"status":"SERVING"} +``` + +:::danger Don't run the platform in Docker for local dev +Running the platform in Docker alongside Keycloak causes split-horizon DNS — tokens from `localhost:8888` have different `iss` claims than tokens from `keycloak:8888`. Use `KC_HOSTNAME_URL` in production (see Part 2), or run the platform on the host for local dev. +::: + +### Step 4: Set Up Attributes and Policy + +The authorization chain has four links — **ALL must be in place** before decrypt works: + +```text +1. Attribute exists in policy DB +2. KAS is registered with keys and granted to the attribute +3. Subject mapping connects identity claims to the attribute +4. Encrypted data is tagged with the attribute +``` + +#### Create attributes + +```bash +# Create namespace (must be a valid hostname, no scheme) +otdfctl policy attributes namespaces create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --name "clinic.example" + +# Create attribute with values +otdfctl policy attributes create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --namespace \ + --name staff_role --rule ANY_OF \ + --value doctor --value nurse --value admin +``` + +#### Register KAS with keys + +```bash +# Register KAS +otdfctl policy kas-registry create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --uri http://localhost:8080 \ + --public-key-remote http://localhost:8080/kas/v2/kas_public_key \ + --name local-kas + +# Create KAS key (base64-encode the PEM) +KAS_PUB_B64=$(curl -sf http://localhost:8080/kas/v2/kas_public_key | python3 -c "import sys,json,base64; print(base64.b64encode(json.load(sys.stdin)['publicKey'].encode()).decode())") +otdfctl policy kas-registry key create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --kas --key-id "r1" --algorithm "rsa:2048" \ + --mode "public_key" --public-key-pem "$KAS_PUB_B64" + +# Assign key to attribute +otdfctl policy attributes key assign \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attribute --key-id +``` + +#### Create subject mappings + +One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. + +```bash +otdfctl policy subject-mappings create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attribute-value-id \ + --action read \ + --subject-condition-set-new '[{ + "condition_groups": [{ + "conditions": [{ + "subject_external_selector_value": ".preferred_username", + "operator": "SUBJECT_MAPPING_OPERATOR_ENUM_IN", + "subject_external_values": ["alice"] + }], + "boolean_operator": "CONDITION_BOOLEAN_TYPE_ENUM_AND" + }] + }]' +``` + +:::warning Duplicate subject mappings poison evaluation +If multiple mappings exist for the same attribute value and one has a non-matching condition, it can block other mappings from granting access. Always delete old mappings before creating replacements. See [issue #3190](https://github.com/opentdf/platform/issues/3190). +::: + +#### Validate with SDK discovery + +Before encrypting, use the SDK's discovery helpers to verify your policy is configured: + +```go +// Check attribute exists +exists, err := sdk.AttributeValueExists(ctx, "https://clinic.example/attr/staff_role/value/doctor") + +// Validate all FQNs before encrypting +err := sdk.ValidateAttributes(ctx, fqns...) +``` + +Note: `AttributeValueExists` currently returns an error (not `false`) for non-existent values. See [issue #3191](https://github.com/opentdf/platform/issues/3191). + +### Step 5: Integrate the SDK + +#### Go SDK + +```go +client, err := sdk.New( + "http://localhost:8080", // Must include http:// scheme + sdk.WithClientCredentials("opentdf-sdk", "secret", nil), + sdk.WithTokenEndpoint("http://localhost:8888/auth/realms/opentdf/protocol/openid-connect/token"), + sdk.WithInsecurePlaintextConn(), // Remove in production +) + +// Encrypt +var buf bytes.Buffer +_, err = client.CreateTDF(&buf, bytes.NewReader(plaintext), + sdk.WithDataAttributes("https://clinic.example/attr/staff_role/value/doctor"), + sdk.WithAutoconfigure(false), + sdk.WithKasInformation(sdk.KASInfo{URL: "http://localhost:8080"}), +) + +// Decrypt +reader, err := client.LoadTDF(bytes.NewReader(encryptedBlob)) +plaintext, err := io.ReadAll(reader) +``` + +#### JavaScript/TypeScript (Browser SDK) + +```typescript +import { AuthProviders, OpenTDF } from '@opentdf/sdk'; + +const authProvider = await AuthProviders.refreshAuthProvider({ + clientId: 'cli-client', // Must be a PUBLIC Keycloak client + exchange: 'refresh', + refreshToken: keycloak.refreshToken, + oidcOrigin: 'http://localhost:8888/auth/realms/opentdf', +}); + +const client = new OpenTDF({ + authProvider, + platformUrl: 'http://localhost:8080', +}); +await client.ready; + +// Decrypt +const decryptedStream = await client.read({ + source: { type: 'buffer', location: tdfBytes }, +}); +``` + +### Step 6: Test the Full Round-Trip + +```bash +# Encrypt +echo "secret data" | otdfctl encrypt \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attr "https://clinic.example/attr/staff_role/value/doctor" \ + -o test.tdf + +# Decrypt as a user with the right entitlement +otdfctl decrypt \ + --host http://localhost:8080 \ + --with-access-token "$USER_TOKEN" \ + test.tdf +``` + +If decrypt fails with "could not perform access", check all four links in the authorization chain (Step 4). + +### Local Development Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| Platform crashes: "failed to discover idp" | OIDC realm doesn't exist | Run `provision keycloak` BEFORE starting platform | +| SDK: "platform endpoint is malformed" | Missing `http://` scheme | Use `http://localhost:8080`, not `localhost:8080` | +| SDK: "unauthenticated" | Token endpoint unreachable | Use `WithTokenEndpoint()` to override Docker-internal URL | +| Container: "Config File not found" | Wrong mount path | Mount to `/home/nonroot/.opentdf/opentdf.yaml`, NOT `/app/` | +| Container: "invalid logger output: stderr" | Unsupported value | Use `output: stdout` | +| Browser SDK: "ListKeyAccessServers 403" | User missing role | Add `opentdf-standard` role to user | +| Encrypt works, decrypt 403 | Missing link in auth chain | Check: attribute exists, KAS registered + key assigned, subject mapping exists | +| `.sub` selector doesn't match | Known platform issue | Use `.preferred_username` instead | +| Duplicate mappings break access | Evaluation poisoning | Delete old mappings, create fresh | + +## Part 2: Production Deployment + +Everything from Part 1 applies. This section covers what **changes** for production. + +### Key Differences from Local + +| Concern | Local Dev | Production | +|---------|-----------|------------| +| Database | Docker PostgreSQL, `changeme` | Managed PostgreSQL with SSL (`sslmode: require`) | +| TLS | Disabled | CA-signed certificates (or terminate at load balancer) | +| DPoP | `enforceDPoP: false` | `enforceDPoP: true` (proof-of-possession on tokens). Requires SDK and IdP support — verify both your browser SDK and backend SDK send DPoP proofs before enabling. | +| Identity Provider | Local Keycloak, default passwords | Production OIDC with strong secrets | +| KAS Keys | Generated locally in repo | Secrets manager or env vars, never committed | +| CORS | `allowedorigins: ["http://localhost:5173"]` | Specific frontend domain | +| Logging | `level: info`, `type: text` | `level: info`, `type: json` | +| gRPC Reflection | Enabled | Disabled | +| SDK Connection | `WithInsecurePlaintextConn()` | Remove — use TLS | +| Credentials | Hardcoded `secret` | Environment variables | +| Logger output | `stdout` | `stdout` (only valid value in container) | +| Keycloak | `start-dev` mode | `start` production mode | + +### Solving Split-Horizon DNS + +The #1 architectural issue for containerized deployment. When the platform and Keycloak run in separate containers, they reach each other via internal hostnames (`keycloak:8888`) but external clients use public URLs (`https://keycloak.example.com`). JWT tokens have different `iss` claims depending on which URL was used. + +**Solution**: Set `KC_HOSTNAME_URL` in Keycloak to the public URL. This forces all tokens to use the same issuer regardless of how Keycloak is accessed: + +```bash +KC_HOSTNAME_URL=https://keycloak.example.com/auth +``` + +### Keycloak Production Setup + +#### Provisioning against a remote Keycloak + +```bash +# Stop local Keycloak first — provisioning defaults to localhost:8888 +docker compose down + +# Use -e flag to target remote Keycloak, -p for admin password +go run ./service provision keycloak \ + -e https://keycloak.example.com/auth \ + -u admin \ + -p +``` + +:::warning Provisioning gotchas +- The admin token expires in **60 seconds** — provisioning handles this, but some clients may be created while others aren't. Verify all expected clients exist. +- The provisioned `opentdf` client's audience defaults to `http://localhost:8080`. The platform's `auth.audience` config must match this, or you need to add an audience mapper. +::: + +#### Required Keycloak fixes for production + +**Disable VERIFY_PROFILE**: Keycloak 25 requires a `kc.org` attribute that blocks login. Disable it: +```bash +# Via API +curl -X PUT "/admin/realms/opentdf/authentication/required-actions/VERIFY_PROFILE" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"alias":"VERIFY_PROFILE","enabled":false,"defaultAction":false,"priority":90}' +``` + +**Add audience mapper to `cli-client`**: Browser tokens lack the platform audience by default. +1. Keycloak admin → `opentdf` realm → Clients → `cli-client` → Client scopes +2. Click `cli-client-dedicated` → Add mapper → By configuration → **Audience** +3. Included Custom Audience: must match your platform's `auth.audience` config. If you used the default provisioning, this is `http://localhost:8080` (see the note in "Config in Production" for why and how to change it). +4. Add to access token: ON + +**Add `opentdf-standard` to default roles**: So every new user automatically gets SDK access. +1. Realm roles → `default-roles-opentdf` → Associated roles → Add `opentdf-standard` + +#### Missing clients after provisioning + +The 60-second token can cause provisioning to exit early. Verify these exist: +- `opentdf` — admin client +- `opentdf-sdk` — SDK client with `opentdf-standard` role +- `tdf-entity-resolution` — ERS client with `view-users`, `query-users` client roles +- `cli-client` — public client for browser auth + +### KAS Keys in Production + +Never commit private keys to a repository. Options: + +1. **Bake into Docker image** (simplest for demos): Copy keys into a custom Dockerfile +2. **Environment variables**: Base64-encode PEM files, decode at startup +3. **Secrets manager**: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault +4. **Volume mounts**: If your platform supports file mounts + +Generate keys: +```bash +openssl genpkey -algorithm RSA -out kas-private.pem -pkeyopt rsa_keygen_bits:2048 +openssl rsa -in kas-private.pem -pubout -out kas-cert.pem +openssl ecparam -name prime256v1 -out ecparams.tmp +openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=KAS" \ + -keyout kas-ec-private.pem -out kas-ec-cert.pem -days 365 +rm ecparams.tmp +``` + +### Container Registry + +The official image at `registry.opentdf.io/platform:latest` may not be accessible from all cloud providers (Railway returns 403 despite the registry being public). Workaround: mirror to GHCR or Docker Hub: + +```bash +docker pull --platform linux/amd64 registry.opentdf.io/platform:latest +docker tag registry.opentdf.io/platform:latest ghcr.io//opentdf-platform:latest +docker push ghcr.io//opentdf-platform:latest +``` + +**Important**: Specify `--platform linux/amd64` when pulling on ARM Macs — otherwise the image won't run on x86 cloud hosts. + +### Config in Production + +The platform reads config from `/home/nonroot/.opentdf/opentdf.yaml` in the container. For platforms that don't support file mounts (like [Railway](https://railway.app)), bake the config into a custom Docker image: + +```dockerfile +FROM --platform=linux/amd64 ghcr.io//opentdf-platform:latest +COPY opentdf.yaml /home/nonroot/.opentdf/opentdf.yaml +COPY keys/ /keys/ +CMD ["start"] +``` + +Parameterize all deployment-specific values with environment variables, and harden the settings that Part 1 left in dev mode: + +```yaml +logger: + level: info + type: json # Structured logs for production + output: stdout + +db: + host: "${OPENTDF_DB_HOST}" + port: 5432 + database: "${OPENTDF_DB_DATABASE}" + user: postgres + password: "${OPENTDF_DB_PASSWORD}" + sslmode: require # Always use SSL for managed databases + +services: + kas: + registered_kas_uri: "${OPENTDF_KAS_URI}" + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: e1 + alg: ec:secp256r1 + legacy: true + - kid: r1 + alg: rsa:2048 + - kid: r1 + alg: rsa:2048 + legacy: true + entityresolution: + mode: claims + url: "${OPENTDF_KEYCLOAK_URL}" + clientid: "tdf-entity-resolution" + clientsecret: "${OPENTDF_ERS_SECRET}" + realm: "opentdf" + legacykeycloak: true + inferid: + from: + email: true + username: true + +server: + tls: + enabled: true # Enable TLS (or terminate at load balancer) + auth: + enabled: true + enforceDPoP: false # Set to true if your SDKs and IdP support DPoP + audience: "http://localhost:8080" # Must match provisioned audience (see note below) + issuer: "${OPENTDF_KEYCLOAK_URL}/realms/opentdf" + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + cors: + enabled: true + allowedorigins: + - "${OPENTDF_CORS_ORIGIN}" # Specific frontend domain, NOT "*" + allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + exposedheaders: [Link] + allowcredentials: true + maxage: 3600 + grpc: + reflectionEnabled: false # Disable in production + cryptoProvider: + type: standard + standard: + keys: + - kid: r1 + alg: rsa:2048 + private: /keys/kas-private.pem + cert: /keys/kas-cert.pem + - kid: e1 + alg: ec:secp256r1 + private: /keys/kas-ec-private.pem + cert: /keys/kas-ec-cert.pem + port: 8080 +``` + +:::tip About `audience: "http://localhost:8080"` +This looks wrong for production, but it works because `provision keycloak` hardcodes this audience in the `opentdf` client. The audience is a token validation string — the platform checks that the JWT's `aud` claim matches this value. It doesn't connect to this URL. + +You *can* change it to your actual domain (e.g., `https://platform.mydomain.com`). Just make sure all three places agree: +1. The `opentdf` client's audience in Keycloak +2. The `cli-client` audience mapper's "Included Custom Audience" value +3. The platform's `auth.audience` config + +If you used the default provisioning and didn't change the Keycloak client, keep it as `http://localhost:8080`. +::: + +:::tip TLS termination +If your platform (Railway, AWS ALB, etc.) terminates TLS at the load balancer, set `tls.enabled: false` and let the proxy handle certificates. The platform will receive plain HTTP on its internal port. +::: + +**Note**: If both YAML values and `OPENTDF_` env vars are set, YAML wins. Use env var references in the YAML (`${VAR}`) or remove the YAML values entirely and rely solely on env vars. + +### SDK Changes for Production + +The Part 1 SDK examples use dev-only settings. Here's what to change: + +#### Go SDK + +```go +client, err := sdk.New( + "https://platform.example.com", // Use HTTPS + sdk.WithClientCredentials("opentdf-sdk", os.Getenv("CLIENT_SECRET"), nil), + sdk.WithTokenEndpoint("https://keycloak.example.com/auth/realms/opentdf/protocol/openid-connect/token"), + // No WithInsecurePlaintextConn() — TLS is required +) +``` + +Remove `sdk.WithInsecurePlaintextConn()` — this disables TLS and must never be used in production. + +#### JavaScript/TypeScript (Browser SDK) + +```typescript +const authProvider = await AuthProviders.refreshAuthProvider({ + clientId: 'cli-client', + exchange: 'refresh', + refreshToken: keycloak.refreshToken, + oidcOrigin: 'https://keycloak.example.com/auth/realms/opentdf', +}); + +const client = new OpenTDF({ + authProvider, + platformUrl: 'https://platform.example.com', +}); +``` + +All URLs must use `https://`. The browser SDK doesn't have an insecure mode flag — it just needs HTTPS URLs and a valid Keycloak token. + +### Production Troubleshooting + +All local troubleshooting applies, plus: + +| Error | Cause | Fix | +|-------|-------|-----| +| "Account is not fully set up" | `VERIFY_PROFILE` enabled | Disable it (see above) | +| Token audience mismatch (401) | Browser token missing platform audience | Add audience mapper to `cli-client` | +| "Exec format error" in container | Wrong CPU architecture | Use `--platform=linux/amd64` in Dockerfile | +| `provision keycloak` provisions wrong instance | Local Keycloak still running | Stop local first, use `-e` flag | +| Admin password doesn't work | Changed env var after first boot | Reset in Keycloak admin console — env var only works on first boot | +| Container can't pull `registry.opentdf.io` | Cloud provider blocks custom registries | Mirror to GHCR/Docker Hub | +| YAML config ignored, uses defaults | Config not at expected path | Mount to `/home/nonroot/.opentdf/opentdf.yaml` | +| `AttributeValueExists` errors on new values | SDK bug | Treat error as "not found" — [issue #3191](https://github.com/opentdf/platform/issues/3191) | + +### Advanced: Per-Field Encryption for Granular Sharing + +Instead of encrypting an entire record with one attribute, encrypt each data category separately. This enables granular sharing — share weight without sharing symptoms. + +#### Compound attribute values + +Encode both user identity and data category in a single attribute value: + +```text +Attribute: patient_access (rule: ANY_OF) +Values: _ + +Example FQNs: + https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_weight + https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_symptoms +``` + +This provides per-user isolation AND per-category granularity in a single attribute. + +#### Server-side vs client-side decrypt + +For true per-user cryptographic enforcement, the **browser** must decrypt using the user's own Keycloak token. The backend should only encrypt — never decrypt. This ensures the KAS checks each user's entitlements individually. + +For more on this pattern, see the [Subject Mapping Guide](/guides/subject-mapping-guide) and the [GLP-1 Tracker demo](https://github.com/opentdf/demo-glp1-tracker). + +### Next Steps + +- **Monitoring**: Add `server.trace` config with an OTLP endpoint for OpenTelemetry tracing +- **Key Rotation**: Add new keys with different `kid` values, update KAS grants +- **Scaling**: Multiple platform instances behind a load balancer (stateless — state lives in PostgreSQL) +- **Backup**: Regularly back up PostgreSQL — it contains all your policy configuration +- **Reference**: Read [opentdf.io/llms.txt](https://opentdf.io/llms.txt) for comprehensive LLM-optimized documentation From d8545d96a7f09291a48e8820bccfe11195cdc714 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 20 Mar 2026 15:10:19 -0700 Subject: [PATCH 2/7] fix(docs): address review feedback on production guide - Rename to .mdx per repo convention - Add comment explaining duplicate kid entries (current + legacy) - Note that otdfctl commands output IDs needed for subsequent steps - Add TOKEN acquisition step to VERIFY_PROFILE example Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ent-guide.md => production-deployment-guide.mdx} | 12 +++++++++--- static/img/filecontents.svg | 13 ------------- 2 files changed, 9 insertions(+), 16 deletions(-) rename docs/guides/{production-deployment-guide.md => production-deployment-guide.mdx} (97%) delete mode 100644 static/img/filecontents.svg diff --git a/docs/guides/production-deployment-guide.md b/docs/guides/production-deployment-guide.mdx similarity index 97% rename from docs/guides/production-deployment-guide.md rename to docs/guides/production-deployment-guide.mdx index ebffe595..2ef7466b 100644 --- a/docs/guides/production-deployment-guide.md +++ b/docs/guides/production-deployment-guide.mdx @@ -139,7 +139,7 @@ logger: services: kas: registered_kas_uri: http://localhost:8080 - keyring: + keyring: # Each kid appears twice: current + legacy for backward compatibility - kid: e1 alg: ec:secp256r1 - kid: e1 @@ -255,6 +255,8 @@ The authorization chain has four links — **ALL must be in place** before decry #### Create attributes +Each `otdfctl` command below outputs an ID. Copy it — you'll need it in the next command. + ```bash # Create namespace (must be a valid hostname, no scheme) otdfctl policy attributes namespaces create \ @@ -474,7 +476,11 @@ go run ./service provision keycloak \ **Disable VERIFY_PROFILE**: Keycloak 25 requires a `kc.org` attribute that blocks login. Disable it: ```bash -# Via API +# Get admin token (replace and with your values) +TOKEN=$(curl -sf -X POST "/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password" -d "client_id=admin-cli" \ + -d "username=admin" -d "password=" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + curl -X PUT "/admin/realms/opentdf/authentication/required-actions/VERIFY_PROFILE" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"alias":"VERIFY_PROFILE","enabled":false,"defaultAction":false,"priority":90}' @@ -558,7 +564,7 @@ db: services: kas: registered_kas_uri: "${OPENTDF_KAS_URI}" - keyring: + keyring: # Each kid appears twice: current + legacy for backward compatibility - kid: e1 alg: ec:secp256r1 - kid: e1 diff --git a/static/img/filecontents.svg b/static/img/filecontents.svg deleted file mode 100644 index 6059a070..00000000 --- a/static/img/filecontents.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - my_document.ext.tdf (Zip) - - - - manifest.json - - - - 0.payload (Encrypted) - \ No newline at end of file From 15dc1ef8155c5b44c0d8f362945247c556353137 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Wed, 18 Mar 2026 10:19:29 -0700 Subject: [PATCH 3/7] fix(ci): use GITHUB_TOKEN and handle errors in check-vendored-yaml The check-vendored-yaml script hits the GitHub Contents API without authentication, causing rate-limit failures (403) in CI. The error manifested as a cryptic "contents is not iterable" TypeError because fetchJson didn't check HTTP status codes. Changes: - Use GITHUB_TOKEN env var for authenticated API requests (already passed by CI workflows, raises rate limit from 60 to 5000 req/hr) - Add HTTP status code checking in fetchJson with clear error messages - Validate that Contents API response is an array before iterating Co-Authored-By: Claude Opus 4.6 (1M context) --- src/openapi/check-vendored-yaml.ts | 31 ++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/openapi/check-vendored-yaml.ts b/src/openapi/check-vendored-yaml.ts index 285f21fd..0c332454 100644 --- a/src/openapi/check-vendored-yaml.ts +++ b/src/openapi/check-vendored-yaml.ts @@ -8,6 +8,7 @@ import { openApiSpecsArray } from './preprocessing'; const PLATFORM_API_BASE = 'https://api.github.com/repos/opentdf/platform'; const PLATFORM_RAW_BASE = 'https://raw.githubusercontent.com/opentdf/platform/refs/heads/main'; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; function fileHash(filePath: string): string { if (!fs.existsSync(filePath)) return ''; @@ -39,7 +40,23 @@ function downloadFile(url: string, dest: string): Promise { function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { import('https').then(https => { - https.get(url, { headers: { 'User-Agent': 'opentdf-docs-check-vendored-yaml' } } as any, (response: any) => { + const headers: Record = { 'User-Agent': 'opentdf-docs-check-vendored-yaml' }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `token ${GITHUB_TOKEN}`; + } + https.get(url, { headers } as any, (response: any) => { + if (response.statusCode !== 200) { + let body = ''; + response.on('data', (chunk: string) => { body += chunk; }); + response.on('end', () => { + reject(new Error( + `GitHub API request failed: ${url}\n` + + ` Status: ${response.statusCode}\n` + + ` Response: ${body.slice(0, 200)}` + )); + }); + return; + } let data = ''; response.on('data', (chunk: string) => { data += chunk; }); response.on('end', () => { @@ -54,7 +71,11 @@ function fetchJson(url: string): Promise { function fetchText(url: string): Promise { return new Promise((resolve, reject) => { import('https').then(https => { - https.get(url, { headers: { 'User-Agent': 'opentdf-docs-check-vendored-yaml' } } as any, (response: any) => { + const headers: Record = { 'User-Agent': 'opentdf-docs-check-vendored-yaml' }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `token ${GITHUB_TOKEN}`; + } + https.get(url, { headers } as any, (response: any) => { let data = ''; response.on('data', (chunk: string) => { data += chunk; }); response.on('end', () => resolve(data)); @@ -70,6 +91,12 @@ async function fetchRemoteSpecPaths(dirPath = 'docs/openapi'): Promise const specPaths: string[] = []; const contents = await fetchJson(`${PLATFORM_API_BASE}/contents/${dirPath}`); + if (!Array.isArray(contents)) { + throw new Error( + `Expected array from GitHub Contents API for ${dirPath}, got: ${JSON.stringify(contents).slice(0, 200)}` + ); + } + for (const item of contents) { if (item.type === 'file' && item.name.endsWith('.yaml')) { specPaths.push(item.path); From 5cfa3cf703e4e9823f53df4ccb38c3d76a650f1c Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 20 Mar 2026 15:16:59 -0700 Subject: [PATCH 4/7] docs: collapse long code blocks into details/summary elements Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/production-deployment-guide.mdx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/guides/production-deployment-guide.mdx b/docs/guides/production-deployment-guide.mdx index 2ef7466b..973ac6b7 100644 --- a/docs/guides/production-deployment-guide.mdx +++ b/docs/guides/production-deployment-guide.mdx @@ -90,6 +90,9 @@ This creates the `opentdf` realm, clients (`opentdf`, `opentdf-sdk`, `tdf-entity #### Create your application users +
+Keycloak user creation commands + ```bash # Get admin token ADMIN_TOKEN=$(curl -sf -X POST http://localhost:8888/auth/realms/master/protocol/openid-connect/token \ @@ -116,6 +119,8 @@ curl -sf -o /dev/null -X POST "http://localhost:8888/auth/admin/realms/opentdf/u -d "[{\"id\":\"$STD_ROLE_ID\",\"name\":\"opentdf-standard\"}]" ``` +
+ **Important**: All users need the `opentdf-standard` realm role, or the browser SDK will get `403` when listing KAS servers. Add it to `default-roles-opentdf` so new users get it automatically. #### Configure browser client @@ -130,6 +135,9 @@ In Keycloak admin → `opentdf` realm → Clients → `cli-client`: Create `opentdf.yaml`: +
+Full opentdf.yaml for local development + ```yaml logger: level: info @@ -211,6 +219,8 @@ server: port: 8080 ``` +
+ #### Understanding ERS Modes The Entity Resolution Service mode controls **what subject mapping selectors match against**. @@ -275,6 +285,9 @@ otdfctl policy attributes create \ #### Register KAS with keys +
+KAS registration and key assignment commands + ```bash # Register KAS otdfctl policy kas-registry create \ @@ -299,6 +312,8 @@ otdfctl policy attributes key assign \ --attribute --key-id ``` +
+ #### Create subject mappings One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. @@ -547,6 +562,9 @@ CMD ["start"] Parameterize all deployment-specific values with environment variables, and harden the settings that Part 1 left in dev mode: +
+Full production opentdf.yaml + ```yaml logger: level: info @@ -635,6 +653,8 @@ server: port: 8080 ``` +
+ :::tip About `audience: "http://localhost:8080"` This looks wrong for production, but it works because `provision keycloak` hardcodes this audience in the `opentdf` client. The audience is a token validation string — the platform checks that the JWT's `aud` claim matches this value. It doesn't connect to this URL. From cdfeacc191558cd609088b998c036f16b6217bfa Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 20 Mar 2026 15:30:40 -0700 Subject: [PATCH 5/7] docs: collapse long sections, clarify TLS keys vs KAS keys Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/production-deployment-guide.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/production-deployment-guide.mdx b/docs/guides/production-deployment-guide.mdx index 973ac6b7..6e2347e0 100644 --- a/docs/guides/production-deployment-guide.mdx +++ b/docs/guides/production-deployment-guide.mdx @@ -58,7 +58,7 @@ The `provision keycloak` command that bootstraps your identity provider requires ### Step 1: Start PostgreSQL and Keycloak -Generate TLS keys and start infrastructure: +Generate self-signed TLS certificates (used for HTTPS between platform services locally — not the KAS encryption keys, which are separate) and start infrastructure: ```bash cd platform From c7c3598a48f67f0080e90b34d59c909aa3f5bdd5 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Fri, 20 Mar 2026 15:51:17 -0700 Subject: [PATCH 6/7] docs: split production guide into subpages with index - Index page introduces the guide scope and links to both parts - Part 1: Local Development (setup, Keycloak, attributes, SDK integration) - Part 2: Production Deployment (hardening, DNS, containers, config) - Add discussion forum links for more examples - Clarify Go SDK is encrypt-only, browser SDK handles decrypt - Add step-by-step for assigning opentdf-standard to default roles - Fix Go version to link to go.mod instead of hardcoding - Fix VERIFY_PROFILE description accuracy Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/production-deployment-guide.mdx | 756 ------------------ .../production-deployment-guide/index.mdx | 56 ++ .../local-development.mdx | 401 ++++++++++ .../production-deployment.mdx | 325 ++++++++ 4 files changed, 782 insertions(+), 756 deletions(-) delete mode 100644 docs/guides/production-deployment-guide.mdx create mode 100644 docs/guides/production-deployment-guide/index.mdx create mode 100644 docs/guides/production-deployment-guide/local-development.mdx create mode 100644 docs/guides/production-deployment-guide/production-deployment.mdx diff --git a/docs/guides/production-deployment-guide.mdx b/docs/guides/production-deployment-guide.mdx deleted file mode 100644 index 6e2347e0..00000000 --- a/docs/guides/production-deployment-guide.mdx +++ /dev/null @@ -1,756 +0,0 @@ ---- -title: "Building with OpenTDF: From Local Development to Production" -sidebar_position: 2 ---- - -# Building with OpenTDF: From Local Development to Production - -:::info What You'll Learn -This guide walks you through the full lifecycle of building an OpenTDF-powered application: -- **Part 1: Local Development** — Set up OpenTDF and build an application that encrypts data with attribute-based access control -- **Part 2: Production Deployment** — What changes when you deploy to a real environment - -We use a medical practice as a running example: a clinic where patients encrypt health data and only authorized staff can decrypt it. -::: - -## Part 1: Local Development - -### What You're Building - -```text -┌─────────────────────────────────────────────────┐ -│ Your Application │ -│ (uses OpenTDF SDK to encrypt/decrypt) │ -└──────────┬──────────────────────┬───────────────┘ - │ gRPC/HTTP │ OIDC - ▼ ▼ -┌─────────────────────┐ ┌──────────────────────┐ -│ OpenTDF Platform │ │ Identity Provider │ -│ (single binary) │ │ (Keycloak) │ -│ │ │ │ -│ ┌───────────────┐ │ │ - User accounts │ -│ │ KAS │ │ │ - Roles & groups │ -│ │ Policy │ │ │ - JWT issuance │ -│ │ Authorization │ │ │ │ -│ │ Entity Res. │ │ └──────────────────────┘ -│ └───────────────┘ │ -└──────────┬──────────┘ - │ - ▼ -┌─────────────────────┐ -│ PostgreSQL │ -│ (policy storage) │ -└─────────────────────┘ -``` - -OpenTDF Platform is a **single Go binary** that bundles KAS (Key Access Server), Policy, Authorization, and Entity Resolution services. It connects to PostgreSQL for storage and an OIDC provider (Keycloak) for authentication. - -### Prerequisites - -- Docker and Docker Compose -- Go 1.21+ (to run the platform from source — needed for provisioning) -- OpenSSL (for generating KAS encryption keys) -- The platform source code: `git clone https://github.com/opentdf/platform.git` - -:::warning Why do I need the source code? -The `provision keycloak` command that bootstraps your identity provider requires files only in the source tree. The container image does not include them. Until provisioning is available as a standalone tool, you need the source checkout. -::: - -### Step 1: Start PostgreSQL and Keycloak - -Generate self-signed TLS certificates (used for HTTPS between platform services locally — not the KAS encryption keys, which are separate) and start infrastructure: - -```bash -cd platform -./.github/scripts/init-temp-keys.sh -docker compose up -d -``` - -Wait for both to be healthy: -```bash -curl -sf http://localhost:8888/auth/realms/master | head -1 -docker compose exec opentdfdb pg_isready -``` - -:::tip Apple M4 users -Keycloak (Java) may crash with SIGILL. Set `export JAVA_OPTS_APPEND="-XX:UseSVE=0"` before running Docker. -::: - -### Step 2: Provision the Identity Provider - -:::danger Order matters -The platform **cannot start** until the OIDC realm exists. Provision Keycloak BEFORE starting the platform. -::: - -```bash -go run ./service provision keycloak -``` - -This creates the `opentdf` realm, clients (`opentdf`, `opentdf-sdk`, `tdf-entity-resolution`, `cli-client`), roles (`opentdf-admin`, `opentdf-standard`), and sample users. - -#### Create your application users - -
-Keycloak user creation commands - -```bash -# Get admin token -ADMIN_TOKEN=$(curl -sf -X POST http://localhost:8888/auth/realms/master/protocol/openid-connect/token \ - -d "grant_type=password" -d "client_id=admin-cli" \ - -d "username=admin" -d "password=changeme" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -# Create a user -curl -sf -X POST http://localhost:8888/auth/admin/realms/opentdf/users \ - -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ - -d '{"username":"alice","enabled":true,"email":"alice@example.com","emailVerified":true}' - -# Set password -ALICE_ID=$(curl -sf "http://localhost:8888/auth/admin/realms/opentdf/users?username=alice" \ - -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") -curl -sf -X PUT "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/reset-password" \ - -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ - -d '{"type":"password","value":"alice123","temporary":false}' - -# Add opentdf-standard role (required for SDK access) -STD_ROLE_ID=$(curl -sf http://localhost:8888/auth/admin/realms/opentdf/roles/opentdf-standard \ - -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") -curl -sf -o /dev/null -X POST "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/role-mappings/realm" \ - -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ - -d "[{\"id\":\"$STD_ROLE_ID\",\"name\":\"opentdf-standard\"}]" -``` - -
- -**Important**: All users need the `opentdf-standard` realm role, or the browser SDK will get `403` when listing KAS servers. Add it to `default-roles-opentdf` so new users get it automatically. - -#### Configure browser client - -For browser-based apps, update `cli-client` (the public client created by provisioning): - -In Keycloak admin → `opentdf` realm → Clients → `cli-client`: -- Valid redirect URIs: `http://localhost:*` -- Web origins: `+` - -### Step 3: Configure and Start the Platform - -Create `opentdf.yaml`: - -
-Full opentdf.yaml for local development - -```yaml -logger: - level: info - type: text - output: stdout # Only 'stdout' is valid in the container image - -services: - kas: - registered_kas_uri: http://localhost:8080 - keyring: # Each kid appears twice: current + legacy for backward compatibility - - kid: e1 - alg: ec:secp256r1 - - kid: e1 - alg: ec:secp256r1 - legacy: true - - kid: r1 - alg: rsa:2048 - - kid: r1 - alg: rsa:2048 - legacy: true - entityresolution: - mode: claims # See "Understanding ERS Modes" below - url: http://localhost:8888/auth - clientid: "tdf-entity-resolution" - clientsecret: "secret" - realm: "opentdf" - legacykeycloak: true - inferid: - from: - email: true - username: true - -server: - tls: - enabled: false - auth: - enabled: true - enforceDPoP: false - audience: "http://localhost:8080" - issuer: http://localhost:8888/auth/realms/opentdf - policy: - extension: | - g, opentdf-admin, role:admin - g, opentdf-standard, role:standard - cors: - enabled: true - allowedorigins: - - "http://localhost:5173" # Vite dev server - - "http://localhost:3000" - allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] - allowedheaders: - - Accept - - Accept-Encoding - - Authorization - - Connect-Protocol-Version - - Content-Length - - Content-Type - - Dpop - - X-CSRF-Token - - X-Requested-With - - X-Rewrap-Additional-Context - exposedheaders: [Link] - allowcredentials: true - maxage: 3600 - grpc: - reflectionEnabled: true - cryptoProvider: - type: standard - standard: - keys: - - kid: r1 - alg: rsa:2048 - private: kas-private.pem - cert: kas-cert.pem - - kid: e1 - alg: ec:secp256r1 - private: kas-ec-private.pem - cert: kas-ec-cert.pem - port: 8080 -``` - -
- -#### Understanding ERS Modes - -The Entity Resolution Service mode controls **what subject mapping selectors match against**. - -| Mode | Selectors target | Use | -|------|-----------------|-----| -| `claims` | JWT token claims directly | Simpler — `.preferred_username`, `.client_id`, `.realm_access.roles[]` | -| `keycloak` (default) | Keycloak Admin API user object | When matching Keycloak user attributes: `.attributes.department[]` | - -**Use `claims` mode** unless you specifically need Keycloak Admin API data. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax. - -:::warning .sub selector doesn't work in claims mode -The `.sub` JWT claim resolves correctly in `otdfctl dev selectors generate` but does NOT evaluate in subject mapping conditions. Use `.preferred_username` instead. This is a [known issue](https://github.com/opentdf/platform/issues/3188). -::: - -#### Start the platform - -```bash -go run ./service start -``` - -Verify: -```bash -curl -sf http://localhost:8080/healthz -# {"status":"SERVING"} -``` - -:::danger Don't run the platform in Docker for local dev -Running the platform in Docker alongside Keycloak causes split-horizon DNS — tokens from `localhost:8888` have different `iss` claims than tokens from `keycloak:8888`. Use `KC_HOSTNAME_URL` in production (see Part 2), or run the platform on the host for local dev. -::: - -### Step 4: Set Up Attributes and Policy - -The authorization chain has four links — **ALL must be in place** before decrypt works: - -```text -1. Attribute exists in policy DB -2. KAS is registered with keys and granted to the attribute -3. Subject mapping connects identity claims to the attribute -4. Encrypted data is tagged with the attribute -``` - -#### Create attributes - -Each `otdfctl` command below outputs an ID. Copy it — you'll need it in the next command. - -```bash -# Create namespace (must be a valid hostname, no scheme) -otdfctl policy attributes namespaces create \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --name "clinic.example" - -# Create attribute with values -otdfctl policy attributes create \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --namespace \ - --name staff_role --rule ANY_OF \ - --value doctor --value nurse --value admin -``` - -#### Register KAS with keys - -
-KAS registration and key assignment commands - -```bash -# Register KAS -otdfctl policy kas-registry create \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --uri http://localhost:8080 \ - --public-key-remote http://localhost:8080/kas/v2/kas_public_key \ - --name local-kas - -# Create KAS key (base64-encode the PEM) -KAS_PUB_B64=$(curl -sf http://localhost:8080/kas/v2/kas_public_key | python3 -c "import sys,json,base64; print(base64.b64encode(json.load(sys.stdin)['publicKey'].encode()).decode())") -otdfctl policy kas-registry key create \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --kas --key-id "r1" --algorithm "rsa:2048" \ - --mode "public_key" --public-key-pem "$KAS_PUB_B64" - -# Assign key to attribute -otdfctl policy attributes key assign \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --attribute --key-id -``` - -
- -#### Create subject mappings - -One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. - -```bash -otdfctl policy subject-mappings create \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --attribute-value-id \ - --action read \ - --subject-condition-set-new '[{ - "condition_groups": [{ - "conditions": [{ - "subject_external_selector_value": ".preferred_username", - "operator": "SUBJECT_MAPPING_OPERATOR_ENUM_IN", - "subject_external_values": ["alice"] - }], - "boolean_operator": "CONDITION_BOOLEAN_TYPE_ENUM_AND" - }] - }]' -``` - -:::warning Duplicate subject mappings poison evaluation -If multiple mappings exist for the same attribute value and one has a non-matching condition, it can block other mappings from granting access. Always delete old mappings before creating replacements. See [issue #3190](https://github.com/opentdf/platform/issues/3190). -::: - -#### Validate with SDK discovery - -Before encrypting, use the SDK's discovery helpers to verify your policy is configured: - -```go -// Check attribute exists -exists, err := sdk.AttributeValueExists(ctx, "https://clinic.example/attr/staff_role/value/doctor") - -// Validate all FQNs before encrypting -err := sdk.ValidateAttributes(ctx, fqns...) -``` - -Note: `AttributeValueExists` currently returns an error (not `false`) for non-existent values. See [issue #3191](https://github.com/opentdf/platform/issues/3191). - -### Step 5: Integrate the SDK - -#### Go SDK - -```go -client, err := sdk.New( - "http://localhost:8080", // Must include http:// scheme - sdk.WithClientCredentials("opentdf-sdk", "secret", nil), - sdk.WithTokenEndpoint("http://localhost:8888/auth/realms/opentdf/protocol/openid-connect/token"), - sdk.WithInsecurePlaintextConn(), // Remove in production -) - -// Encrypt -var buf bytes.Buffer -_, err = client.CreateTDF(&buf, bytes.NewReader(plaintext), - sdk.WithDataAttributes("https://clinic.example/attr/staff_role/value/doctor"), - sdk.WithAutoconfigure(false), - sdk.WithKasInformation(sdk.KASInfo{URL: "http://localhost:8080"}), -) - -// Decrypt -reader, err := client.LoadTDF(bytes.NewReader(encryptedBlob)) -plaintext, err := io.ReadAll(reader) -``` - -#### JavaScript/TypeScript (Browser SDK) - -```typescript -import { AuthProviders, OpenTDF } from '@opentdf/sdk'; - -const authProvider = await AuthProviders.refreshAuthProvider({ - clientId: 'cli-client', // Must be a PUBLIC Keycloak client - exchange: 'refresh', - refreshToken: keycloak.refreshToken, - oidcOrigin: 'http://localhost:8888/auth/realms/opentdf', -}); - -const client = new OpenTDF({ - authProvider, - platformUrl: 'http://localhost:8080', -}); -await client.ready; - -// Decrypt -const decryptedStream = await client.read({ - source: { type: 'buffer', location: tdfBytes }, -}); -``` - -### Step 6: Test the Full Round-Trip - -```bash -# Encrypt -echo "secret data" | otdfctl encrypt \ - --host http://localhost:8080 \ - --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ - --attr "https://clinic.example/attr/staff_role/value/doctor" \ - -o test.tdf - -# Decrypt as a user with the right entitlement -otdfctl decrypt \ - --host http://localhost:8080 \ - --with-access-token "$USER_TOKEN" \ - test.tdf -``` - -If decrypt fails with "could not perform access", check all four links in the authorization chain (Step 4). - -### Local Development Troubleshooting - -| Error | Cause | Fix | -|-------|-------|-----| -| Platform crashes: "failed to discover idp" | OIDC realm doesn't exist | Run `provision keycloak` BEFORE starting platform | -| SDK: "platform endpoint is malformed" | Missing `http://` scheme | Use `http://localhost:8080`, not `localhost:8080` | -| SDK: "unauthenticated" | Token endpoint unreachable | Use `WithTokenEndpoint()` to override Docker-internal URL | -| Container: "Config File not found" | Wrong mount path | Mount to `/home/nonroot/.opentdf/opentdf.yaml`, NOT `/app/` | -| Container: "invalid logger output: stderr" | Unsupported value | Use `output: stdout` | -| Browser SDK: "ListKeyAccessServers 403" | User missing role | Add `opentdf-standard` role to user | -| Encrypt works, decrypt 403 | Missing link in auth chain | Check: attribute exists, KAS registered + key assigned, subject mapping exists | -| `.sub` selector doesn't match | Known platform issue | Use `.preferred_username` instead | -| Duplicate mappings break access | Evaluation poisoning | Delete old mappings, create fresh | - -## Part 2: Production Deployment - -Everything from Part 1 applies. This section covers what **changes** for production. - -### Key Differences from Local - -| Concern | Local Dev | Production | -|---------|-----------|------------| -| Database | Docker PostgreSQL, `changeme` | Managed PostgreSQL with SSL (`sslmode: require`) | -| TLS | Disabled | CA-signed certificates (or terminate at load balancer) | -| DPoP | `enforceDPoP: false` | `enforceDPoP: true` (proof-of-possession on tokens). Requires SDK and IdP support — verify both your browser SDK and backend SDK send DPoP proofs before enabling. | -| Identity Provider | Local Keycloak, default passwords | Production OIDC with strong secrets | -| KAS Keys | Generated locally in repo | Secrets manager or env vars, never committed | -| CORS | `allowedorigins: ["http://localhost:5173"]` | Specific frontend domain | -| Logging | `level: info`, `type: text` | `level: info`, `type: json` | -| gRPC Reflection | Enabled | Disabled | -| SDK Connection | `WithInsecurePlaintextConn()` | Remove — use TLS | -| Credentials | Hardcoded `secret` | Environment variables | -| Logger output | `stdout` | `stdout` (only valid value in container) | -| Keycloak | `start-dev` mode | `start` production mode | - -### Solving Split-Horizon DNS - -The #1 architectural issue for containerized deployment. When the platform and Keycloak run in separate containers, they reach each other via internal hostnames (`keycloak:8888`) but external clients use public URLs (`https://keycloak.example.com`). JWT tokens have different `iss` claims depending on which URL was used. - -**Solution**: Set `KC_HOSTNAME_URL` in Keycloak to the public URL. This forces all tokens to use the same issuer regardless of how Keycloak is accessed: - -```bash -KC_HOSTNAME_URL=https://keycloak.example.com/auth -``` - -### Keycloak Production Setup - -#### Provisioning against a remote Keycloak - -```bash -# Stop local Keycloak first — provisioning defaults to localhost:8888 -docker compose down - -# Use -e flag to target remote Keycloak, -p for admin password -go run ./service provision keycloak \ - -e https://keycloak.example.com/auth \ - -u admin \ - -p -``` - -:::warning Provisioning gotchas -- The admin token expires in **60 seconds** — provisioning handles this, but some clients may be created while others aren't. Verify all expected clients exist. -- The provisioned `opentdf` client's audience defaults to `http://localhost:8080`. The platform's `auth.audience` config must match this, or you need to add an audience mapper. -::: - -#### Required Keycloak fixes for production - -**Disable VERIFY_PROFILE**: Keycloak 25 requires a `kc.org` attribute that blocks login. Disable it: -```bash -# Get admin token (replace and with your values) -TOKEN=$(curl -sf -X POST "/realms/master/protocol/openid-connect/token" \ - -d "grant_type=password" -d "client_id=admin-cli" \ - -d "username=admin" -d "password=" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -curl -X PUT "/admin/realms/opentdf/authentication/required-actions/VERIFY_PROFILE" \ - -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"alias":"VERIFY_PROFILE","enabled":false,"defaultAction":false,"priority":90}' -``` - -**Add audience mapper to `cli-client`**: Browser tokens lack the platform audience by default. -1. Keycloak admin → `opentdf` realm → Clients → `cli-client` → Client scopes -2. Click `cli-client-dedicated` → Add mapper → By configuration → **Audience** -3. Included Custom Audience: must match your platform's `auth.audience` config. If you used the default provisioning, this is `http://localhost:8080` (see the note in "Config in Production" for why and how to change it). -4. Add to access token: ON - -**Add `opentdf-standard` to default roles**: So every new user automatically gets SDK access. -1. Realm roles → `default-roles-opentdf` → Associated roles → Add `opentdf-standard` - -#### Missing clients after provisioning - -The 60-second token can cause provisioning to exit early. Verify these exist: -- `opentdf` — admin client -- `opentdf-sdk` — SDK client with `opentdf-standard` role -- `tdf-entity-resolution` — ERS client with `view-users`, `query-users` client roles -- `cli-client` — public client for browser auth - -### KAS Keys in Production - -Never commit private keys to a repository. Options: - -1. **Bake into Docker image** (simplest for demos): Copy keys into a custom Dockerfile -2. **Environment variables**: Base64-encode PEM files, decode at startup -3. **Secrets manager**: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault -4. **Volume mounts**: If your platform supports file mounts - -Generate keys: -```bash -openssl genpkey -algorithm RSA -out kas-private.pem -pkeyopt rsa_keygen_bits:2048 -openssl rsa -in kas-private.pem -pubout -out kas-cert.pem -openssl ecparam -name prime256v1 -out ecparams.tmp -openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=KAS" \ - -keyout kas-ec-private.pem -out kas-ec-cert.pem -days 365 -rm ecparams.tmp -``` - -### Container Registry - -The official image at `registry.opentdf.io/platform:latest` may not be accessible from all cloud providers (Railway returns 403 despite the registry being public). Workaround: mirror to GHCR or Docker Hub: - -```bash -docker pull --platform linux/amd64 registry.opentdf.io/platform:latest -docker tag registry.opentdf.io/platform:latest ghcr.io//opentdf-platform:latest -docker push ghcr.io//opentdf-platform:latest -``` - -**Important**: Specify `--platform linux/amd64` when pulling on ARM Macs — otherwise the image won't run on x86 cloud hosts. - -### Config in Production - -The platform reads config from `/home/nonroot/.opentdf/opentdf.yaml` in the container. For platforms that don't support file mounts (like [Railway](https://railway.app)), bake the config into a custom Docker image: - -```dockerfile -FROM --platform=linux/amd64 ghcr.io//opentdf-platform:latest -COPY opentdf.yaml /home/nonroot/.opentdf/opentdf.yaml -COPY keys/ /keys/ -CMD ["start"] -``` - -Parameterize all deployment-specific values with environment variables, and harden the settings that Part 1 left in dev mode: - -
-Full production opentdf.yaml - -```yaml -logger: - level: info - type: json # Structured logs for production - output: stdout - -db: - host: "${OPENTDF_DB_HOST}" - port: 5432 - database: "${OPENTDF_DB_DATABASE}" - user: postgres - password: "${OPENTDF_DB_PASSWORD}" - sslmode: require # Always use SSL for managed databases - -services: - kas: - registered_kas_uri: "${OPENTDF_KAS_URI}" - keyring: # Each kid appears twice: current + legacy for backward compatibility - - kid: e1 - alg: ec:secp256r1 - - kid: e1 - alg: ec:secp256r1 - legacy: true - - kid: r1 - alg: rsa:2048 - - kid: r1 - alg: rsa:2048 - legacy: true - entityresolution: - mode: claims - url: "${OPENTDF_KEYCLOAK_URL}" - clientid: "tdf-entity-resolution" - clientsecret: "${OPENTDF_ERS_SECRET}" - realm: "opentdf" - legacykeycloak: true - inferid: - from: - email: true - username: true - -server: - tls: - enabled: true # Enable TLS (or terminate at load balancer) - auth: - enabled: true - enforceDPoP: false # Set to true if your SDKs and IdP support DPoP - audience: "http://localhost:8080" # Must match provisioned audience (see note below) - issuer: "${OPENTDF_KEYCLOAK_URL}/realms/opentdf" - policy: - extension: | - g, opentdf-admin, role:admin - g, opentdf-standard, role:standard - cors: - enabled: true - allowedorigins: - - "${OPENTDF_CORS_ORIGIN}" # Specific frontend domain, NOT "*" - allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] - allowedheaders: - - Accept - - Accept-Encoding - - Authorization - - Connect-Protocol-Version - - Content-Length - - Content-Type - - Dpop - - X-CSRF-Token - - X-Requested-With - - X-Rewrap-Additional-Context - exposedheaders: [Link] - allowcredentials: true - maxage: 3600 - grpc: - reflectionEnabled: false # Disable in production - cryptoProvider: - type: standard - standard: - keys: - - kid: r1 - alg: rsa:2048 - private: /keys/kas-private.pem - cert: /keys/kas-cert.pem - - kid: e1 - alg: ec:secp256r1 - private: /keys/kas-ec-private.pem - cert: /keys/kas-ec-cert.pem - port: 8080 -``` - -
- -:::tip About `audience: "http://localhost:8080"` -This looks wrong for production, but it works because `provision keycloak` hardcodes this audience in the `opentdf` client. The audience is a token validation string — the platform checks that the JWT's `aud` claim matches this value. It doesn't connect to this URL. - -You *can* change it to your actual domain (e.g., `https://platform.mydomain.com`). Just make sure all three places agree: -1. The `opentdf` client's audience in Keycloak -2. The `cli-client` audience mapper's "Included Custom Audience" value -3. The platform's `auth.audience` config - -If you used the default provisioning and didn't change the Keycloak client, keep it as `http://localhost:8080`. -::: - -:::tip TLS termination -If your platform (Railway, AWS ALB, etc.) terminates TLS at the load balancer, set `tls.enabled: false` and let the proxy handle certificates. The platform will receive plain HTTP on its internal port. -::: - -**Note**: If both YAML values and `OPENTDF_` env vars are set, YAML wins. Use env var references in the YAML (`${VAR}`) or remove the YAML values entirely and rely solely on env vars. - -### SDK Changes for Production - -The Part 1 SDK examples use dev-only settings. Here's what to change: - -#### Go SDK - -```go -client, err := sdk.New( - "https://platform.example.com", // Use HTTPS - sdk.WithClientCredentials("opentdf-sdk", os.Getenv("CLIENT_SECRET"), nil), - sdk.WithTokenEndpoint("https://keycloak.example.com/auth/realms/opentdf/protocol/openid-connect/token"), - // No WithInsecurePlaintextConn() — TLS is required -) -``` - -Remove `sdk.WithInsecurePlaintextConn()` — this disables TLS and must never be used in production. - -#### JavaScript/TypeScript (Browser SDK) - -```typescript -const authProvider = await AuthProviders.refreshAuthProvider({ - clientId: 'cli-client', - exchange: 'refresh', - refreshToken: keycloak.refreshToken, - oidcOrigin: 'https://keycloak.example.com/auth/realms/opentdf', -}); - -const client = new OpenTDF({ - authProvider, - platformUrl: 'https://platform.example.com', -}); -``` - -All URLs must use `https://`. The browser SDK doesn't have an insecure mode flag — it just needs HTTPS URLs and a valid Keycloak token. - -### Production Troubleshooting - -All local troubleshooting applies, plus: - -| Error | Cause | Fix | -|-------|-------|-----| -| "Account is not fully set up" | `VERIFY_PROFILE` enabled | Disable it (see above) | -| Token audience mismatch (401) | Browser token missing platform audience | Add audience mapper to `cli-client` | -| "Exec format error" in container | Wrong CPU architecture | Use `--platform=linux/amd64` in Dockerfile | -| `provision keycloak` provisions wrong instance | Local Keycloak still running | Stop local first, use `-e` flag | -| Admin password doesn't work | Changed env var after first boot | Reset in Keycloak admin console — env var only works on first boot | -| Container can't pull `registry.opentdf.io` | Cloud provider blocks custom registries | Mirror to GHCR/Docker Hub | -| YAML config ignored, uses defaults | Config not at expected path | Mount to `/home/nonroot/.opentdf/opentdf.yaml` | -| `AttributeValueExists` errors on new values | SDK bug | Treat error as "not found" — [issue #3191](https://github.com/opentdf/platform/issues/3191) | - -### Advanced: Per-Field Encryption for Granular Sharing - -Instead of encrypting an entire record with one attribute, encrypt each data category separately. This enables granular sharing — share weight without sharing symptoms. - -#### Compound attribute values - -Encode both user identity and data category in a single attribute value: - -```text -Attribute: patient_access (rule: ANY_OF) -Values: _ - -Example FQNs: - https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_weight - https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_symptoms -``` - -This provides per-user isolation AND per-category granularity in a single attribute. - -#### Server-side vs client-side decrypt - -For true per-user cryptographic enforcement, the **browser** must decrypt using the user's own Keycloak token. The backend should only encrypt — never decrypt. This ensures the KAS checks each user's entitlements individually. - -For more on this pattern, see the [Subject Mapping Guide](/guides/subject-mapping-guide) and the [GLP-1 Tracker demo](https://github.com/opentdf/demo-glp1-tracker). - -### Next Steps - -- **Monitoring**: Add `server.trace` config with an OTLP endpoint for OpenTelemetry tracing -- **Key Rotation**: Add new keys with different `kid` values, update KAS grants -- **Scaling**: Multiple platform instances behind a load balancer (stateless — state lives in PostgreSQL) -- **Backup**: Regularly back up PostgreSQL — it contains all your policy configuration -- **Reference**: Read [opentdf.io/llms.txt](https://opentdf.io/llms.txt) for comprehensive LLM-optimized documentation diff --git a/docs/guides/production-deployment-guide/index.mdx b/docs/guides/production-deployment-guide/index.mdx new file mode 100644 index 00000000..3f086d53 --- /dev/null +++ b/docs/guides/production-deployment-guide/index.mdx @@ -0,0 +1,56 @@ +--- +title: "Building with OpenTDF: From Local Development to Production" +sidebar_label: "Production Deployment Guide" +sidebar_position: 2 +--- + +# Building with OpenTDF: From Local Development to Production + +This guide walks you through integrating a frontend and backend application with OpenTDF, then publishing it to the web. We use a medical practice as a running example: a clinic where patients encrypt health data and only authorized staff can decrypt it. + +:::note +This is a lightweight example using [Railway](https://railway.app) for hosting, aimed at developers new to working with Keycloak configuration and OpenTDF platform setup. For production use, follow security best practices appropriate to your deployment environment and use case. +::: + +## What You're Building + +```text +┌─────────────────────────────────────────────────┐ +│ Your Application │ +│ (uses OpenTDF SDK to encrypt/decrypt) │ +└──────────┬──────────────────────┬───────────────┘ + │ gRPC/HTTP │ OIDC + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ OpenTDF Platform │ │ Identity Provider │ +│ (single binary) │ │ (Keycloak) │ +│ │ │ │ +│ ┌───────────────┐ │ │ - User accounts │ +│ │ KAS │ │ │ - Roles & groups │ +│ │ Policy │ │ │ - JWT issuance │ +│ │ Authorization │ │ │ │ +│ │ Entity Res. │ │ └──────────────────────┘ +│ └───────────────┘ │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ PostgreSQL │ +│ (policy storage) │ +└─────────────────────┘ +``` + +OpenTDF Platform is a **single Go binary** that bundles KAS (Key Access Server), Policy, Authorization, and Entity Resolution services. It connects to PostgreSQL for storage and an OIDC provider (Keycloak) for authentication. + +## Guide Structure + +1. **[Local Development](./production-deployment-guide/local-development)** — Set up OpenTDF locally, configure Keycloak, create attributes and policies, and integrate the Go and browser SDKs into your application. + +2. **[Production Deployment](./production-deployment-guide/production-deployment)** — What changes when you deploy to a real environment: TLS, secrets management, split-horizon DNS, container configuration, and security hardening. + +## See Also + +- [Subject Mapping Guide](/guides/subject-mapping-guide) — how identity claims map to attribute-based access control +- [GLP-1 Tracker demo](https://github.com/opentdf/demo-glp1-tracker) — a working example app built with this guide +- [opentdf.io/llms.txt](https://opentdf.io/llms.txt) — comprehensive LLM-optimized reference documentation +- [OpenTDF Discussion Forum](https://github.com/orgs/opentdf/discussions) — more examples and community Q&A diff --git a/docs/guides/production-deployment-guide/local-development.mdx b/docs/guides/production-deployment-guide/local-development.mdx new file mode 100644 index 00000000..50d79ade --- /dev/null +++ b/docs/guides/production-deployment-guide/local-development.mdx @@ -0,0 +1,401 @@ +--- +title: "Part 1: Local Development" +sidebar_label: "Local Development" +sidebar_position: 1 +--- + +# Part 1: Local Development + +Set up OpenTDF and build an application that encrypts data with attribute-based access control. + +## Prerequisites + +- Docker and Docker Compose +- Go (check the platform's [`go.mod`](https://github.com/opentdf/platform/blob/main/service/go.mod) for the required version) +- OpenSSL (for generating KAS encryption keys) +- The platform source code: `git clone https://github.com/opentdf/platform.git` + +:::warning Why do I need the source code? +The `provision keycloak` command that bootstraps your identity provider requires files only in the source tree. The container image does not include them. Until provisioning is available as a standalone tool, you need the source checkout. +::: + +## Step 1: Start PostgreSQL and Keycloak + +Generate self-signed TLS certificates (used for HTTPS between platform services locally — not the KAS encryption keys, which are separate) and start infrastructure: + +```bash +cd platform +./.github/scripts/init-temp-keys.sh +docker compose up -d +``` + +Wait for both to be healthy: +```bash +curl -sf http://localhost:8888/auth/realms/master | head -1 +docker compose exec opentdfdb pg_isready +``` + +:::tip Apple M4 users +Keycloak (Java) may crash with SIGILL. Set `export JAVA_OPTS_APPEND="-XX:UseSVE=0"` before running Docker. +::: + +## Step 2: Provision the Identity Provider + +:::danger Order matters +The platform **cannot start** until the OIDC realm exists. Provision Keycloak BEFORE starting the platform. +::: + +```bash +go run ./service provision keycloak +``` + +This creates the `opentdf` realm, clients (`opentdf`, `opentdf-sdk`, `tdf-entity-resolution`, `cli-client`), roles (`opentdf-admin`, `opentdf-standard`), and sample users. + +### Create your application users + +
+Keycloak user creation commands + +```bash +# Get admin token +ADMIN_TOKEN=$(curl -sf -X POST http://localhost:8888/auth/realms/master/protocol/openid-connect/token \ + -d "grant_type=password" -d "client_id=admin-cli" \ + -d "username=admin" -d "password=changeme" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +# Create a user +curl -sf -X POST http://localhost:8888/auth/admin/realms/opentdf/users \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"username":"alice","enabled":true,"email":"alice@example.com","emailVerified":true}' + +# Set password +ALICE_ID=$(curl -sf "http://localhost:8888/auth/admin/realms/opentdf/users?username=alice" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") +curl -sf -X PUT "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/reset-password" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"type":"password","value":"alice123","temporary":false}' + +# Add opentdf-standard role (required for SDK access) +STD_ROLE_ID=$(curl -sf http://localhost:8888/auth/admin/realms/opentdf/roles/opentdf-standard \ + -H "Authorization: Bearer $ADMIN_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +curl -sf -o /dev/null -X POST "http://localhost:8888/auth/admin/realms/opentdf/users/$ALICE_ID/role-mappings/realm" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d "[{\"id\":\"$STD_ROLE_ID\",\"name\":\"opentdf-standard\"}]" +``` + +
+ +**Important**: All users need the `opentdf-standard` realm role, or the browser SDK will get `403` when listing KAS servers. To make this automatic for new users: + +1. Keycloak admin → `opentdf` realm → Realm roles → `default-roles-opentdf` +2. Click **Assign role** → Filter by realm roles → Select `opentdf-standard` → **Assign** + +### Configure browser client + +For browser-based apps, update `cli-client` (the public client created by provisioning): + +In Keycloak admin → `opentdf` realm → Clients → `cli-client`: +- Valid redirect URIs: `http://localhost:*` +- Web origins: `+` + +## Step 3: Configure and Start the Platform + +Create `opentdf.yaml`: + +
+Full opentdf.yaml for local development + +```yaml +logger: + level: info + type: text + output: stdout # Only 'stdout' is valid in the container image + +services: + kas: + registered_kas_uri: http://localhost:8080 + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: e1 + alg: ec:secp256r1 + legacy: true # Used for trial decryption of old TDFs that lack a kid field + - kid: r1 + alg: rsa:2048 + - kid: r1 + alg: rsa:2048 + legacy: true + entityresolution: + mode: claims # See "Understanding ERS Modes" below + url: http://localhost:8888/auth + clientid: "tdf-entity-resolution" + clientsecret: "secret" + realm: "opentdf" + legacykeycloak: true + inferid: + from: + email: true + username: true + +server: + tls: + enabled: false + auth: + enabled: true + enforceDPoP: false + audience: "http://localhost:8080" + issuer: http://localhost:8888/auth/realms/opentdf + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + cors: + enabled: true + allowedorigins: + - "http://localhost:5173" # Vite dev server + - "http://localhost:3000" + allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + exposedheaders: [Link] + allowcredentials: true + maxage: 3600 + grpc: + reflectionEnabled: true + cryptoProvider: + type: standard + standard: + keys: + - kid: r1 + alg: rsa:2048 + private: kas-private.pem + cert: kas-cert.pem + - kid: e1 + alg: ec:secp256r1 + private: kas-ec-private.pem + cert: kas-ec-cert.pem + port: 8080 +``` + +
+ +### Understanding ERS Modes + +The Entity Resolution Service mode controls **what subject mapping selectors match against**. + +| Mode | Selectors target | Use | +|------|-----------------|-----| +| `claims` | JWT token claims directly | Simpler — `.preferred_username`, `.client_id`, `.realm_access.roles[]` | +| `keycloak` (default) | Keycloak Admin API user object | When matching Keycloak user attributes: `.attributes.department[]` | + +**Use `claims` mode** unless you specifically need Keycloak Admin API data. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax. + +:::warning .sub selector doesn't work in claims mode +The `.sub` JWT claim resolves correctly in `otdfctl dev selectors generate` but does NOT evaluate in subject mapping conditions. Use `.preferred_username` instead. This is a [known issue](https://github.com/opentdf/platform/issues/3188). +::: + +### Start the platform + +```bash +go run ./service start +``` + +Verify: +```bash +curl -sf http://localhost:8080/healthz +# {"status":"SERVING"} +``` + +:::danger Don't run the platform in Docker for local dev +Running the platform in Docker alongside Keycloak causes split-horizon DNS — tokens from `localhost:8888` have different `iss` claims than tokens from `keycloak:8888`. Use `KC_HOSTNAME_URL` in production (see [Part 2](./production-deployment)), or run the platform on the host for local dev. +::: + +## Step 4: Set Up Attributes and Policy + +The authorization chain has four links — **ALL must be in place** before decrypt works: + +```text +1. Attribute exists in policy DB +2. KAS is registered with keys and granted to the attribute +3. Subject mapping connects identity claims to the attribute +4. Encrypted data is tagged with the attribute +``` + +### Create attributes + +Each `otdfctl` command below outputs an ID. Copy it — you'll need it in the next command. + +```bash +# Create namespace (must be a valid hostname, no scheme) +otdfctl policy attributes namespaces create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --name "clinic.example" + +# Create attribute with values +otdfctl policy attributes create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --namespace \ + --name staff_role --rule ANY_OF \ + --value doctor --value nurse --value admin +``` + +### Register KAS with keys + +
+KAS registration and key assignment commands + +```bash +# Register KAS +otdfctl policy kas-registry create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --uri http://localhost:8080 \ + --public-key-remote http://localhost:8080/kas/v2/kas_public_key \ + --name local-kas + +# Create KAS key (base64-encode the PEM) +KAS_PUB_B64=$(curl -sf http://localhost:8080/kas/v2/kas_public_key | python3 -c "import sys,json,base64; print(base64.b64encode(json.load(sys.stdin)['publicKey'].encode()).decode())") +otdfctl policy kas-registry key create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --kas --key-id "r1" --algorithm "rsa:2048" \ + --mode "public_key" --public-key-pem "$KAS_PUB_B64" + +# Assign key to attribute +otdfctl policy attributes key assign \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attribute --key-id +``` + +
+ +### Create subject mappings + +One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. + +```bash +otdfctl policy subject-mappings create \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attribute-value-id \ + --action read \ + --subject-condition-set-new '[{ + "condition_groups": [{ + "conditions": [{ + "subject_external_selector_value": ".preferred_username", + "operator": "SUBJECT_MAPPING_OPERATOR_ENUM_IN", + "subject_external_values": ["alice"] + }], + "boolean_operator": "CONDITION_BOOLEAN_TYPE_ENUM_AND" + }] + }]' +``` + +:::warning Duplicate subject mappings poison evaluation +If multiple mappings exist for the same attribute value and one has a non-matching condition, it can block other mappings from granting access. Always delete old mappings before creating replacements. See [issue #3190](https://github.com/opentdf/platform/issues/3190). +::: + +### Validate with SDK discovery + +Before encrypting, use the SDK's discovery helpers to verify your policy is configured: + +```go +// Check attribute exists +exists, err := sdk.AttributeValueExists(ctx, "https://clinic.example/attr/staff_role/value/doctor") + +// Validate all FQNs before encrypting +err := sdk.ValidateAttributes(ctx, fqns...) +``` + +Note: `AttributeValueExists` currently returns an error (not `false`) for non-existent values. See [issue #3191](https://github.com/opentdf/platform/issues/3191). + +## Step 5: Integrate the SDK + +### Go SDK (Backend — Encrypt Only) + +```go +client, err := sdk.New( + "http://localhost:8080", // Must include http:// scheme + sdk.WithClientCredentials("opentdf-sdk", "secret", nil), + sdk.WithTokenEndpoint("http://localhost:8888/auth/realms/opentdf/protocol/openid-connect/token"), + sdk.WithInsecurePlaintextConn(), // Remove in production +) + +// Encrypt +var buf bytes.Buffer +_, err = client.CreateTDF(&buf, bytes.NewReader(plaintext), + sdk.WithDataAttributes("https://clinic.example/attr/staff_role/value/doctor"), + sdk.WithAutoconfigure(false), + sdk.WithKasInformation(sdk.KASInfo{URL: "http://localhost:8080"}), +) +``` + +### JavaScript/TypeScript (Browser SDK — Decrypt) + +For true per-user access enforcement, the **browser** decrypts using the user's own Keycloak token. The backend should only encrypt. + +```typescript +import { AuthProviders, OpenTDF } from '@opentdf/sdk'; + +const authProvider = await AuthProviders.refreshAuthProvider({ + clientId: 'cli-client', // Must be a PUBLIC Keycloak client + exchange: 'refresh', + refreshToken: keycloak.refreshToken, + oidcOrigin: 'http://localhost:8888/auth/realms/opentdf', +}); + +const client = new OpenTDF({ + authProvider, + platformUrl: 'http://localhost:8080', +}); +await client.ready; + +// Decrypt +const decryptedStream = await client.read({ + source: { type: 'buffer', location: tdfBytes }, +}); +``` + +## Step 6: Test the Full Round-Trip + +```bash +# Encrypt +echo "secret data" | otdfctl encrypt \ + --host http://localhost:8080 \ + --with-client-creds '{"clientId":"opentdf","clientSecret":"secret"}' \ + --attr "https://clinic.example/attr/staff_role/value/doctor" \ + -o test.tdf + +# Decrypt as a user with the right entitlement +otdfctl decrypt \ + --host http://localhost:8080 \ + --with-access-token "$USER_TOKEN" \ + test.tdf +``` + +If decrypt fails with "could not perform access", check all four links in the authorization chain (Step 4). + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| Platform crashes: "failed to discover idp" | OIDC realm doesn't exist | Run `provision keycloak` BEFORE starting platform | +| SDK: "platform endpoint is malformed" | Missing `http://` scheme | Use `http://localhost:8080`, not `localhost:8080` | +| SDK: "unauthenticated" | Token endpoint unreachable | Use `WithTokenEndpoint()` to override Docker-internal URL | +| Container: "Config File not found" | Wrong mount path | Mount to `/home/nonroot/.opentdf/opentdf.yaml`, NOT `/app/` | +| Container: "invalid logger output: stderr" | Unsupported value | Use `output: stdout` | +| Browser SDK: "ListKeyAccessServers 403" | User missing role | Add `opentdf-standard` role to user | +| Encrypt works, decrypt 403 | Missing link in auth chain | Check: attribute exists, KAS registered + key assigned, subject mapping exists | +| `.sub` selector doesn't match | Known platform issue | Use `.preferred_username` instead | +| Duplicate mappings break access | Evaluation poisoning | Delete old mappings, create fresh | diff --git a/docs/guides/production-deployment-guide/production-deployment.mdx b/docs/guides/production-deployment-guide/production-deployment.mdx new file mode 100644 index 00000000..888ad745 --- /dev/null +++ b/docs/guides/production-deployment-guide/production-deployment.mdx @@ -0,0 +1,325 @@ +--- +title: "Part 2: Production Deployment" +sidebar_label: "Production Deployment" +sidebar_position: 2 +--- + +# Part 2: Production Deployment + +Everything from [Part 1](./local-development) applies. This section covers what **changes** for production. + +## Key Differences from Local + +| Concern | Local Dev | Production | +|---------|-----------|------------| +| Database | Docker PostgreSQL, `changeme` | Managed PostgreSQL with SSL (`sslmode: require`) | +| TLS | Disabled | CA-signed certificates (or terminate at load balancer) | +| DPoP | `enforceDPoP: false` | `enforceDPoP: true` (proof-of-possession on tokens). Requires SDK and IdP support — verify both your browser SDK and backend SDK send DPoP proofs before enabling. | +| Identity Provider | Local Keycloak, default passwords | Production OIDC with strong secrets | +| KAS Keys | Generated locally in repo | Secrets manager or env vars, never committed | +| CORS | `allowedorigins: ["http://localhost:5173"]` | Specific frontend domain | +| Logging | `level: info`, `type: text` | `level: info`, `type: json` | +| gRPC Reflection | Enabled | Disabled | +| SDK Connection | `WithInsecurePlaintextConn()` | Remove — use TLS | +| Credentials | Hardcoded `secret` | Environment variables | +| Logger output | `stdout` | `stdout` (only valid value in container) | +| Keycloak | `start-dev` mode | `start` production mode | + +## Solving Split-Horizon DNS + +The #1 architectural issue for containerized deployment. When the platform and Keycloak run in separate containers, they reach each other via internal hostnames (`keycloak:8888`) but external clients use public URLs (`https://keycloak.example.com`). JWT tokens have different `iss` claims depending on which URL was used. + +**Solution**: Set `KC_HOSTNAME_URL` in Keycloak to the public URL. This forces all tokens to use the same issuer regardless of how Keycloak is accessed: + +```bash +KC_HOSTNAME_URL=https://keycloak.example.com/auth +``` + +## Keycloak Production Setup + +### Provisioning against a remote Keycloak + +```bash +# Stop local Keycloak first — provisioning defaults to localhost:8888 +docker compose down + +# Use -e flag to target remote Keycloak, -p for admin password +go run ./service provision keycloak \ + -e https://keycloak.example.com/auth \ + -u admin \ + -p +``` + +:::warning Provisioning gotchas +- The admin token expires in **60 seconds** — provisioning handles this, but some clients may be created while others aren't. Verify all expected clients exist. +- The provisioned `opentdf` client's audience defaults to `http://localhost:8080`. The platform's `auth.audience` config must match this, or you need to add an audience mapper. +::: + +### Required Keycloak fixes for production + +**Disable VERIFY_PROFILE**: Keycloak 25's Verify Profile required action can block login when user profiles are incomplete. Disable it: +```bash +# Get admin token (replace and with your values) +TOKEN=$(curl -sf -X POST "/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password" -d "client_id=admin-cli" \ + -d "username=admin" -d "password=" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +curl -X PUT "/admin/realms/opentdf/authentication/required-actions/VERIFY_PROFILE" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"alias":"VERIFY_PROFILE","enabled":false,"defaultAction":false,"priority":90}' +``` + +**Add audience mapper to `cli-client`**: Browser tokens lack the platform audience by default. +1. Keycloak admin → `opentdf` realm → Clients → `cli-client` → Client scopes +2. Click `cli-client-dedicated` → Add mapper → By configuration → **Audience** +3. Included Custom Audience: must match your platform's `auth.audience` config. If you used the default provisioning, this is `http://localhost:8080` (see the note in "Config in Production" for why and how to change it). +4. Add to access token: ON + +**Add `opentdf-standard` to default roles**: So every new user automatically gets SDK access. +1. Realm roles → `default-roles-opentdf` → Associated roles → Add `opentdf-standard` + +### Missing clients after provisioning + +The 60-second token can cause provisioning to exit early. Verify these exist: +- `opentdf` — admin client +- `opentdf-sdk` — SDK client with `opentdf-standard` role +- `tdf-entity-resolution` — ERS client with `view-users`, `query-users` client roles +- `cli-client` — public client for browser auth + +## KAS Keys in Production + +Never commit private keys to a repository. Options: + +1. **Bake into Docker image** (simplest for demos): Copy keys into a custom Dockerfile +2. **Environment variables**: Base64-encode PEM files, decode at startup +3. **Secrets manager**: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault +4. **Volume mounts**: If your platform supports file mounts + +Generate keys: +```bash +openssl genpkey -algorithm RSA -out kas-private.pem -pkeyopt rsa_keygen_bits:2048 +openssl rsa -in kas-private.pem -pubout -out kas-cert.pem +openssl ecparam -name prime256v1 -out ecparams.tmp +openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=KAS" \ + -keyout kas-ec-private.pem -out kas-ec-cert.pem -days 365 +rm ecparams.tmp +``` + +## Container Registry + +The official image at `registry.opentdf.io/platform:latest` may not be accessible from all cloud providers (Railway returns 403 despite the registry being public). Workaround: mirror to GHCR or Docker Hub: + +```bash +docker pull --platform linux/amd64 registry.opentdf.io/platform:latest +docker tag registry.opentdf.io/platform:latest ghcr.io//opentdf-platform:latest +docker push ghcr.io//opentdf-platform:latest +``` + +**Important**: Specify `--platform linux/amd64` when pulling on ARM Macs — otherwise the image won't run on x86 cloud hosts. + +## Config in Production + +The platform reads config from `/home/nonroot/.opentdf/opentdf.yaml` in the container. For platforms that don't support file mounts (like [Railway](https://railway.app)), bake the config into a custom Docker image: + +```dockerfile +FROM --platform=linux/amd64 ghcr.io//opentdf-platform:latest +COPY opentdf.yaml /home/nonroot/.opentdf/opentdf.yaml +COPY keys/ /keys/ +CMD ["start"] +``` + +Parameterize all deployment-specific values with environment variables, and harden the settings that Part 1 left in dev mode: + +
+Full production opentdf.yaml + +```yaml +logger: + level: info + type: json # Structured logs for production + output: stdout + +db: + host: "${OPENTDF_DB_HOST}" + port: 5432 + database: "${OPENTDF_DB_DATABASE}" + user: postgres + password: "${OPENTDF_DB_PASSWORD}" + sslmode: require # Always use SSL for managed databases + +services: + kas: + registered_kas_uri: "${OPENTDF_KAS_URI}" + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: e1 + alg: ec:secp256r1 + legacy: true # Used for trial decryption of old TDFs that lack a kid field + - kid: r1 + alg: rsa:2048 + - kid: r1 + alg: rsa:2048 + legacy: true + entityresolution: + mode: claims + url: "${OPENTDF_KEYCLOAK_URL}" + clientid: "tdf-entity-resolution" + clientsecret: "${OPENTDF_ERS_SECRET}" + realm: "opentdf" + legacykeycloak: true + inferid: + from: + email: true + username: true + +server: + tls: + enabled: true # Enable TLS (or terminate at load balancer) + auth: + enabled: true + enforceDPoP: false # Set to true if your SDKs and IdP support DPoP + audience: "http://localhost:8080" # Must match provisioned audience (see note below) + issuer: "${OPENTDF_KEYCLOAK_URL}/realms/opentdf" + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard + cors: + enabled: true + allowedorigins: + - "${OPENTDF_CORS_ORIGIN}" # Specific frontend domain, NOT "*" + allowedmethods: [GET, POST, PATCH, PUT, DELETE, OPTIONS] + allowedheaders: + - Accept + - Accept-Encoding + - Authorization + - Connect-Protocol-Version + - Content-Length + - Content-Type + - Dpop + - X-CSRF-Token + - X-Requested-With + - X-Rewrap-Additional-Context + exposedheaders: [Link] + allowcredentials: true + maxage: 3600 + grpc: + reflectionEnabled: false # Disable in production + cryptoProvider: + type: standard + standard: + keys: + - kid: r1 + alg: rsa:2048 + private: /keys/kas-private.pem + cert: /keys/kas-cert.pem + - kid: e1 + alg: ec:secp256r1 + private: /keys/kas-ec-private.pem + cert: /keys/kas-ec-cert.pem + port: 8080 +``` + +
+ +:::tip About `audience: "http://localhost:8080"` +This looks wrong for production, but it works because `provision keycloak` hardcodes this audience in the `opentdf` client. The audience is a token validation string — the platform checks that the JWT's `aud` claim matches this value. It doesn't connect to this URL. + +You *can* change it to your actual domain (e.g., `https://platform.mydomain.com`). Just make sure all three places agree: +1. The `opentdf` client's audience in Keycloak +2. The `cli-client` audience mapper's "Included Custom Audience" value +3. The platform's `auth.audience` config + +If you used the default provisioning and didn't change the Keycloak client, keep it as `http://localhost:8080`. +::: + +:::tip TLS termination +If your platform (Railway, AWS ALB, etc.) terminates TLS at the load balancer, set `tls.enabled: false` and let the proxy handle certificates. The platform will receive plain HTTP on its internal port. +::: + +**Note**: If both YAML values and `OPENTDF_` env vars are set, YAML wins. Use env var references in the YAML (`${VAR}`) or remove the YAML values entirely and rely solely on env vars. + +## SDK Changes for Production + +The Part 1 SDK examples use dev-only settings. Here's what to change: + +### Go SDK + +```go +client, err := sdk.New( + "https://platform.example.com", // Use HTTPS + sdk.WithClientCredentials("opentdf-sdk", os.Getenv("CLIENT_SECRET"), nil), + sdk.WithTokenEndpoint("https://keycloak.example.com/auth/realms/opentdf/protocol/openid-connect/token"), + // No WithInsecurePlaintextConn() — TLS is required +) +``` + +Remove `sdk.WithInsecurePlaintextConn()` — this disables TLS and must never be used in production. + +### JavaScript/TypeScript (Browser SDK) + +```typescript +const authProvider = await AuthProviders.refreshAuthProvider({ + clientId: 'cli-client', + exchange: 'refresh', + refreshToken: keycloak.refreshToken, + oidcOrigin: 'https://keycloak.example.com/auth/realms/opentdf', +}); + +const client = new OpenTDF({ + authProvider, + platformUrl: 'https://platform.example.com', +}); +``` + +All URLs must use `https://`. The browser SDK doesn't have an insecure mode flag — it just needs HTTPS URLs and a valid Keycloak token. + +## Troubleshooting + +All [local development troubleshooting](./local-development#troubleshooting) applies, plus: + +| Error | Cause | Fix | +|-------|-------|-----| +| "Account is not fully set up" | `VERIFY_PROFILE` enabled | Disable it (see above) | +| Token audience mismatch (401) | Browser token missing platform audience | Add audience mapper to `cli-client` | +| "Exec format error" in container | Wrong CPU architecture | Use `--platform=linux/amd64` in Dockerfile | +| `provision keycloak` provisions wrong instance | Local Keycloak still running | Stop local first, use `-e` flag | +| Admin password doesn't work | Changed env var after first boot | Reset in Keycloak admin console — env var only works on first boot | +| Container can't pull `registry.opentdf.io` | Cloud provider blocks custom registries | Mirror to GHCR/Docker Hub | +| YAML config ignored, uses defaults | Config not at expected path | Mount to `/home/nonroot/.opentdf/opentdf.yaml` | +| `AttributeValueExists` errors on new values | SDK bug | Treat error as "not found" — [issue #3191](https://github.com/opentdf/platform/issues/3191) | + +## Advanced: Per-Field Encryption for Granular Sharing + +Instead of encrypting an entire record with one attribute, encrypt each data category separately. This enables granular sharing — share weight without sharing symptoms. + +### Compound attribute values + +Encode both user identity and data category in a single attribute value: + +```text +Attribute: patient_access (rule: ANY_OF) +Values: _ + +Example FQNs: + https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_weight + https://clinic.example/attr/patient_access/value/356eeb1968c94364b4227defd515db94_symptoms +``` + +This provides per-user isolation AND per-category granularity in a single attribute. + +### Server-side vs client-side decrypt + +For true per-user cryptographic enforcement, the **browser** must decrypt using the user's own Keycloak token. The backend should only encrypt — never decrypt. This ensures the KAS checks each user's entitlements individually. + +For more on this pattern, see the [Subject Mapping Guide](/guides/subject-mapping-guide) and the [GLP-1 Tracker demo](https://github.com/opentdf/demo-glp1-tracker). + +## Next Steps + +- **Monitoring**: Add `server.trace` config with an OTLP endpoint for OpenTelemetry tracing +- **Key Rotation**: Add new keys with different `kid` values, update KAS grants +- **Scaling**: Multiple platform instances behind a load balancer (stateless — state lives in PostgreSQL) +- **Backup**: Regularly back up PostgreSQL — it contains all your policy configuration +- **Reference**: Read [opentdf.io/llms.txt](https://opentdf.io/llms.txt) for comprehensive LLM-optimized reference documentation +- **Community**: Visit the [OpenTDF Discussion Forum](https://github.com/orgs/opentdf/discussions) for more examples and to ask questions From c69918ef9e8e13e05bc5da9631f0472659b8faa8 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 23 Mar 2026 07:33:21 -0700 Subject: [PATCH 7/7] docs: add example links, frontend prereq, and JWT auth note - Add frontend with OIDC auth as first prerequisite - Link to demo app examples throughout: auth setup, TDF client, policy manager, create-users script, local config, Railway deploy - Note that production uses JWT auth from your own IdP Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/production-deployment-guide/index.mdx | 2 +- .../production-deployment-guide/local-development.mdx | 11 ++++++++--- .../production-deployment.mdx | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/guides/production-deployment-guide/index.mdx b/docs/guides/production-deployment-guide/index.mdx index 3f086d53..c97e7142 100644 --- a/docs/guides/production-deployment-guide/index.mdx +++ b/docs/guides/production-deployment-guide/index.mdx @@ -9,7 +9,7 @@ sidebar_position: 2 This guide walks you through integrating a frontend and backend application with OpenTDF, then publishing it to the web. We use a medical practice as a running example: a clinic where patients encrypt health data and only authorized staff can decrypt it. :::note -This is a lightweight example using [Railway](https://railway.app) for hosting, aimed at developers new to working with Keycloak configuration and OpenTDF platform setup. For production use, follow security best practices appropriate to your deployment environment and use case. +This is a lightweight example using [Railway](https://railway.app) for hosting, aimed at developers new to working with Keycloak configuration and OpenTDF platform setup. In production, you would authenticate with JWTs from your own identity provider. Follow security best practices appropriate to your deployment environment and use case. ::: ## What You're Building diff --git a/docs/guides/production-deployment-guide/local-development.mdx b/docs/guides/production-deployment-guide/local-development.mdx index 50d79ade..6cf0eefd 100644 --- a/docs/guides/production-deployment-guide/local-development.mdx +++ b/docs/guides/production-deployment-guide/local-development.mdx @@ -10,6 +10,7 @@ Set up OpenTDF and build an application that encrypts data with attribute-based ## Prerequisites +- A frontend application with OIDC authentication — the browser SDK needs a valid Keycloak token to decrypt. Use a library like [`keycloak-js`](https://www.keycloak.org/docs/latest/securing_apps/#_javascript_adapter) to handle login. See the [GLP-1 Tracker frontend](https://github.com/opentdf/demo-glp1-tracker/tree/main/frontend) for a working example. - Docker and Docker Compose - Go (check the platform's [`go.mod`](https://github.com/opentdf/platform/blob/main/service/go.mod) for the required version) - OpenSSL (for generating KAS encryption keys) @@ -53,6 +54,8 @@ This creates the `opentdf` realm, clients (`opentdf`, `opentdf-sdk`, `tdf-entity ### Create your application users +For bulk user creation, see the [create-users.py](https://github.com/opentdf/demo-glp1-tracker/blob/main/scripts/create-users.py) script in the demo app. +
Keycloak user creation commands @@ -99,7 +102,7 @@ In Keycloak admin → `opentdf` realm → Clients → `cli-client`: ## Step 3: Configure and Start the Platform -Create `opentdf.yaml`: +Create `opentdf.yaml` (see the [demo app's local config](https://github.com/opentdf/demo-glp1-tracker/blob/main/opentdf.yaml) for a working example):
Full opentdf.yaml for local development @@ -282,7 +285,7 @@ otdfctl policy attributes key assign \ ### Create subject mappings -One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. +One mapping per attribute value. See the [Subject Mapping Guide](/guides/subject-mapping-guide) for selector syntax, operators, and patterns. For programmatic creation of attributes and subject mappings, see the [policy manager](https://github.com/opentdf/demo-glp1-tracker/blob/main/backend/policymanager/manager.go) in the demo app. ```bash otdfctl policy subject-mappings create \ @@ -324,6 +327,8 @@ Note: `AttributeValueExists` currently returns an error (not `false`) for non-ex ### Go SDK (Backend — Encrypt Only) +See the [demo app's TDF client](https://github.com/opentdf/demo-glp1-tracker/blob/main/backend/tdf/client.go) for a complete encrypt wrapper. + ```go client, err := sdk.New( "http://localhost:8080", // Must include http:// scheme @@ -343,7 +348,7 @@ _, err = client.CreateTDF(&buf, bytes.NewReader(plaintext), ### JavaScript/TypeScript (Browser SDK — Decrypt) -For true per-user access enforcement, the **browser** decrypts using the user's own Keycloak token. The backend should only encrypt. +For true per-user access enforcement, the **browser** decrypts using the user's own Keycloak token. The backend should only encrypt. See the demo app's [auth setup](https://github.com/opentdf/demo-glp1-tracker/blob/main/frontend/src/lib/auth.ts) and [TDF decrypt helper](https://github.com/opentdf/demo-glp1-tracker/blob/main/frontend/src/lib/tdf.ts). ```typescript import { AuthProviders, OpenTDF } from '@opentdf/sdk'; diff --git a/docs/guides/production-deployment-guide/production-deployment.mdx b/docs/guides/production-deployment-guide/production-deployment.mdx index 888ad745..739e245c 100644 --- a/docs/guides/production-deployment-guide/production-deployment.mdx +++ b/docs/guides/production-deployment-guide/production-deployment.mdx @@ -119,7 +119,7 @@ docker push ghcr.io//opentdf-platform:latest ## Config in Production -The platform reads config from `/home/nonroot/.opentdf/opentdf.yaml` in the container. For platforms that don't support file mounts (like [Railway](https://railway.app)), bake the config into a custom Docker image: +The platform reads config from `/home/nonroot/.opentdf/opentdf.yaml` in the container. For platforms that don't support file mounts (like [Railway](https://railway.app)), bake the config into a custom Docker image. See the demo app's [Railway deployment files](https://github.com/opentdf/demo-glp1-tracker/tree/main/deploy/railway) for a complete working example. ```dockerfile FROM --platform=linux/amd64 ghcr.io//opentdf-platform:latest