Compare commits
1 Commits
main
...
authorizer
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b4ec9ee3 |
400
AUTHORIZATION_GUIDE.md
Normal file
400
AUTHORIZATION_GUIDE.md
Normal 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
413
DIAGRAMS.md
Normal 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
443
IMPLEMENTATION_CHECKLIST.md
Normal 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
183
QUICK_REFERENCE.md
Normal 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/
|
||||
60
README.md
60
README.md
@ -22,63 +22,3 @@ Deploy using the `-c` flag to specify one or multiple compose files.
|
||||
```
|
||||
docker stack deploy temporal --detach=true -c compose.yaml
|
||||
```
|
||||
|
||||
## Additional setup steps
|
||||
|
||||
### Creating the default namespace
|
||||
|
||||
After deploying Temporal with authentication enabled, the default namespace is not created automatically. You need to create it manually using the `admin-tools` service.
|
||||
|
||||
```
|
||||
docker exec -it $(docker ps -qf "name=temporal_admin-tools") bash
|
||||
```
|
||||
Then, inside the container, run:
|
||||
|
||||
```
|
||||
temporal operator namespace create -n default
|
||||
```
|
||||
|
||||
### Configuring permissions with the default Authorizer and Claim Mapper
|
||||
|
||||
The default JWT `ClaimMapper` expects OAuth2 Access Tokens with the `permissions` claim containing a list of strings representing the user's permissions per namespace. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"default:read",
|
||||
"default:write",
|
||||
"temporal-system:admin"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Make sure your Identity Provider is configured to include these claims in the tokens issued to Temporal clients. For more information about these claims and Temporal's authorization model, refer to the [official documentation](https://docs.temporal.io/self-hosted-guide/security#plugins).
|
||||
|
||||
We include below an example configuration snippet for Keycloak to add these claims via a protocol mapper.
|
||||
|
||||
### Keycloak Protocol Mapper Example
|
||||
|
||||
There are several ways to configure Keycloak to include the necessary `permissions` claim in the Access Tokens, this is one example using a Protocol Mapper:
|
||||
|
||||
1. Navigate to your Keycloak Admin Console.
|
||||
2. Go to the "Clients" section and select your Temporal client.
|
||||
3. Go to the "Roles" tab and define roles corresponding to the permissions you want to assign (e.g., `default:read`, `default:write`, `temporal-system:admin`).
|
||||
4. Go to the "Client Scopes" tab and select the dedicated scope for Temporal (or create one if it doesn't exist).
|
||||
5. Go to the "Mappers" tab and create a new mapper with the following settings:
|
||||
- Mapper Type: "User Client Role"
|
||||
- Name: "permissions"
|
||||
- Multivalued: "On"
|
||||
- Token Claim Name: "permissions"
|
||||
- Claim JSON Type: "String"
|
||||
|
||||
Make sure to assign the appropriate roles to users so that they receive the correct permissions in their Access Tokens. Use the evaluation tool in Keycloak to verify that the tokens contain the expected claims.
|
||||
|
||||
This is just one way to set it up; depending on your requirements, you might need to adjust the configuration accordingly.
|
||||
|
||||
## Development notes
|
||||
|
||||
Those are notes for future improvements and clarifications of this configuration.
|
||||
|
||||
- We need to better understand how static config files are managed in this setup.
|
||||
- Are they baked into the image, or mounted at runtime? Where are they stored? What is a good default location?
|
||||
|
||||
232
README_AUTHORIZATION.md
Normal file
232
README_AUTHORIZATION.md
Normal 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
225
REVERSE_PROXY_APPROACH.md
Normal 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.
|
||||
19
compose.yaml
19
compose.yaml
@ -2,18 +2,19 @@ services:
|
||||
db:
|
||||
image: postgres:18.0
|
||||
environment:
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
- POSTGRES_USER=temporal
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- POSTGRES_DB=temporal
|
||||
networks:
|
||||
- internal
|
||||
volumes:
|
||||
- 'postgresql_data:/var/lib/postgresql'
|
||||
- 'postgresql_data:/var/lib/postgresql/data'
|
||||
secrets:
|
||||
- db_password
|
||||
|
||||
temporal:
|
||||
image: temporalio/auto-setup:1.29
|
||||
image: temporalio/auto-setup:1.29.0
|
||||
depends_on:
|
||||
- db
|
||||
configs:
|
||||
@ -25,18 +26,12 @@ services:
|
||||
entrypoint: /entrypoint.sh
|
||||
command: "autosetup"
|
||||
environment:
|
||||
- SERVICES=frontend:history:matching:worker:internal-frontend
|
||||
- DB=postgres12
|
||||
- DB_PORT=5432
|
||||
- POSTGRES_USER=temporal
|
||||
- POSTGRES_PWD_FILE=/run/secrets/db_password # entrypoint.sh exports POSTGRES_PWD
|
||||
- POSTGRES_SEEDS=db
|
||||
- DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml
|
||||
- SKIP_DEFAULT_NAMESPACE_CREATION=true
|
||||
- TEMPORAL_AUTH_AUTHORIZER=default
|
||||
- TEMPORAL_AUTH_CLAIM_MAPPER=default
|
||||
- TEMPORAL_JWT_KEY_SOURCE1
|
||||
- USE_INTERNAL_FRONTEND=true
|
||||
- DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml # What is this
|
||||
networks:
|
||||
- internal
|
||||
secrets:
|
||||
@ -49,8 +44,8 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
environment:
|
||||
- TEMPORAL_ADDRESS=temporal:7236
|
||||
- TEMPORAL_CLI_ADDRESS=temporal:7236 # Need to find out what this is for
|
||||
- TEMPORAL_ADDRESS=temporal:7233
|
||||
- TEMPORAL_CLI_ADDRESS=temporal:7233
|
||||
|
||||
ui:
|
||||
image: temporalio/ui:2.41.0
|
||||
@ -98,4 +93,4 @@ configs:
|
||||
entrypoint:
|
||||
file: entrypoint.sh
|
||||
dynamicconfig:
|
||||
file: dynamicconfig/development-sql.yaml
|
||||
file: dynamicconfig/development-sql.yaml
|
||||
28
custom-server/Dockerfile
Normal file
28
custom-server/Dockerfile
Normal 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
271
custom-server/README.md
Normal 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)
|
||||
99
custom-server/config/development.yaml
Normal file
99
custom-server/config/development.yaml
Normal 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"
|
||||
13
custom-server/example-keycloak-mapper.json
Normal file
13
custom-server/example-keycloak-mapper.json
Normal 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
7
custom-server/go.mod
Normal 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
51
custom-server/main.go
Normal 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.")
|
||||
}
|
||||
@ -24,10 +24,4 @@ file_env() {
|
||||
|
||||
file_env POSTGRES_PWD
|
||||
|
||||
# : "${TEMPORAL_CONFIG_DIR:=/etc/temporal/config}"
|
||||
# : "${TEMPORAL_CONFIG_ENV:=development}"
|
||||
|
||||
# export TEMPORAL_CONFIG_DIR
|
||||
# export TEMPORAL_CONFIG_ENV
|
||||
|
||||
exec /etc/temporal/entrypoint.sh $@
|
||||
|
||||
244
test-authorization.sh
Executable file
244
test-authorization.sh
Executable 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 ""
|
||||
Reference in New Issue
Block a user