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