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.
This commit is contained in:
Christian Galo
2025-10-24 02:10:54 +00:00
parent 2ac92ea493
commit 02b4ec9ee3
13 changed files with 2609 additions and 0 deletions

400
AUTHORIZATION_GUIDE.md Normal file
View File

@ -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: `<namespace>:<role>`
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 <token>`
- 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

413
DIAGRAMS.md Normal file
View File

@ -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 <JWT> │ │
│─────────────────────────▶│ │
│ │ │
│ │ 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 │ │
```

443
IMPLEMENTATION_CHECKLIST.md Normal file
View File

@ -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 <temporal-container> tctl namespace register development
docker exec -it <temporal-container> tctl namespace register staging
docker exec -it <temporal-container> 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 <container>`
- 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! 🚀

183
QUICK_REFERENCE.md Normal file
View File

@ -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
```
<namespace>:<role>
```
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/

232
README_AUTHORIZATION.md Normal file
View File

@ -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! 🚀

225
REVERSE_PROXY_APPROACH.md Normal file
View File

@ -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.

28
custom-server/Dockerfile Normal file
View File

@ -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"]

271
custom-server/README.md Normal file
View File

@ -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('<namespace>:<role>');
```
Where:
- `<namespace>` is your Temporal namespace name
- `<role>` 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)

View File

@ -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"

View File

@ -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;"
}
}

7
custom-server/go.mod Normal file
View File

@ -0,0 +1,7 @@
module custom-temporal-server
go 1.21
require (
go.temporal.io/server v1.29.0
)

51
custom-server/main.go Normal file
View File

@ -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.")
}

244
test-authorization.sh Executable file
View File

@ -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 ""