From 02b4ec9ee36d70f4c2a9041da3f6c31bb7b03ff7 Mon Sep 17 00:00:00 2001 From: Christian Galo Date: Fri, 24 Oct 2025 02:10:54 +0000 Subject: [PATCH] Add JWT-based authorization support for Temporal server with Keycloak integration - Create QUICK_REFERENCE.md for a concise guide on setting up temporal authorization. - Add README_AUTHORIZATION.md detailing the implementation steps and common issues. - Introduce REVERSE_PROXY_APPROACH.md as an alternative method for authorization using a reverse proxy. - Implement Dockerfile for building a custom Temporal server with authorization features. - Add main.go to initialize the custom Temporal server with JWT authorization. - Create example-keycloak-mapper.json for mapping Keycloak groups to Temporal permissions. - Add development.yaml for configuring the Temporal server with JWT settings. - Implement test-authorization.sh script to verify JWT token claims and Temporal server access. - Include go.mod for managing Go dependencies in the custom server. - Document troubleshooting steps and customization options in README.md. --- AUTHORIZATION_GUIDE.md | 400 +++++++++++++++++++ DIAGRAMS.md | 413 +++++++++++++++++++ IMPLEMENTATION_CHECKLIST.md | 443 +++++++++++++++++++++ QUICK_REFERENCE.md | 183 +++++++++ README_AUTHORIZATION.md | 232 +++++++++++ REVERSE_PROXY_APPROACH.md | 225 +++++++++++ custom-server/Dockerfile | 28 ++ custom-server/README.md | 271 +++++++++++++ custom-server/config/development.yaml | 99 +++++ custom-server/example-keycloak-mapper.json | 13 + custom-server/go.mod | 7 + custom-server/main.go | 51 +++ test-authorization.sh | 244 ++++++++++++ 13 files changed, 2609 insertions(+) create mode 100644 AUTHORIZATION_GUIDE.md create mode 100644 DIAGRAMS.md create mode 100644 IMPLEMENTATION_CHECKLIST.md create mode 100644 QUICK_REFERENCE.md create mode 100644 README_AUTHORIZATION.md create mode 100644 REVERSE_PROXY_APPROACH.md create mode 100644 custom-server/Dockerfile create mode 100644 custom-server/README.md create mode 100644 custom-server/config/development.yaml create mode 100644 custom-server/example-keycloak-mapper.json create mode 100644 custom-server/go.mod create mode 100644 custom-server/main.go create mode 100755 test-authorization.sh diff --git a/AUTHORIZATION_GUIDE.md b/AUTHORIZATION_GUIDE.md new file mode 100644 index 0000000..ee4b675 --- /dev/null +++ b/AUTHORIZATION_GUIDE.md @@ -0,0 +1,400 @@ +# Temporal Authorization Implementation Guide + +## Overview + +This guide explains how to implement fine-grained authorization for your self-hosted Temporal deployment with OIDC/Keycloak integration. + +## Architecture + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Browser │─────▶│ Keycloak │─────▶│ Temporal UI │ +│ │◀─────│ (OIDC) │◀─────│ │ +└─────────────┘ └──────────────┘ └─────────────────┘ + │ │ + │ JWT Token │ API Calls + │ with claims │ (with JWT) + ▼ ▼ + ┌──────────────────────────────────┐ + │ Temporal Server │ + │ ┌────────────────────────┐ │ + │ │ ClaimMapper │ │ + │ │ (Extracts JWT claims) │ │ + │ └────────────┬───────────┘ │ + │ │ │ + │ ┌────────────▼───────────┐ │ + │ │ Authorizer │ │ + │ │ (Allow/Deny decision) │ │ + │ └────────────────────────┘ │ + └──────────────────────────────────┘ +``` + +## Key Concepts + +### 1. ClaimMapper +- Extracts claims from JWT tokens +- Translates OIDC/Keycloak groups/roles into Temporal permissions +- Temporal provides a default JWT ClaimMapper that you can use + +### 2. Authorizer +- Makes authorization decisions for each API call +- Checks if the caller's claims allow the requested operation +- Temporal provides a default Authorizer + +### 3. JWT Token Format + +The default JWT ClaimMapper expects a `permissions` array in your JWT: + +```json +{ + "sub": "user@example.com", + "permissions": [ + "temporal-system:admin", + "production:read", + "development:write" + ], + "aud": "temporal-ui", + "iss": "https://keycloak.example.com/realms/myrealm" +} +``` + +Permission format: `:` + +Available roles: +- `read` - Read-only access +- `write` - Read and write access +- `worker` - Worker permissions +- `admin` - Full admin access + +## Implementation Steps + +### Step 1: Configure Keycloak Claims + +You need to map Keycloak groups/roles to the `permissions` claim format that Temporal expects. + +#### Option A: Using Keycloak Groups + +1. Create Keycloak groups for your teams: + - `temporal-admins` + - `dev-team` + - `ops-team` + - `viewers` + +2. Add a **Protocol Mapper** to your Temporal UI client: + - Mapper Type: `Group Membership` + - Token Claim Name: `groups` + - Full group path: OFF + - Add to ID token: YES + - Add to access token: YES + - Add to userinfo: YES + +3. Add a **Script Mapper** to transform groups to permissions: + - Mapper Type: `Script Mapper` + - Token Claim Name: `permissions` + - Script: + ```javascript + var permissions = []; + var groups = user.getGroupIds(); + + for each (var group in groups) { + var groupName = group.getName(); + if (groupName === 'temporal-admins') { + permissions.push('temporal-system:admin'); + } else if (groupName === 'dev-team') { + permissions.push('development:write'); + permissions.push('staging:write'); + } else if (groupName === 'ops-team') { + permissions.push('production:write'); + } else if (groupName === 'viewers') { + permissions.push('production:read'); + permissions.push('development:read'); + } + } + + permissions; + ``` + +#### Option B: Using Keycloak Roles + +1. Create Keycloak roles: + - `temporal-system-admin` + - `namespace-dev-writer` + - `namespace-prod-reader` + +2. Add a **User Realm Role Mapper**: + - Mapper Type: `User Realm Role` + - Token Claim Name: `roles` + - Add to ID token: YES + +3. Add a **Script Mapper** to convert roles to permissions format + +#### Option C: Custom Attribute Mapper + +You can also add custom user attributes in Keycloak and map them directly: + +1. In User → Attributes, add: + - `temporal-permissions` = `["production:read", "development:write"]` + +2. Create an attribute mapper that extracts this as the `permissions` claim + +### Step 2: Update Your Temporal Server Configuration + +Add JWT configuration to your Temporal config: + +```yaml +# config/development.yaml +global: + authorization: + jwtKeyProvider: + # JWKS endpoint from Keycloak + keySourceURIs: + - "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs" + refreshInterval: "1h" + + # Claim name containing permissions (default: "permissions") + permissionsClaimName: "permissions" +``` + +### Step 3: Build Custom Temporal Server + +See `custom-server/main.go` for the implementation. Key points: + +```go +temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { + return authorization.NewDefaultJWTClaimMapper( + authorization.NewDefaultTokenKeyProvider(cfg, logger), + cfg, + logger, + ) +}) + +temporal.WithAuthorizer(authorization.NewDefaultAuthorizer()) +``` + +### Step 4: Update Docker Compose + +Replace the standard Temporal image with your custom build: + +```yaml +services: + temporal: + build: + context: ./custom-server + dockerfile: Dockerfile + # ... rest of config + environment: + - TEMPORAL_AUTH_ISSUER_URL=${TEMPORAL_AUTH_ISSUER_URL} + # ... other env vars +``` + +### Step 5: Configure Temporal UI to Pass JWT + +The Temporal UI needs to forward the JWT token to the Temporal Server. This happens automatically when you configure the UI with OIDC - the UI will include the token in gRPC metadata under the `authorization` header. + +Your existing UI configuration should work: +```yaml +environment: + - TEMPORAL_AUTH_ENABLED=true + - TEMPORAL_AUTH_PROVIDER_URL=${TEMPORAL_AUTH_PROVIDER_URL} + - TEMPORAL_AUTH_ISSUER_URL=${TEMPORAL_AUTH_ISSUER_URL} + - TEMPORAL_AUTH_CLIENT_ID=${TEMPORAL_AUTH_CLIENT_ID} + - TEMPORAL_AUTH_CLIENT_SECRET=${TEMPORAL_AUTH_CLIENT_SECRET} + - TEMPORAL_AUTH_CALLBACK_URL=${TEMPORAL_AUTH_CALLBACK_URL} +``` + +## Testing + +### 1. Test JWT Token + +First, verify your Keycloak is issuing the right claims: + +```bash +# Get a token +TOKEN=$(curl -X POST "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/token" \ + -d "client_id=temporal-ui" \ + -d "client_secret=YOUR_SECRET" \ + -d "grant_type=password" \ + -d "username=testuser" \ + -d "password=testpass" | jq -r '.access_token') + +# Decode and inspect +echo $TOKEN | cut -d'.' -f2 | base64 -d | jq . +``` + +Look for the `permissions` array in the output. + +### 2. Test Authorization + +1. Create a user in Keycloak with limited permissions (e.g., only `development:read`) +2. Log in to Temporal UI with that user +3. Try to: + - View workflows in `development` namespace ✓ (should work) + - Start a workflow in `development` namespace ✗ (should fail) + - Access `production` namespace ✗ (should fail) + +### 3. Check Logs + +Enable debug logging to see authorization decisions: + +```yaml +# In dynamicconfig/development-sql.yaml +frontend.enableDebugMode: + - value: true +``` + +## Common Permission Patterns + +### Admin User +```json +{ + "permissions": [ + "temporal-system:admin" + ] +} +``` + +### Developer +```json +{ + "permissions": [ + "development:write", + "staging:write", + "production:read" + ] +} +``` + +### Production Operator +```json +{ + "permissions": [ + "production:write" + ] +} +``` + +### Read-Only Viewer +```json +{ + "permissions": [ + "development:read", + "staging:read", + "production:read" + ] +} +``` + +### Worker Service Account +```json +{ + "permissions": [ + "production:worker", + "development:worker" + ] +} +``` + +## Advanced: Custom ClaimMapper + +If you need more complex logic than the default JWT ClaimMapper provides (e.g., mapping based on multiple claims, using group hierarchies, etc.), you can implement a custom ClaimMapper: + +```go +type customClaimMapper struct { + defaultMapper authorization.ClaimMapper +} + +func (c *customClaimMapper) GetClaims(authInfo *authorization.AuthInfo) (*authorization.Claims, error) { + claims := &authorization.Claims{} + + // Parse the JWT token + token, err := jwt.Parse(authInfo.AuthToken, /* key function */) + if err != nil { + return nil, err + } + + // Extract custom claims + groups := token.Claims["groups"].([]interface{}) + email := token.Claims["email"].(string) + + // Custom logic to map groups/email to Temporal roles + claims.Namespaces = make(map[string]authorization.Role) + + for _, group := range groups { + switch group.(string) { + case "engineering": + claims.Namespaces["development"] = authorization.RoleWriter + case "ops": + claims.Namespaces["production"] = authorization.RoleWriter + case "management": + claims.System = authorization.RoleReader + } + } + + return claims, nil +} +``` + +## Troubleshooting + +### Issue: "PermissionDenied" for all operations + +**Solution**: Check that: +1. JWT token contains the `permissions` claim +2. JWKS URL is correct and accessible from Temporal server +3. Token signature is valid +4. ClaimMapper and Authorizer are both configured + +### Issue: UI can authenticate but server rejects calls + +**Solution**: Ensure the UI is passing the JWT token to the server: +- The token must be in the `authorization` header as `Bearer ` +- Check UI logs to see if token is being sent + +### Issue: Permissions not being extracted + +**Solution**: +1. Verify the `permissionsClaimName` matches your JWT claim name +2. Check that permissions are in the correct format: `"namespace:role"` +3. Enable debug logging to see what claims are being extracted + +## Security Best Practices + +1. **Use HTTPS**: Always use TLS for Keycloak and Temporal +2. **Short Token Lifetime**: Set JWT expiration to 15-60 minutes +3. **Rotate Keys**: Regularly rotate signing keys in Keycloak +4. **Principle of Least Privilege**: Grant minimum necessary permissions +5. **Audit Logging**: Enable Temporal's audit logging to track access +6. **Service Accounts**: Use dedicated service accounts for workers with `worker` role only + +## Alternative Approaches + +### Reverse Proxy Authorization + +If building a custom Temporal server is too complex, you can implement authorization at the reverse proxy level: + +1. Use a proxy (e.g., Nginx, Traefik, Envoy) in front of Temporal +2. Validate JWT and extract claims in the proxy +3. Block requests based on claims before they reach Temporal +4. More limited but easier to implement + +### Custom Auth Service + +Create a separate auth service that: +1. Validates tokens +2. Checks permissions +3. Issues short-lived Temporal-specific tokens +4. Acts as a gateway between UI and Temporal server + +## References + +- [Temporal Security Documentation](https://docs.temporal.io/self-hosted-guide/security) +- [Authorizer Sample](https://github.com/temporalio/samples-server/tree/main/extensibility/authorizer) +- [Keycloak Protocol Mappers](https://www.keycloak.org/docs/latest/server_admin/#_protocol-mappers) +- [JWT.io](https://jwt.io) - Tool for inspecting JWT tokens + +## Need Help? + +Common issues and where to get help: +- Temporal Community Forum: https://community.temporal.io/ +- Temporal Slack: https://temporal.io/slack +- GitHub Issues: https://github.com/temporalio/temporal/issues diff --git a/DIAGRAMS.md b/DIAGRAMS.md new file mode 100644 index 0000000..ba4c976 --- /dev/null +++ b/DIAGRAMS.md @@ -0,0 +1,413 @@ +# Temporal Authorization Flow Diagrams + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Your Organization │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Users │────────▶│ Keycloak │───────▶│ Temporal UI │ │ +│ │ │ Login │ (OIDC) │ JWT │ │ │ +│ └──────────┘ └──────────────┘ └──────┬───────┘ │ +│ │ │ │ +│ │ JWT │ │ +│ │ with │ API │ +│ │ permissions │ Calls │ +│ ▼ │ │ +│ ┌─────────────┐ │ │ +│ │ JWKS │ │ │ +│ │ Public Keys │ │ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │ Temporal Server│ │ +│ │ │ │ +│ │ ┌────────────┐ │ │ +│ │ │ ClaimMapper│ │ │ +│ │ │ (extracts) │ │ │ +│ │ └─────┬──────┘ │ │ +│ │ │ │ │ +│ │ ┌─────▼──────┐ │ │ +│ │ │ Authorizer │ │ │ +│ │ │ (allow/ │ │ │ +│ │ │ deny) │ │ │ +│ │ └────────────┘ │ │ +│ └────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Detailed Request Flow + +### Step 1: User Authentication + +``` +┌────────┐ ┌──────────┐ +│ User │ │ Keycloak │ +└───┬────┘ └────┬─────┘ + │ │ + │ 1. Navigate to Temporal UI │ + │─────────────────────────────────────────▶ │ + │ │ + │ 2. Redirect to Keycloak login │ + │◀───────────────────────────────────────── │ + │ │ + │ 3. Enter credentials │ + │─────────────────────────────────────────▶ │ + │ │ + │ 4. Check user groups │ + │ (e.g., dev-team) │ + │ │ + │ 5. Generate JWT with permissions │ + │ { │ + │ "groups": ["dev-team"], │ + │ "permissions": [ │ + │ "development:write", │ + │ "staging:write", │ + │ "production:read" │ + │ ] │ + │ } │ + │◀───────────────────────────────────────── │ + │ │ + │ 6. Redirect back to UI with JWT │ + │─────────────────────────────────────────▶ │ + │ │ +``` + +### Step 2: API Request Authorization + +``` +┌─────────────┐ ┌─────────────────┐ ┌────────────┐ +│ Temporal UI │ │ Temporal Server │ │ Database │ +└──────┬──────┘ └────────┬────────┘ └─────┬──────┘ + │ │ │ + │ 1. API Request │ │ + │ Authorization: │ │ + │ Bearer │ │ + │─────────────────────────▶│ │ + │ │ │ + │ │ 2. Extract JWT │ + │ │ from header │ + │ │ │ + │ │ 3. Verify JWT │ + │ │ signature │ + │ │ (using JWKS) │ + │ │ │ + │ │ 4. ClaimMapper │ + │ │ extracts │ + │ │ permissions │ + │ │ │ + │ │ 5. Authorizer │ + │ │ checks if │ + │ │ operation │ + │ │ allowed │ + │ │ │ + │ │ Target: │ + │ │ - Namespace: dev │ + │ │ - API: StartWF │ + │ │ │ + │ │ Claims: │ + │ │ - dev:write ✓ │ + │ │ │ + │ │ 6. Execute request │ + │ │────────────────────────▶ + │ │ │ + │ │ 7. Return data │ + │ │◀──────────────────────── + │ │ │ + │ 8. Return response │ │ + │◀─────────────────────────│ │ + │ │ │ +``` + +### Step 3: Authorization Decision Tree + +``` + Incoming API Request + │ + ▼ + ┌──────────────────────┐ + │ Extract JWT Token │ + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Verify Signature │ + │ (using JWKS) │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ Valid Signature? │ + └──────────┬───────────┘ + │ + ┌──────────┴───────────┐ + │ │ + ❌ NO ✅ YES + │ │ + ▼ ▼ + Return 401 ┌────────────────┐ + Unauthorized │ ClaimMapper │ + │ Extract perms │ + └────────┬───────┘ + │ + ┌────────▼────────┐ + │ Get target: │ + │ - Namespace │ + │ - Operation │ + └────────┬────────┘ + │ + ┌────────▼────────────────┐ + │ Authorizer: │ + │ Check if claims allow │ + │ operation on namespace │ + └────────┬────────────────┘ + │ + ┌────────▼────────┐ + │ Authorized? │ + └────────┬────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ❌ NO ✅ YES + │ │ + ▼ ▼ + Return 403 Execute API + Permission Denied Return Result +``` + +## Permission Matching Examples + +### Example 1: Developer accessing Development namespace + +``` +User JWT: +{ + "permissions": [ + "development:write", + "staging:write", + "production:read" + ] +} + +API Request: +- Namespace: "development" +- Operation: "StartWorkflowExecution" +- Required: write permission + +Authorization Check: +✅ "development:write" in permissions +✅ write >= write required +✅ ALLOW +``` + +### Example 2: Developer accessing Production namespace + +``` +User JWT: +{ + "permissions": [ + "development:write", + "staging:write", + "production:read" + ] +} + +API Request: +- Namespace: "production" +- Operation: "StartWorkflowExecution" +- Required: write permission + +Authorization Check: +✅ "production:read" in permissions +❌ read < write required +❌ DENY - PermissionDenied +``` + +### Example 3: Viewer accessing any namespace + +``` +User JWT: +{ + "permissions": [ + "development:read", + "staging:read", + "production:read" + ] +} + +API Request: +- Namespace: "production" +- Operation: "DescribeWorkflowExecution" +- Required: read permission + +Authorization Check: +✅ "production:read" in permissions +✅ read >= read required +✅ ALLOW +``` + +### Example 4: Admin with system access + +``` +User JWT: +{ + "permissions": [ + "temporal-system:admin" + ] +} + +API Request: +- Namespace: "any-namespace" +- Operation: "any-operation" + +Authorization Check: +✅ "temporal-system:admin" grants all access +✅ ALLOW +``` + +## Group to Permission Mapping Flow + +``` +Keycloak Groups Script Mapper JWT Permissions +┌──────────────┐ ┌─────────────┐ ┌──────────────────┐ +│ temporal- │───────▶│ if group │──────────▶│ temporal-system │ +│ admins │ │ == admins │ │ :admin │ +└──────────────┘ └─────────────┘ └──────────────────┘ + +┌──────────────┐ ┌─────────────┐ ┌──────────────────┐ +│ dev-team │───────▶│ if group │──────────▶│ development:write│ +│ │ │ == dev │ │ staging:write │ +└──────────────┘ └─────────────┘ │ production:read │ + └──────────────────┘ + +┌──────────────┐ ┌─────────────┐ ┌──────────────────┐ +│ ops-team │───────▶│ if group │──────────▶│ production:admin │ +│ │ │ == ops │ │ staging:admin │ +└──────────────┘ └─────────────┘ └──────────────────┘ + +┌──────────────┐ ┌─────────────┐ ┌──────────────────┐ +│ viewers │───────▶│ if group │──────────▶│ development:read │ +│ │ │ == viewers │ │ staging:read │ +└──────────────┘ └─────────────┘ │ production:read │ + └──────────────────┘ +``` + +## System Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Temporal Server │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Frontend Service │ │ +│ │ │ │ +│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ ClaimMapper │ │ Authorizer │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Input: │ │ Input: │ │ │ +│ │ │ - JWT token │ │ - Claims │ │ │ +│ │ │ │ │ - CallTarget │ │ │ +│ │ │ Process: │ │ │ │ │ +│ │ │ 1. Verify signature │ │ Process: │ │ │ +│ │ │ 2. Extract claims │ │ 1. Check namespace │ │ │ +│ │ │ 3. Parse perms │ │ 2. Check operation │ │ │ +│ │ │ │ │ 3. Compare with │ │ │ +│ │ │ Output: │ │ claims │ │ │ +│ │ │ - Claims object │──▶ │ │ │ +│ │ │ │ │ Output: │ │ │ +│ │ │ │ │ - Allow/Deny │ │ │ +│ │ └──────────────────────┘ └──────────────────────┘ │ │ +│ │ │ │ │ +│ └──────────────────────────────────────┼─────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────▼─────────────────┐ │ +│ │ API Handler │ │ +│ │ (executes if allowed) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Deployment Architecture + +``` + Internet + │ + │ HTTPS + ▼ + ┌────────────────┐ + │ Caddy Proxy │ + │ (TLS Term) │ + └────────┬───────┘ + │ + ┌──────────────┴──────────────┐ + │ │ + ▼ ▼ +┌───────────────┐ ┌─────────────────┐ +│ Temporal UI │ │ Keycloak │ +│ (port 8080) │◀─────────▶│ (OIDC) │ +└───────┬───────┘ JWT └─────────────────┘ + │ │ + │ gRPC │ JWKS + │ with JWT │ + ▼ ▼ +┌───────────────────────┐ ┌────────────────┐ +│ Temporal Server │───│ Public Keys │ +│ (port 7233) │ └────────────────┘ +│ │ +│ - ClaimMapper │ +│ - Authorizer │ +└──────────┬───────────┘ + │ + │ SQL + ▼ + ┌─────────────┐ + │ PostgreSQL │ + └─────────────┘ +``` + +## Data Flow Timeline + +``` +Time User Keycloak Temporal UI Temporal Server +──┼──────┼──────────────┼──────────────┼──────────────┼─────────── +0s│ Login │ │ + │──────────────────▶ │ │ + │ │ │ +1s│ Authenticate │ │ + │ │ │ │ + │ Verify │ │ + │ Generate JWT │ │ + │◀───────────────── │ │ + │ JWT Token │ │ + │ │ │ +2s│ Access UI │ │ + │───────────────────────────────────▶│ │ + │ │ │ +3s│ │ List Workflows + │ │ (with JWT) │ + │ │─────────────▶│ + │ │ Verify JWT + │ │ Extract perms + │ │ Authorize + │ │ Query DB + │ │◀─────────────│ + │ │ Workflows │ + │◀───────────────────────────────────│ │ + │ Display │ │ + │ │ │ +4s│ Start Workflow │ │ + │───────────────────────────────────▶│ │ + │ │ StartWF │ + │ │ (with JWT) │ + │ │─────────────▶│ + │ │ Verify JWT + │ │ Extract perms + │ │ Check: write? + │ │ ✓ Authorized + │ │ Start WF + │ │◀─────────────│ + │ │ Success │ + │◀───────────────────────────────────│ │ + │ Confirmation │ │ +``` diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..25e5336 --- /dev/null +++ b/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,443 @@ +# Temporal Authorization Implementation Checklist + +Use this checklist to implement authorization step-by-step. + +## ✅ Phase 1: Understanding (15 mins) + +- [ ] Read `README_AUTHORIZATION.md` (high-level overview) +- [ ] Review `DIAGRAMS.md` (understand the flow) +- [ ] Read `QUICK_REFERENCE.md` (understand permission format) +- [ ] Understand the concept: Groups → Permissions → Allow/Deny + +**Key Concept**: Users in Keycloak groups get permissions like `"namespace:role"` in their JWT, which Temporal uses to decide what they can do. + +--- + +## ✅ Phase 2: Verify Current Setup (15 mins) + +- [ ] Verify Temporal UI authentication is working +- [ ] Test that you can login via Keycloak +- [ ] Confirm `TEMPORAL_AUTH_ISSUER_URL` environment variable is set +- [ ] Run test script: `./test-authorization.sh` +- [ ] Note: JWT probably does NOT have `permissions` claim yet (that's OK) + +**Expected Result**: You can login, but JWT doesn't have permissions yet. + +--- + +## ✅ Phase 3: Configure Keycloak (30 mins) + +### 3.1: Create Groups + +Login to Keycloak Admin Console: + +- [ ] Navigate to: Groups +- [ ] Create group: `temporal-admins` +- [ ] Create group: `dev-team` +- [ ] Create group: `ops-team` +- [ ] Create group: `viewers` + +### 3.2: Add Protocol Mapper + +- [ ] Navigate to: Clients → [your-temporal-ui-client] +- [ ] Go to: Client Scopes tab +- [ ] Select the client scope (usually named same as client) +- [ ] Click: Mappers tab +- [ ] Click: Add mapper → By configuration +- [ ] Select: Script Mapper +- [ ] Configure: + - Name: `temporal-permissions` + - Mapper Type: `Script Mapper` + - Token Claim Name: `permissions` + - Claim JSON Type: `JSON` + - Add to ID token: `ON` + - Add to access token: `ON` + - Add to userinfo: `ON` + - Multivalued: `ON` + - Script: Copy from `custom-server/example-keycloak-mapper.json` +- [ ] Click: Save + +### 3.3: Customize Mapper (Optional) + +Edit the script in the mapper to match your organization: + +```javascript +// Example: Map your groups to permissions +if (groupName === 'your-admin-group') { + permissions.add('temporal-system:admin'); +} +else if (groupName === 'your-dev-group') { + permissions.add('development:write'); + permissions.add('staging:write'); +} +// ... etc +``` + +- [ ] Customize group-to-permission mappings if needed +- [ ] Save changes + +### 3.4: Assign Users to Groups + +- [ ] Navigate to: Users +- [ ] Select a test user +- [ ] Go to: Groups tab +- [ ] Click: Join Group +- [ ] Add user to `dev-team` (or another group) +- [ ] Repeat for other test users + +### 3.5: Verify JWT + +- [ ] Run test script again: `./test-authorization.sh` +- [ ] Verify JWT now contains `permissions` array +- [ ] Verify permissions match expected format: `"namespace:role"` + +**Expected Result**: JWT contains permissions like `["development:write", "staging:write"]` + +--- + +## ✅ Phase 4: Prepare Custom Server (15 mins) + +### 4.1: Review Configuration + +- [ ] Review `custom-server/config/development.yaml` +- [ ] Verify JWKS URL format: `https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs` +- [ ] Update if your realm name is different + +### 4.2: Review Server Code + +- [ ] Review `custom-server/main.go` +- [ ] Understand: It uses Temporal's default ClaimMapper and Authorizer +- [ ] No code changes needed for basic setup + +### 4.3: Create go.sum + +```bash +cd /srv/temporal/custom-server +go mod download +go mod tidy +``` + +- [ ] Run commands above +- [ ] Verify `go.sum` is created +- [ ] Check for any errors + +--- + +## ✅ Phase 5: Update Docker Compose (10 mins) + +### 5.1: Backup Current Config + +```bash +cp compose.yaml compose.yaml.backup +``` + +- [ ] Create backup + +### 5.2: Update Temporal Service + +Edit `compose.yaml`: + +```yaml +temporal: + # OLD: image: temporalio/auto-setup:1.29.0 + # NEW: + build: + context: ./custom-server + dockerfile: Dockerfile + # ... rest stays the same +``` + +- [ ] Change from `image:` to `build:` +- [ ] Point to `./custom-server` directory + +### 5.3: Add Environment Variable + +Make sure this exists in temporal service: + +```yaml +environment: + # ... existing vars + - TEMPORAL_AUTH_ISSUER_URL=${TEMPORAL_AUTH_ISSUER_URL} +``` + +- [ ] Verify `TEMPORAL_AUTH_ISSUER_URL` is in environment +- [ ] Verify it's defined in your `.env` file + +--- + +## ✅ Phase 6: Build and Deploy (15 mins) + +### 6.1: Build Custom Server + +```bash +cd /srv/temporal +docker-compose build temporal +``` + +- [ ] Run build command +- [ ] Wait for build to complete (may take 5-10 mins first time) +- [ ] Check for errors + +### 6.2: Deploy + +#### If using Docker Compose: + +```bash +docker-compose down +docker-compose up -d +``` + +#### If using Docker Swarm: + +```bash +docker stack deploy temporal --detach=true -c compose.yaml +``` + +- [ ] Deploy updated stack +- [ ] Wait for services to start + +### 6.3: Verify Deployment + +```bash +# Check logs +docker-compose logs temporal +# OR +docker service logs temporal_temporal + +# Look for: +# "Starting Temporal Server with JWT Authorization..." +# "All services are started" +``` + +- [ ] Check logs for startup +- [ ] Verify no errors +- [ ] Confirm server is running + +--- + +## ✅ Phase 7: Create Namespaces (5 mins) + +Create namespaces that match your permission names: + +```bash +# As admin user (in temporal-admins group) +docker exec -it tctl namespace register development +docker exec -it tctl namespace register staging +docker exec -it tctl namespace register production +``` + +- [ ] Create `development` namespace +- [ ] Create `staging` namespace +- [ ] Create `production` namespace +- [ ] Create any other namespaces you need + +--- + +## ✅ Phase 8: Test Authorization (30 mins) + +### 8.1: Test Admin User + +- [ ] Login as user in `temporal-admins` group +- [ ] Verify can access all namespaces +- [ ] Verify can start workflows +- [ ] Verify can terminate workflows +- [ ] Verify can see all operations + +### 8.2: Test Developer User + +- [ ] Login as user in `dev-team` group +- [ ] Verify can access `development` namespace +- [ ] Verify can start workflow in `development` ✓ +- [ ] Verify can access `production` namespace (read-only) +- [ ] Try to start workflow in `production` - should FAIL ✗ +- [ ] Verify error message: "PermissionDenied" + +### 8.3: Test Viewer User + +- [ ] Login as user in `viewers` group +- [ ] Verify can view workflows in all namespaces +- [ ] Try to start any workflow - should FAIL ✗ +- [ ] Try to terminate workflow - should FAIL ✗ +- [ ] Verify can only perform read operations + +### 8.4: Test No-Access User + +- [ ] Login as user NOT in any Temporal groups +- [ ] Try to access any namespace +- [ ] Should see: PermissionDenied or no data + +### 8.5: Verify Logs + +```bash +# Check authorization decisions in logs +docker-compose logs temporal | grep -i "authoriz" +``` + +- [ ] Check logs show authorization checks +- [ ] Look for Allow/Deny decisions +- [ ] Verify permissions are being extracted from JWT + +--- + +## ✅ Phase 9: Production Hardening (Optional) + +### 9.1: Security + +- [ ] Use HTTPS for Keycloak (if not already) +- [ ] Use HTTPS for Temporal UI (if not already) +- [ ] Verify JWT tokens have short expiration (15-60 mins) +- [ ] Set up key rotation in Keycloak +- [ ] Review Temporal audit logging + +### 9.2: Monitoring + +- [ ] Add authorization metrics to monitoring +- [ ] Set up alerts for frequent PermissionDenied errors +- [ ] Monitor JWT validation failures + +### 9.3: Documentation + +- [ ] Document your permission scheme for team +- [ ] Create onboarding guide for new users +- [ ] Document which groups map to which permissions +- [ ] Create troubleshooting guide + +--- + +## ✅ Phase 10: Maintenance + +### 10.1: Adding New Users + +- [ ] Add user to Keycloak +- [ ] Assign to appropriate group +- [ ] User can immediately login with correct permissions + +### 10.2: Changing Permissions + +- [ ] Move user to different group in Keycloak +- [ ] User gets new permissions on next login +- [ ] (JWT refresh may take up to token expiration time) + +### 10.3: Adding New Namespaces + +- [ ] Create namespace in Temporal +- [ ] Update Keycloak mapper script to include new namespace +- [ ] Users in appropriate groups get access + +--- + +## ❌ Troubleshooting + +If things go wrong, check: + +### Issue: "PermissionDenied" for all users + +**Check**: +- [ ] JWT contains `permissions` claim (run `./test-authorization.sh`) +- [ ] Permissions format is correct: `"namespace:role"` +- [ ] JWKS URL is accessible from Temporal server +- [ ] Temporal server logs show JWT validation success + +**Fix**: +- Review Keycloak mapper configuration +- Verify `permissionsClaimName` in config matches JWT claim +- Check network connectivity to Keycloak + +### Issue: Server won't start + +**Check**: +- [ ] Docker build completed successfully +- [ ] `go.mod` and `go.sum` exist +- [ ] Configuration YAML is valid +- [ ] Environment variables are set + +**Fix**: +- Check server logs: `docker logs ` +- Verify YAML syntax: `yamllint custom-server/config/development.yaml` +- Rebuild: `docker-compose build --no-cache temporal` + +### Issue: JWT signature validation fails + +**Check**: +- [ ] JWKS endpoint is correct +- [ ] JWKS endpoint is accessible from container +- [ ] Token hasn't expired +- [ ] Issuer matches expected value + +**Fix**: +- Test JWKS: `curl https://keycloak.com/realms/realm/protocol/openid-connect/certs` +- Verify `TEMPORAL_AUTH_ISSUER_URL` environment variable +- Check Temporal can reach Keycloak (network/firewall) + +### Issue: UI can't connect to server + +**Check**: +- [ ] Temporal server is running +- [ ] No errors in server logs +- [ ] UI can reach server (network) +- [ ] JWT is being passed from UI to server + +**Fix**: +- Restart UI: `docker-compose restart ui` +- Check UI logs for auth errors +- Verify UI environment variables are correct + +--- + +## 📊 Success Criteria + +You'll know it's working when: + +1. ✅ Different users see different things in Temporal UI +2. ✅ Users in `dev-team` can manage dev namespace but not production +3. ✅ Users in `viewers` can only view, not modify +4. ✅ Users in `temporal-admins` can do everything +5. ✅ Users not in any group are denied access +6. ✅ Server logs show authorization checks happening +7. ✅ "PermissionDenied" errors appear for unauthorized actions + +--- + +## 📚 Reference + +- **Full Guide**: `AUTHORIZATION_GUIDE.md` +- **Quick Reference**: `QUICK_REFERENCE.md` +- **Flow Diagrams**: `DIAGRAMS.md` +- **Test Script**: `./test-authorization.sh` +- **Alternative Approach**: `REVERSE_PROXY_APPROACH.md` + +--- + +## ⏱️ Time Tracking + +- [ ] Phase 1: Understanding (15 mins) +- [ ] Phase 2: Verify Setup (15 mins) +- [ ] Phase 3: Configure Keycloak (30 mins) +- [ ] Phase 4: Prepare Server (15 mins) +- [ ] Phase 5: Update Compose (10 mins) +- [ ] Phase 6: Build & Deploy (15 mins) +- [ ] Phase 7: Create Namespaces (5 mins) +- [ ] Phase 8: Test (30 mins) + +**Total**: ~2.5 hours + +--- + +## 🎯 Current Status + +Track your progress: + +- [ ] Started implementation +- [ ] Keycloak configured +- [ ] Server built +- [ ] Server deployed +- [ ] Basic testing done +- [ ] All test cases passing +- [ ] Production ready +- [ ] Documentation complete + +Last updated: _______________ + +--- + +Good luck with your implementation! 🚀 diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..e11cb45 --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,183 @@ +# Temporal Authorization Quick Reference + +## TL;DR + +You need to: +1. Configure Keycloak to add a `permissions` claim to JWT tokens +2. Build a custom Temporal server with ClaimMapper and Authorizer +3. Deploy the custom server instead of the standard image + +## Permission Format + +``` +: +``` + +Example: `"production:read"` = read-only access to production namespace + +## Available Roles + +| Role | Can View | Can Start/Signal | Can Terminate | Can Administer | +|------|----------|------------------|---------------|----------------| +| read | ✅ | ❌ | ❌ | ❌ | +| write | ✅ | ✅ | ✅ | ❌ | +| worker | ✅ | ✅ (as worker) | ❌ | ❌ | +| admin | ✅ | ✅ | ✅ | ✅ | + +## Special Namespace + +`temporal-system:admin` = Full cluster administrator access + +## Keycloak Setup (One-Time) + +### 1. Create Groups +``` +temporal-admins → temporal-system:admin +dev-team → development:write, staging:write, production:read +ops-team → production:admin, staging:admin +viewers → *:read +``` + +### 2. Add Protocol Mapper + +**Type**: Script Mapper +**Claim Name**: `permissions` +**Script**: See `custom-server/example-keycloak-mapper.json` + +### 3. Assign Users to Groups + +Users → [user] → Groups → Join + +## Temporal Server Setup (One-Time) + +### 1. Add JWT Configuration + +Edit `custom-server/config/development.yaml`: + +```yaml +global: + authorization: + jwtKeyProvider: + keySourceURIs: + - "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs" + refreshInterval: "1h" + permissionsClaimName: "permissions" +``` + +### 2. Build Custom Server + +```bash +cd /srv/temporal +docker-compose build temporal +docker-compose up -d temporal +``` + +## Verification + +### Test JWT Token +```bash +./test-authorization.sh +``` + +Look for `permissions` array in output. + +### Test Access +1. Login as user in `dev-team` +2. Access development namespace ✅ +3. Start workflow in development ✅ +4. Access production namespace ✅ (read-only) +5. Start workflow in production ❌ (should fail) + +## Common Patterns + +### Admin User +```json +{"permissions": ["temporal-system:admin"]} +``` + +### Developer +```json +{ + "permissions": [ + "development:write", + "staging:write", + "production:read" + ] +} +``` + +### Read-Only Viewer +```json +{ + "permissions": [ + "development:read", + "staging:read", + "production:read" + ] +} +``` + +### Worker Service Account +```json +{ + "permissions": [ + "production:worker", + "staging:worker" + ] +} +``` + +## Troubleshooting + +### "PermissionDenied" Error +1. Check JWT contains `permissions` claim +2. Verify format: `"namespace:role"` +3. Check JWKS URL is accessible +4. Ensure namespace name matches exactly + +### No `permissions` in JWT +1. Check Keycloak mapper is configured +2. Verify user is in a group +3. Check mapper script logic +4. Test token: `./test-authorization.sh` + +### Server Won't Start +1. Check logs: `docker logs temporal_temporal_1` +2. Verify JWKS URL in config +3. Run `go mod tidy` in custom-server directory + +## Files + +| File | Purpose | +|------|---------| +| `custom-server/main.go` | Custom server with auth | +| `custom-server/config/development.yaml` | Temporal config with JWT | +| `custom-server/example-keycloak-mapper.json` | Keycloak mapper config | +| `test-authorization.sh` | Test JWT tokens | +| `AUTHORIZATION_GUIDE.md` | Full documentation | + +## Architecture + +``` +User → Keycloak → JWT with permissions → Temporal UI → Temporal Server + ↓ + ClaimMapper (extract) + ↓ + Authorizer (allow/deny) +``` + +## Next Steps + +1. ✅ Configure Keycloak mapper +2. ✅ Test JWT token format +3. ✅ Build custom Temporal server +4. ✅ Deploy and test +5. ✅ Create namespaces matching permissions +6. ✅ Test with different user groups + +## Support + +- Documentation: `/srv/temporal/AUTHORIZATION_GUIDE.md` +- Temporal Docs: https://docs.temporal.io/self-hosted-guide/security +- Sample Code: https://github.com/temporalio/samples-server/tree/main/extensibility/authorizer +- Forum: https://community.temporal.io/ diff --git a/README_AUTHORIZATION.md b/README_AUTHORIZATION.md new file mode 100644 index 0000000..f58b3e0 --- /dev/null +++ b/README_AUTHORIZATION.md @@ -0,0 +1,232 @@ +# Implementation Summary + +## What You Asked For + +You want to restrict Temporal UI access so that only certain users (based on Keycloak groups/roles) can log in or view workflows, instead of everyone who can authenticate having full access. + +## The Solution + +Temporal provides an **Authorizer Plugin** system that works with your existing OIDC setup. Here's what you need to do: + +### 1. Configure Keycloak (5 minutes) + +Add a protocol mapper that transforms user groups into a `permissions` claim in the JWT token. + +**File**: `custom-server/example-keycloak-mapper.json` + +This mapper converts: +- Keycloak group `temporal-admins` → Permission `temporal-system:admin` +- Keycloak group `dev-team` → Permissions `development:write`, `staging:write`, `production:read` +- etc. + +### 2. Build Custom Temporal Server (10 minutes) + +Replace the standard Temporal Docker image with a custom build that includes the Authorizer and ClaimMapper. + +**Files**: +- `custom-server/main.go` - Server with authorization +- `custom-server/Dockerfile` - Docker build +- `custom-server/config/development.yaml` - Configuration + +The custom server: +- Uses Temporal's built-in JWT ClaimMapper to extract permissions from tokens +- Uses Temporal's default Authorizer to make allow/deny decisions +- Validates JWT signatures using Keycloak's public keys (JWKS) + +### 3. Deploy (5 minutes) + +Update your Docker Compose to build and use the custom server, then redeploy. + +## How It Works + +``` +User logs in → Keycloak issues JWT → JWT includes permissions → +UI sends JWT with API calls → Server validates JWT → +Server extracts permissions → Server checks if operation allowed → +Allow or Deny +``` + +## Key Files Created + +1. **`AUTHORIZATION_GUIDE.md`** - Complete implementation guide +2. **`QUICK_REFERENCE.md`** - Quick lookup for common tasks +3. **`custom-server/`** - Custom Temporal server code +4. **`test-authorization.sh`** - Script to test your JWT tokens +5. **`DIAGRAMS.md`** - Visual flow diagrams +6. **`REVERSE_PROXY_APPROACH.md`** - Alternative simpler approach + +## Next Steps + +### Phase 1: Test Your Current Setup (15 minutes) + +```bash +# Test your JWT tokens contain the right claims +./test-authorization.sh +``` + +This will show you what's currently in your JWT tokens from Keycloak. + +### Phase 2: Configure Keycloak (15 minutes) + +1. Create groups in Keycloak (e.g., `temporal-admins`, `dev-team`, `ops-team`) +2. Add the protocol mapper from `custom-server/example-keycloak-mapper.json` +3. Assign users to groups +4. Re-run the test script to verify permissions appear in JWT + +### Phase 3: Deploy Custom Server (30 minutes) + +```bash +# Build the custom server +cd /srv/temporal +docker-compose build temporal + +# Deploy +docker stack deploy temporal --detach=true -c compose.yaml +# OR +docker-compose up -d +``` + +### Phase 4: Test Authorization (15 minutes) + +1. Login as user in `dev-team` group +2. Create namespace: `development` +3. Try to start workflow - should work +4. Login as user in `viewers` group +5. Try to start workflow - should fail with PermissionDenied + +## Permission Examples + +### Full Admin +```json +{"permissions": ["temporal-system:admin"]} +``` +Can do everything in all namespaces. + +### Developer +```json +{ + "permissions": [ + "development:write", + "staging:write", + "production:read" + ] +} +``` +Can manage dev/staging, but only view production. + +### Viewer +```json +{ + "permissions": [ + "development:read", + "production:read" + ] +} +``` +Can only view workflows, cannot start or modify. + +### Worker Service Account +```json +{ + "permissions": [ + "production:worker" + ] +} +``` +Can execute workflows but not manage them via UI. + +## Common Issues & Solutions + +### Issue: "PermissionDenied" for everyone +**Solution**: JWT doesn't contain `permissions` claim. Run `./test-authorization.sh` to verify. + +### Issue: Server won't start +**Solution**: Check JWKS URL is accessible from container. Verify config syntax. + +### Issue: Permissions not working +**Solution**: Ensure namespace names in permissions match your actual namespace names exactly. + +## Architecture Comparison + +### Before (Current) +``` +User → Keycloak → Temporal UI → Temporal Server + (auth only) (no authorization) + ↓ + Everyone has full access +``` + +### After (With Authorization) +``` +User → Keycloak → JWT with permissions → Temporal UI → Temporal Server + ↓ + ClaimMapper + ↓ + Authorizer + ↓ + Allow/Deny based on + permissions in JWT +``` + +## Customization Options + +### Option 1: Use Default JWT ClaimMapper (Recommended) +- Simplest approach +- Keycloak maps groups to permissions format +- Temporal extracts and validates +- **Best for**: Standard use cases + +### Option 2: Custom ClaimMapper +- Write Go code to extract claims +- More flexible logic (e.g., email domain-based) +- **Best for**: Complex authorization rules + +### Option 3: Reverse Proxy +- Validate JWT at proxy level (Caddy/Nginx) +- No custom Temporal build +- Less granular control +- **Best for**: Simple "can access or not" logic + +## Production Checklist + +- [ ] Configure Keycloak groups and protocol mapper +- [ ] Test JWT tokens contain correct permissions +- [ ] Build and test custom Temporal server locally +- [ ] Create Temporal namespaces matching permission names +- [ ] Update deployment configuration +- [ ] Deploy to production +- [ ] Test with different user groups +- [ ] Monitor authorization logs +- [ ] Document your permission scheme for team + +## Support Resources + +- **Full Guide**: `AUTHORIZATION_GUIDE.md` (comprehensive documentation) +- **Quick Ref**: `QUICK_REFERENCE.md` (cheat sheet) +- **Test Script**: `./test-authorization.sh` (verify JWT) +- **Diagrams**: `DIAGRAMS.md` (visual flows) + +- **Temporal Docs**: https://docs.temporal.io/self-hosted-guide/security +- **Sample Code**: https://github.com/temporalio/samples-server/tree/main/extensibility/authorizer +- **Forum**: https://community.temporal.io/ + +## Estimated Time + +- **Reading/Understanding**: 30 minutes +- **Keycloak Configuration**: 15 minutes +- **Building Custom Server**: 15 minutes +- **Deployment**: 15 minutes +- **Testing**: 15 minutes + +**Total**: ~90 minutes for full implementation + +## Questions? + +All the details are in `AUTHORIZATION_GUIDE.md`. Start there if you need step-by-step instructions. + +The key insight: Temporal's authorization system is designed for exactly your use case - restricting access based on OIDC groups/roles. You just need to: +1. Map groups to permissions in Keycloak +2. Build a server that validates those permissions +3. Deploy it + +Good luck! 🚀 diff --git a/REVERSE_PROXY_APPROACH.md b/REVERSE_PROXY_APPROACH.md new file mode 100644 index 0000000..8537dd9 --- /dev/null +++ b/REVERSE_PROXY_APPROACH.md @@ -0,0 +1,225 @@ +# Alternative Approach: Reverse Proxy Authorization + +If building a custom Temporal server seems too complex, you can implement authorization at the reverse proxy level using Caddy, which you're already using. + +## How It Works + +``` +Browser → Keycloak → Temporal UI → Caddy (validates JWT) → Temporal Server + ↓ + (blocks if unauthorized) +``` + +## Pros and Cons + +### Pros ✅ +- Simpler to implement - no custom Temporal server build +- Centralized authorization logic in one place +- Can protect any backend service, not just Temporal +- Easier to debug and modify + +### Cons ❌ +- Less granular control (can't enforce per-namespace/per-operation) +- All-or-nothing access (either user can access Temporal, or they can't) +- Harder to implement complex authorization logic +- Can't leverage Temporal's built-in authorization features + +## Implementation with Caddy + +### Option 1: JWT Validation Plugin + +Use Caddy with the JWT plugin to validate tokens: + +```caddyfile +{ + order jwtauth before basicauth +} + +# Temporal UI endpoint +{$DOMAIN} { + # Validate JWT on all Temporal UI requests + jwtauth { + # Your Keycloak JWKS endpoint + jwks_url https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs + + # Required claims + claim_check sub exists + claim_check groups contains temporal-admins temporal-users + + # Token location + token_source header Authorization + strip_prefix Bearer + } + + reverse_proxy temporal-ui:8080 +} + +# Temporal Server gRPC endpoint (if exposed) +{$DOMAIN}:7233 { + jwtauth { + jwks_url https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs + claim_check groups contains temporal-admins temporal-users temporal-workers + token_source metadata authorization + } + + reverse_proxy h2c://temporal:7233 +} +``` + +Install the JWT plugin: +```bash +caddy add-package github.com/ggicci/caddy-jwt +``` + +### Option 2: Forward Auth to External Service + +Create a simple auth service that validates the JWT and checks groups: + +```yaml +# docker-compose.yaml +services: + auth-service: + image: your-auth-service + environment: + - JWKS_URL=https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs + - ALLOWED_GROUPS=temporal-admins,temporal-users + networks: + - proxy +``` + +```caddyfile +{$DOMAIN} { + forward_auth auth-service:8080 { + uri /verify + copy_headers Authorization + } + + reverse_proxy temporal-ui:8080 +} +``` + +Auth service code (Python example): + +```python +from flask import Flask, request, jsonify +import jwt +import requests +from functools import lru_cache + +app = Flask(__name__) + +@lru_cache(maxsize=1) +def get_jwks(): + response = requests.get(os.getenv('JWKS_URL')) + return response.json() + +@app.route('/verify', methods=['GET', 'POST']) +def verify(): + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Missing token'}), 401 + + token = auth_header.split(' ')[1] + + try: + # Verify token + jwks = get_jwks() + # ... JWT verification logic + + # Check groups + groups = decoded_token.get('groups', []) + allowed_groups = os.getenv('ALLOWED_GROUPS', '').split(',') + + if not any(g in allowed_groups for g in groups): + return jsonify({'error': 'Insufficient permissions'}), 403 + + return jsonify({'status': 'ok'}), 200 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token'}), 401 +``` + +### Option 3: Nginx with JWT Module + +If you prefer Nginx, use the nginx-jwt module: + +```nginx +server { + listen 443 ssl; + server_name temporal.example.com; + + # JWT validation + set $jwt_auth_key "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs"; + set $jwt_auth_validate_groups "temporal-admins,temporal-users"; + + location / { + auth_jwt "Temporal"; + auth_jwt_key_file $jwt_auth_key; + auth_jwt_require_claim groups ~ "temporal-admins|temporal-users"; + + proxy_pass http://temporal-ui:8080; + } +} +``` + +## Limitations + +With the reverse proxy approach: + +1. **No per-namespace control** - You can't easily restrict users to specific namespaces +2. **No per-operation control** - Can't allow reads but deny writes +3. **UI-focused** - Harder to protect direct gRPC access to Temporal server +4. **Group-based only** - Can't implement complex logic based on multiple claims + +## When to Use This Approach + +Use reverse proxy authorization when: +- You only need to control who can access Temporal UI (yes/no) +- You don't need granular per-namespace permissions +- You want a simpler setup +- Your authorization logic is straightforward (group membership) + +Use the custom Temporal server approach when: +- You need per-namespace access control +- You want to restrict specific operations (e.g., read-only users) +- You need complex authorization logic +- You want to leverage Temporal's built-in authorization framework + +## Hybrid Approach + +You can also combine both approaches: + +1. Use reverse proxy for initial access control (who can reach Temporal UI) +2. Use Temporal's built-in authorization for granular namespace/operation control + +This provides defense in depth and is recommended for production. + +## Example: Simple Caddy Configuration + +```caddyfile +# Allow only users in specific groups to access Temporal UI +{$DOMAIN} { + @has_auth header Authorization * + @no_auth not header Authorization * + + # Redirect to Keycloak if no auth + handle @no_auth { + redir https://your-keycloak.com/realms/your-realm/protocol/openid-connect/auth?client_id=temporal-ui&redirect_uri=https://{$DOMAIN}/auth/sso/callback&response_type=code 302 + } + + # Forward authenticated requests + handle @has_auth { + reverse_proxy temporal-ui:8080 + } +} +``` + +This is the simplest approach but provides minimal security as it relies entirely on the Temporal UI's OIDC authentication without validating the JWT at the proxy level. + +## Recommendation + +For your use case (restricting access based on Keycloak groups), I recommend: + +**Start with**: Custom Temporal Server approach (more complex but more powerful) +**Fall back to**: Reverse proxy approach if the custom server is too difficult + +The custom server approach gives you the most flexibility and aligns with Temporal's recommended security architecture. diff --git a/custom-server/Dockerfile b/custom-server/Dockerfile new file mode 100644 index 0000000..85a7175 --- /dev/null +++ b/custom-server/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage build for custom Temporal server with authorization +FROM golang:1.21 AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum* ./ +RUN go mod download + +# Copy source code +COPY main.go ./ + +# Build the server +RUN CGO_ENABLED=0 GOOS=linux go build -o temporal-server main.go + +# Final stage +FROM temporalio/auto-setup:1.29.0 + +# Copy the custom server binary +COPY --from=builder /app/temporal-server /usr/local/bin/temporal-server + +# Copy configuration +COPY config/ /etc/temporal/config/ + +# The auto-setup image's entrypoint will handle initialization +# We'll override the command to use our custom binary +ENTRYPOINT ["/entrypoint.sh"] +CMD ["temporal-server", "start"] diff --git a/custom-server/README.md b/custom-server/README.md new file mode 100644 index 0000000..39f1676 --- /dev/null +++ b/custom-server/README.md @@ -0,0 +1,271 @@ +# Custom Temporal Server with OIDC Authorization + +This directory contains the code to build a custom Temporal server with JWT-based authorization support for your Keycloak/OIDC setup. + +## Quick Start + +### 1. Update your Docker Compose + +Edit your `compose.yaml` to build and use the custom server: + +```yaml +services: + temporal: + build: + context: ./custom-server + dockerfile: Dockerfile + depends_on: + - db + configs: + - source: entrypoint + target: /entrypoint.sh + mode: 0555 + - source: dynamicconfig + target: /etc/temporal/config/dynamicconfig/development-sql.yaml + entrypoint: /entrypoint.sh + command: "autosetup" + environment: + - DB=postgres12 + - DB_PORT=5432 + - POSTGRES_USER=temporal + - POSTGRES_PWD_FILE=/run/secrets/db_password + - POSTGRES_SEEDS=db + - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml + # Add this new environment variable for JWKS endpoint + - TEMPORAL_AUTH_ISSUER_URL=${TEMPORAL_AUTH_ISSUER_URL} + networks: + - internal + secrets: + - db_password +``` + +### 2. Set Environment Variables + +Add to your `.env` file: + +```bash +# Your Keycloak realm issuer URL (without .well-known/openid-configuration) +TEMPORAL_AUTH_ISSUER_URL=https://your-keycloak.com/realms/your-realm +``` + +### 3. Configure Keycloak + +#### Import the Protocol Mapper + +1. Go to Keycloak Admin Console +2. Navigate to: Clients → [your temporal-ui client] → Client scopes → [client-scope] → Mappers +3. Click "Add mapper" → "By configuration" → "Script Mapper" +4. Import settings from `example-keycloak-mapper.json` or create manually + +#### Create Groups + +Create the following groups in Keycloak (or customize the mapper script): + +- `temporal-admins` - Full system access +- `dev-team` - Development namespace access +- `ops-team` - Production namespace access +- `qa-team` - Read-only access +- `temporal-workers` - Worker service accounts + +#### Assign Users to Groups + +1. Go to Users → [user] → Groups +2. Add users to appropriate groups + +### 4. Build and Deploy + +```bash +# Build the custom server +docker-compose build temporal + +# Deploy +docker stack deploy temporal --detach=true -c compose.yaml + +# Or with docker-compose +docker-compose up -d +``` + +### 5. Test + +1. Log in to Temporal UI as a user in the `dev-team` group +2. Try to access the `development` namespace - should work +3. Try to start a workflow - should work (write access) +4. Try to access `production` namespace - should only be able to view (read access) + +Log in as a user in the `qa-team` group: +1. Should only be able to view workflows +2. Should not be able to start or terminate workflows + +## Customization + +### Modify Permission Mappings + +Edit the script in `example-keycloak-mapper.json` to match your organization's structure. The format is: + +```javascript +permissions.add(':'); +``` + +Where: +- `` is your Temporal namespace name +- `` is one of: `read`, `write`, `worker`, `admin` + +Examples: +```javascript +permissions.add('production:admin'); // Full access to production +permissions.add('development:write'); // Read and write to development +permissions.add('staging:read'); // Read-only access to staging +permissions.add('temporal-system:admin'); // Cluster admin +``` + +### Use Different Claim Names + +If you want to use a different claim name instead of `permissions`, edit `config/development.yaml`: + +```yaml +global: + authorization: + permissionsClaimName: "temporal_perms" # Use this claim from JWT +``` + +### Advanced: Custom ClaimMapper + +If you need more complex logic (like mapping based on email domains, user attributes, or multiple claims), you can implement a custom ClaimMapper. + +Create a new file `custom-server/custom_claim_mapper.go`: + +```go +package main + +import ( + "encoding/json" + "strings" + + "go.temporal.io/server/common/authorization" +) + +type customClaimMapper struct{} + +func newCustomClaimMapper() authorization.ClaimMapper { + return &customClaimMapper{} +} + +func (c *customClaimMapper) GetClaims(authInfo *authorization.AuthInfo) (*authorization.Claims, error) { + claims := &authorization.Claims{} + + if authInfo.AuthToken == "" { + return claims, nil + } + + // Parse JWT - simple example, use a proper JWT library in production + parts := strings.Split(authInfo.AuthToken, ".") + if len(parts) != 3 { + return claims, nil + } + + // Decode payload (you should use a proper JWT library) + // This is just for illustration + var payload map[string]interface{} + // ... decode base64 and unmarshal JSON + + // Extract groups from JWT + groups, ok := payload["groups"].([]interface{}) + if !ok { + return claims, nil + } + + // Map groups to Temporal roles + claims.Namespaces = make(map[string]authorization.Role) + + for _, g := range groups { + group := g.(string) + switch group { + case "temporal-admins": + claims.System = authorization.RoleAdmin + case "dev-team": + claims.Namespaces["development"] = authorization.RoleWriter + claims.Namespaces["staging"] = authorization.RoleWriter + claims.Namespaces["production"] = authorization.RoleReader + case "ops-team": + claims.Namespaces["production"] = authorization.RoleAdmin + } + } + + return claims, nil +} +``` + +Then update `main.go` to use it: + +```go +temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { + return newCustomClaimMapper() +}) +``` + +## Troubleshooting + +### Enable Debug Logging + +Add to your environment variables: + +```yaml +environment: + - LOG_LEVEL=debug +``` + +### Check JWT Token + +Use this to decode and inspect your JWT token: + +```bash +# Get token from browser (F12 → Network → find request with Authorization header) +TOKEN="your.jwt.token" + +# Decode (install jq first: apt-get install jq) +echo $TOKEN | cut -d'.' -f2 | base64 -d | jq . +``` + +Look for the `permissions` array. + +### Common Issues + +**Error: "PermissionDenied"** +- Check that the JWT contains the `permissions` claim +- Verify the permissions are in the correct format: `"namespace:role"` +- Ensure the JWKS URL is accessible from the Temporal server container + +**Error: "InvalidToken" or "TokenExpired"** +- Check that `TEMPORAL_AUTH_ISSUER_URL` is correct +- Verify the JWKS endpoint is reachable: `https://your-keycloak.com/realms/your-realm/protocol/openid-connect/certs` +- Ensure token hasn't expired (check `exp` claim) + +**Server won't start** +- Check server logs: `docker logs temporal_temporal_1` +- Verify go.mod dependencies: `cd custom-server && go mod tidy` +- Ensure config file syntax is correct: `yamllint config/development.yaml` + +### View Server Logs + +```bash +# Docker Compose +docker-compose logs -f temporal + +# Docker Swarm +docker service logs -f temporal_temporal +``` + +## Files in This Directory + +- `main.go` - Custom Temporal server entrypoint with authorization +- `go.mod` - Go module dependencies +- `config/development.yaml` - Temporal configuration with JWT settings +- `Dockerfile` - Multi-stage build for custom server +- `example-keycloak-mapper.json` - Keycloak protocol mapper configuration +- `README.md` - This file + +## References + +- [Temporal Authorization Documentation](https://docs.temporal.io/self-hosted-guide/security#authorizer-plugin) +- [Authorization Sample](https://github.com/temporalio/samples-server/tree/main/extensibility/authorizer) +- [Keycloak Script Mappers](https://www.keycloak.org/docs/latest/server_development/#_script_providers) diff --git a/custom-server/config/development.yaml b/custom-server/config/development.yaml new file mode 100644 index 0000000..68a9072 --- /dev/null +++ b/custom-server/config/development.yaml @@ -0,0 +1,99 @@ +# Temporal Server Configuration with JWT Authorization + +global: + authorization: + # JWT token validation settings + jwtKeyProvider: + # JWKS endpoint from your Keycloak - this provides the public keys + # Format: https://your-keycloak.com/realms/yourRealm/protocol/openid-connect/certs + keySourceURIs: + - ${TEMPORAL_AUTH_ISSUER_URL}/.well-known/jwks.json + # How often to refresh the keys (in Go duration format) + refreshInterval: 1h + + # The claim name in the JWT that contains permissions + # Default is "permissions" but you can customize this based on your Keycloak setup + permissionsClaimName: "permissions" + +# Database configuration +persistence: + defaultStore: default + visibilityStore: visibility + numHistoryShards: 4 + datastores: + default: + sql: + pluginName: "postgres12" + databaseName: "temporal" + connectAddr: "db:5432" + connectProtocol: "tcp" + user: "temporal" + password: "${POSTGRES_PWD}" + maxConns: 20 + maxIdleConns: 20 + maxConnLifetime: "1h" + visibility: + sql: + pluginName: "postgres12" + databaseName: "temporal_visibility" + connectAddr: "db:5432" + connectProtocol: "tcp" + user: "temporal" + password: "${POSTGRES_PWD}" + maxConns: 10 + maxIdleConns: 10 + maxConnLifetime: "1h" + +# Services configuration +services: + frontend: + rpc: + grpcPort: 7233 + membershipPort: 6933 + bindOnIP: "0.0.0.0" + + matching: + rpc: + grpcPort: 7235 + membershipPort: 6935 + bindOnIP: "0.0.0.0" + + history: + rpc: + grpcPort: 7234 + membershipPort: 6934 + bindOnIP: "0.0.0.0" + + worker: + rpc: + grpcPort: 7239 + membershipPort: 6939 + bindOnIP: "0.0.0.0" + +clusterMetadata: + enableGlobalNamespace: false + failoverVersionIncrement: 10 + masterClusterName: "active" + currentClusterName: "active" + clusterInformation: + active: + enabled: true + initialFailoverVersion: 1 + rpcName: "frontend" + rpcAddress: "127.0.0.1:7233" + +dcRedirectionPolicy: + policy: "noop" + +archival: + history: + state: "disabled" + visibility: + state: "disabled" + +publicClient: + hostPort: "127.0.0.1:7233" + +dynamicConfigClient: + filepath: "/etc/temporal/config/dynamicconfig/development-sql.yaml" + pollInterval: "10s" diff --git a/custom-server/example-keycloak-mapper.json b/custom-server/example-keycloak-mapper.json new file mode 100644 index 0000000..4525892 --- /dev/null +++ b/custom-server/example-keycloak-mapper.json @@ -0,0 +1,13 @@ +{ + "name": "temporal-permissions-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-script-based-protocol-mapper", + "config": { + "userinfo.token.claim": "true", + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "permissions", + "script": "/**\n * This script maps Keycloak groups to Temporal permissions.\n * \n * Temporal expects permissions in the format: 'namespace:role'\n * where role can be: read, write, worker, admin\n * \n * Customize the mapping below based on your Keycloak groups.\n */\n\nvar ArrayList = Java.type('java.util.ArrayList');\nvar permissions = new ArrayList();\n\n// Get user's groups\nvar groups = user.getGroups();\n\n// Iterate through groups and map to Temporal permissions\nfor each (var group in groups) {\n var groupName = group.getName();\n \n // System administrators - full access to everything\n if (groupName === 'temporal-admins' || groupName === '/temporal-admins') {\n permissions.add('temporal-system:admin');\n }\n \n // Development team - write access to dev/staging, read to production\n else if (groupName === 'dev-team' || groupName === '/dev-team') {\n permissions.add('development:write');\n permissions.add('staging:write');\n permissions.add('production:read');\n }\n \n // Operations team - full access to production\n else if (groupName === 'ops-team' || groupName === '/ops-team') {\n permissions.add('production:admin');\n permissions.add('staging:write');\n permissions.add('development:read');\n }\n \n // QA team - read access everywhere\n else if (groupName === 'qa-team' || groupName === '/qa-team') {\n permissions.add('development:read');\n permissions.add('staging:read');\n permissions.add('production:read');\n }\n \n // Service accounts for workers\n else if (groupName === 'temporal-workers' || groupName === '/temporal-workers') {\n permissions.add('production:worker');\n permissions.add('staging:worker');\n permissions.add('development:worker');\n }\n \n // Support team - read only access\n else if (groupName === 'support-team' || groupName === '/support-team') {\n permissions.add('production:read');\n }\n}\n\n// Return the permissions array\npermissions;" + } +} diff --git a/custom-server/go.mod b/custom-server/go.mod new file mode 100644 index 0000000..2c7dc48 --- /dev/null +++ b/custom-server/go.mod @@ -0,0 +1,7 @@ +module custom-temporal-server + +go 1.21 + +require ( + go.temporal.io/server v1.29.0 +) diff --git a/custom-server/main.go b/custom-server/main.go new file mode 100644 index 0000000..4a55ad6 --- /dev/null +++ b/custom-server/main.go @@ -0,0 +1,51 @@ +// Custom Temporal Server with JWT Authorization +package main + +import ( + "log" + + "go.temporal.io/server/common/authorization" + "go.temporal.io/server/common/config" + "go.temporal.io/server/temporal" +) + +func main() { + // Load Temporal configuration + cfg, err := config.LoadConfig("development", "./config", "") + if err != nil { + log.Fatal("Failed to load config:", err) + } + + // Create Temporal server with authorization + s, err := temporal.NewServer( + temporal.ForServices(temporal.DefaultServices), + temporal.WithConfig(cfg), + temporal.InterruptOn(temporal.InterruptCh()), + + // Configure JWT ClaimMapper + temporal.WithClaimMapper(func(cfg *config.Config) authorization.ClaimMapper { + return authorization.NewDefaultJWTClaimMapper( + // Token key provider - fetches public keys from your OIDC provider + authorization.NewDefaultTokenKeyProvider(cfg, log.Default()), + cfg, + log.Default(), + ) + }), + + // Configure Authorizer + temporal.WithAuthorizer(authorization.NewDefaultAuthorizer()), + ) + + if err != nil { + log.Fatal("Failed to create server:", err) + } + + // Start the server + log.Println("Starting Temporal Server with JWT Authorization...") + err = s.Start() + if err != nil { + log.Fatal("Server failed:", err) + } + + log.Println("Server stopped.") +} diff --git a/test-authorization.sh b/test-authorization.sh new file mode 100755 index 0000000..02b91c1 --- /dev/null +++ b/test-authorization.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# Test script to verify Temporal authorization setup + +set -e + +echo "==================================" +echo "Temporal Authorization Test Script" +echo "==================================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_info() { + echo "ℹ $1" +} + +# Check if required tools are installed +echo "Checking prerequisites..." +command -v jq >/dev/null 2>&1 || { print_error "jq is not installed. Install with: apt-get install jq"; exit 1; } +command -v curl >/dev/null 2>&1 || { print_error "curl is not installed."; exit 1; } +print_success "Prerequisites OK" +echo "" + +# Configuration +read -p "Enter Keycloak URL (e.g., https://keycloak.example.com): " KEYCLOAK_URL +read -p "Enter Realm name: " REALM +read -p "Enter Client ID (e.g., temporal-ui): " CLIENT_ID +read -sp "Enter Client Secret: " CLIENT_SECRET +echo "" +read -p "Enter test username: " USERNAME +read -sp "Enter test password: " PASSWORD +echo "" +echo "" + +# Get token from Keycloak +print_info "Requesting token from Keycloak..." +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" + +RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" \ + -d "grant_type=password" \ + -d "username=${USERNAME}" \ + -d "password=${PASSWORD}") + +if [ $? -ne 0 ]; then + print_error "Failed to connect to Keycloak" + exit 1 +fi + +# Check for errors +if echo "$RESPONSE" | jq -e '.error' >/dev/null 2>&1; then + print_error "Authentication failed:" + echo "$RESPONSE" | jq '.error_description' + exit 1 +fi + +# Extract access token +ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') + +if [ "$ACCESS_TOKEN" == "null" ] || [ -z "$ACCESS_TOKEN" ]; then + print_error "Failed to get access token" + echo "$RESPONSE" + exit 1 +fi + +print_success "Successfully obtained access token" +echo "" + +# Decode and display JWT +print_info "Decoding JWT token..." +echo "" + +# Split token into header, payload, signature +IFS='.' read -ra TOKEN_PARTS <<< "$ACCESS_TOKEN" + +# Decode header +HEADER=$(echo "${TOKEN_PARTS[0]}" | base64 -d 2>/dev/null || echo "${TOKEN_PARTS[0]}" | base64 -d -i 2>/dev/null) +echo "JWT Header:" +echo "$HEADER" | jq '.' 2>/dev/null || echo "$HEADER" +echo "" + +# Decode payload +PAYLOAD=$(echo "${TOKEN_PARTS[1]}" | base64 -d 2>/dev/null || echo "${TOKEN_PARTS[1]}" | base64 -d -i 2>/dev/null) +echo "JWT Payload:" +echo "$PAYLOAD" | jq '.' 2>/dev/null || echo "$PAYLOAD" +echo "" + +# Extract relevant claims +print_info "Checking for Temporal authorization claims..." +echo "" + +# Check for permissions claim +PERMISSIONS=$(echo "$PAYLOAD" | jq -r '.permissions // empty' 2>/dev/null) +if [ ! -z "$PERMISSIONS" ] && [ "$PERMISSIONS" != "null" ]; then + print_success "Found 'permissions' claim" + echo "Permissions:" + echo "$PAYLOAD" | jq '.permissions' + echo "" +else + print_warning "No 'permissions' claim found in JWT" + echo "Temporal requires a 'permissions' array in the JWT token." + echo "Each permission should be in the format: 'namespace:role'" + echo "" +fi + +# Check for groups claim +GROUPS=$(echo "$PAYLOAD" | jq -r '.groups // empty' 2>/dev/null) +if [ ! -z "$GROUPS" ] && [ "$GROUPS" != "null" ]; then + print_info "Found 'groups' claim:" + echo "$PAYLOAD" | jq '.groups' + echo "" +else + print_warning "No 'groups' claim found" + echo "" +fi + +# Check for roles claim +ROLES=$(echo "$PAYLOAD" | jq -r '.roles // empty' 2>/dev/null) +if [ ! -z "$ROLES" ] && [ "$ROLES" != "null" ]; then + print_info "Found 'roles' claim:" + echo "$PAYLOAD" | jq '.roles' + echo "" +else + print_warning "No 'roles' claim found" + echo "" +fi + +# Check token expiration +EXP=$(echo "$PAYLOAD" | jq -r '.exp // empty' 2>/dev/null) +if [ ! -z "$EXP" ]; then + CURRENT_TIME=$(date +%s) + TIME_LEFT=$((EXP - CURRENT_TIME)) + if [ $TIME_LEFT -gt 0 ]; then + print_success "Token is valid (expires in $((TIME_LEFT / 60)) minutes)" + else + print_error "Token has expired!" + fi + echo "" +fi + +# Check issuer +ISSUER=$(echo "$PAYLOAD" | jq -r '.iss // empty' 2>/dev/null) +if [ ! -z "$ISSUER" ]; then + print_info "Token issuer: $ISSUER" + echo "" +fi + +# Verify JWKS endpoint is accessible +print_info "Verifying JWKS endpoint..." +JWKS_URL="${ISSUER}/protocol/openid-connect/certs" +JWKS_RESPONSE=$(curl -s "$JWKS_URL") +if echo "$JWKS_RESPONSE" | jq -e '.keys' >/dev/null 2>&1; then + KEY_COUNT=$(echo "$JWKS_RESPONSE" | jq '.keys | length') + print_success "JWKS endpoint accessible ($KEY_COUNT keys available)" + echo "JWKS URL: $JWKS_URL" +else + print_error "JWKS endpoint not accessible or invalid response" + echo "URL: $JWKS_URL" + echo "Response: $JWKS_RESPONSE" +fi +echo "" + +# Test Temporal Server connection (optional) +read -p "Do you want to test connection to Temporal Server? (y/n): " TEST_TEMPORAL +if [ "$TEST_TEMPORAL" == "y" ]; then + read -p "Enter Temporal Server address (e.g., localhost:7233): " TEMPORAL_ADDR + + print_info "Testing Temporal Server connection..." + + # Try to list namespaces with the token + # Note: This requires grpcurl to be installed + if command -v grpcurl >/dev/null 2>&1; then + RESULT=$(grpcurl -plaintext \ + -H "authorization: Bearer $ACCESS_TOKEN" \ + -d '{"pageSize": 10}' \ + "$TEMPORAL_ADDR" \ + temporal.api.workflowservice.v1.WorkflowService/ListNamespaces 2>&1) + + if echo "$RESULT" | grep -q "PermissionDenied"; then + print_warning "Server rejected request (PermissionDenied)" + echo "This likely means authorization is working but the user lacks permissions." + echo "Check that the 'permissions' claim in the JWT matches your Temporal namespaces." + elif echo "$RESULT" | grep -q "namespaces"; then + print_success "Successfully connected to Temporal Server!" + echo "User has access to namespaces." + else + print_error "Unexpected response from Temporal Server:" + echo "$RESULT" + fi + else + print_warning "grpcurl not installed, skipping Temporal Server test" + echo "Install grpcurl to test server connection: https://github.com/fullstorydev/grpcurl" + fi +fi +echo "" + +# Summary +echo "==================================" +echo "Summary" +echo "==================================" +echo "" + +if [ ! -z "$PERMISSIONS" ] && [ "$PERMISSIONS" != "null" ]; then + print_success "JWT token contains 'permissions' claim - ready for Temporal authorization!" + echo "" + echo "Next steps:" + echo "1. Verify the permissions match your Temporal namespace names" + echo "2. Deploy the custom Temporal server with authorization enabled" + echo "3. Test access with different user groups" +else + print_warning "JWT token does NOT contain 'permissions' claim" + echo "" + echo "Next steps:" + echo "1. Configure Keycloak protocol mapper to add 'permissions' claim" + echo "2. Map user groups/roles to permissions in format: 'namespace:role'" + echo "3. Re-run this test script to verify" + echo "" + echo "See: custom-server/example-keycloak-mapper.json for an example" +fi + +echo "" +echo "==================================" +echo "Full JWT Token (for debugging):" +echo "==================================" +echo "$ACCESS_TOKEN" +echo ""