Zitadel RBAC Mapper
Groups-to-grants mapping webhook for Zitadel Actions V2. Resolves user's Google Workspace group memberships, maps groups to project roles, syncs UserGrants, and enriches tokens with a groups claim — all in a single synchronous function call during the OIDC flow.
What it does
On every login (OIDC token issuance), Zitadel calls this webhook via preuserinfo/preaccesstoken function executions. The webhook:
- Resolves groups — calls google-group-sync (localhost:9090) to get the user's Google Workspace group emails
- Syncs grants — maps groups to project roles via configured rules, then performs idempotent add/update/remove of UserGrants in Zitadel via gRPC
- Enriches token — returns
append_claims with a groups claim containing the group email list
The grant sync is idempotent — if grants are already correct, no Zitadel API calls are made.
Architecture
[User logs in] → [Zitadel OIDC flow]
│
├─ function/preuserinfo ──→ [restCall target] ──→ POST /webhook
└─ function/preaccesstoken → [restCall target] ──→ POST /webhook
│
[zitadel-rbac-mapper Lambda]
│
├─ google-group-sync extension (localhost:9090)
├─ resolve groups → map to grants → sync via gRPC
└─ return {"append_claims": [{"key":"groups","value":[...]}]}
Why Functions, Not Events
Grant sync runs in the preuserinfo function (not as an async event handler) because:
- Timing: Grants must exist before the token is issued. Async events arrive after the fact — too late.
- Reliability: Functions are synchronous and inline. Events have no delivery guarantees and can be delayed/dropped on Zitadel Cloud.
- Idempotency: The sync is diff-based. Running on every login is safe — no-op if nothing changed.
- Simplicity: One target, two executions. No event infrastructure needed.
Zitadel Configuration
1. Create a REST Call target
curl -L -X POST 'https://<ZITADEL_DOMAIN>/v2/actions/targets' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <PAT>' \
--data-raw '{
"name": "rbac-mapper",
"restCall": {
"interruptOnError": false
},
"endpoint": "https://<LAMBDA_FUNCTION_URL>/webhook",
"timeout": "30s",
"payloadType": "PAYLOAD_TYPE_JWT"
}'
Important:
- Target type must be REST Call (not Webhook) — Zitadel reads the response body to apply
append_claims
- Payload type JWT — the body is signed with the instance key, verified via JWKS
interruptOnError: false — token issuance continues even if the webhook fails
2. Create function executions
# Adds "groups" claim to userinfo endpoint
curl -L -X PUT 'https://<ZITADEL_DOMAIN>/v2/actions/executions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <PAT>' \
--data-raw '{"condition":{"function":{"name":"preuserinfo"}},"targets":["<TARGET_ID>"]}'
# Adds "groups" claim to access token
curl -L -X PUT 'https://<ZITADEL_DOMAIN>/v2/actions/executions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <PAT>' \
--data-raw '{"condition":{"function":{"name":"preaccesstoken"}},"targets":["<TARGET_ID>"]}'
Final configuration
| Target |
Type |
Payload |
Timeout |
rbac-mapper |
REST Call |
JWT |
30s |
| Execution |
Condition |
Effect |
| preuserinfo |
function/preuserinfo |
Groups claim in userinfo + grant sync |
| preaccesstoken |
function/preaccesstoken |
Groups claim in access token (no grant sync — payload lacks org context) |
No event executions needed. No webhook targets needed.
Environment Variables
| Variable |
Required |
Default |
Description |
ZITADEL_DOMAIN |
Yes |
— |
Zitadel instance domain (for gRPC + JWKS + Org Metadata) |
ZITADEL_PORT |
No |
443 |
Zitadel gRPC port |
ZITADEL_KEY_JSON |
Yes |
— |
Raw JWT key JSON content (Lambda: loaded from SM by entry point) |
ZITADEL_KEY_SECRET_NAME |
No |
— |
AWS Secrets Manager secret name (Lambda entry point only) |
GROUPS_RESOLVER_URL |
Yes |
— |
google-group-sync base URL (e.g., http://localhost:9090) |
SYNC_API_KEY |
Yes |
— |
API key for /sync endpoint authentication (X-Sync-Key header) |
RULES_CACHE_TTL |
No |
5m |
TTL for Org Metadata rules cache |
PORT |
No |
8080 |
HTTP server port |
HEALTH_PORT |
No |
7070 |
Health probe port |
LOG_LEVEL |
No |
info |
Log level: debug, info, warn, error |
LOG_FORMAT |
No |
json |
Log format: json, text |
Rules are stored as Zitadel Org Metadata entries (written by Pulumi):
Key: rbac/{cluster-name}/{role-key}
Value: comma-delimited Google Group emails
Example:
rbac/kernel/admin = engineering-admin@truvity.com,engineering-sre@truvity.com
rbac/devel/deployer = engineering-devops@truvity.com
rbac/devel/billing:deployer = product-billing@truvity.com,engineering-product@truvity.com
The mapper reads all rbac/* entries at startup and caches them for RULES_CACHE_TTL. The /sync endpoint forces a cache refresh before processing.
API
POST /webhook — Zitadel Actions V2 function handler
Receives JWT-signed payloads from Zitadel's preuserinfo and preaccesstoken function executions.
Request: JWT body containing the function payload (verified via JWKS at ZITADEL_DOMAIN/oauth/v2/keys).
Response:
{"append_claims": [{"key": "groups", "value": ["group1@example.com", "group2@example.com"]}]}
On preuserinfo: resolves groups, syncs grants (using org.id from payload), returns groups claim.
On preaccesstoken: returns empty groups claim (no grant sync — payload lacks org context).
POST /sync — Full user reconciliation
Reloads rules from Org Metadata, lists all human users in the org, resolves groups for each, and syncs grants idempotently. Protected by API key.
Request:
POST /sync
X-Sync-Key: <SYNC_API_KEY value>
Response:
{
"users_processed": 10,
"grants_added": 3,
"grants_updated": 1,
"grants_removed": 0
}
Authentication: Requires X-Sync-Key header matching SYNC_API_KEY. Returns 401 if missing/invalid.
Triggers:
- Lambda: EventBridge scheduled rule (every 15 minutes)
- K8s: CronJob with API key header
GET /health — Health check
Returns 200 OK when the server is ready.
Deployment
AWS Lambda (with google-group-sync extension)
[zitadel-rbac-mapper Lambda]
├── LWA layer (event → HTTP on :8080)
├── google-group-sync extension layer (HTTP on :9090)
│ └── reads GGS_SA_KEY_SECRET_NAME from Secrets Manager
└── rbac-mapper binary
├── POST /webhook — Zitadel Actions V2 function handler (JWT verified)
├── POST /sync — full reconciliation (API key verified, scheduled every 15 min)
└── GET /health — health check
Org context
The preuserinfo payload includes org.id — the organization the user belongs to. The mapper passes it as x-zitadel-orgid gRPC metadata to scope Management API calls to the correct organization. This is required when the SA belongs to a different org than the projects (e.g., instance-level IAM admin in the default org).
Kubernetes (Helm chart)
helm install zitadel-rbac-mapper oci://ghcr.io/truvity/charts/zitadel-rbac-mapper \
--set zitadelKey.secretName=zitadel-sa-key \
--set sidecar.enabled=true \
--set sidecar.saKey.secretName=google-sa-key
Development
devbox shell # activates dev environment (GOEXPERIMENT=jsonv2)
just build # build binaries
just test # run unit tests
just lint # run linter
just test-integration # run integration tests (requires real Zitadel)
just check # build + test + lint + vuln
Integration Test Setup
# Store Zitadel JWT key in system keyring
secret-tool store --label='zitadel-rbac-mapper jwt-key' \
service zitadel-rbac-mapper username jwt-key < /path/to/key.json
# Delete the key file after storing (don't leave secrets on disk)
rm /path/to/key.json
# Verify it's stored correctly
secret-tool lookup service zitadel-rbac-mapper username jwt-key | head -c 20
# Create config (~/.config/zitadel-rbac-mapper/config.yaml)
# See tests/integration/README.md for required fields
License
MIT