A small, reusable Go package and HTTP service that provides role management and a Redis-based roles cache for use with Zitadel. This README explains how to include and use the package in other Go projects, how to run the included HTTP server (locally and with Docker), and provides examples for adding roles, assigning roles to users, and using the HasAnyRole helper.
Contents
- Overview
- Requirements
- Environment variables
- Quick start (library usage)
- Example: create a
Serviceand call APIs (Go code)- Example: call the HTTP endpoints with
curl- Middleware and
HasAnyRoleusage example- Cache cleanup job (role removal) and job status
- Docker / building the server
- Notes, error handling and tips
This repository contains:
- A small HTTP server (in
cmd/server) exposing role management endpoints (/v1/*). - A
pkg/zitadelHTTP client that talks to Zitadel management API. - A
pkg/cacheimplementation using Redis to cache per-user role lists and run background jobs to remove a role from all caches. pkg/servicewhich composes the Zitadel client and cache and provides higher-level operations used by the HTTP handlers.pkg/middlewareprovidingRoleMiddlewareand helperHasAnyRole.
You can use the library directly in your Go services (recommended) or run the HTTP server and call it over HTTP.
- Go 1.21+ (module-aware)
- Redis server accessible to your application
- A Zitadel management API (project id and service account token)
The server and library configuration comes from environment variables (see .env in repo for example). Important variables:
ZITADEL_DOMAIN— base URL of Zitadel management API (e.g.https://zitadel.example.comorhttp://localhost:8080).SERVICE_ACCOUNT_TOKEN— service account token used to call Zitadel management API.PROJECT_ID— Zitadel project id used when creating/assigning roles.REDIS_ADDR— Redis host:port (defaultlocalhost:6379).REDIS_PASSWORD— Redis password (if any).REDIS_DB— Redis DB number (integer, default0).CACHE_TTL— default TTL for role cache entries (e.g.300s).PORT— port where HTTP server listens (default3000).REQUEST_TIMEOUT— HTTP client timeout for calls to Zitadel (default8s).RETRY_MAX— number of retries for Zitadel calls (default3).- Circuit breaker settings:
CB_INTERVAL,CB_TIMEOUT, optionallyCBMaxRequests.
Do not commit real tokens to source control. Use environment files or a secret manager.
- Add the module to your
go.mod(use the module path of this repo). Example:
go get github.com/AbduAllahGabbar/service@latest- Initialize the dependencies and construct the service object. Example snippet showing the recommended wiring:
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"github.com/AbduAllahGabbar/service/pkg/cache"
"github.com/AbduAllahGabbar/service/pkg/config"
"github.com/AbduAllahGabbar/service/pkg/service"
"github.com/AbduAllahGabbar/service/pkg/zitadel"
)
func BuildService() (*service.Service, error) {
cfg := config.LoadConfig()
rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
})
// optional: ping Redis to verify
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, err
}
cacheImpl := cache.NewRedisCache(rdb, cfg.CacheTTL)
zitadelClient := zitadel.NewHTTPClient(cfg.ZitadelBaseURL, cfg.ZitadelToken, cfg)
svc := service.New(zitadelClient, cacheImpl, cfg.CacheTTL)
return svc, nil
}After svc is created you can call its methods to create roles, assign roles, query roles, etc.
These are the main Service methods you will use:
svc.CreateRoles(ctx, roles []zitadel.RoleInput) ([]string, error)
svc.AssignRolesToUser(ctx, userID string, roleIDs []string) error
svc.DeleteRole(ctx, roleID string) error // triggers async cache cleanup job
svc.RemoveRoleFromUser(ctx, roleID, userID string) error
svc.GetUserRoles(ctx, userID string) ([]string, error)
svc.InvalidateRoles(ctx, userID string) error
svc.StartRemoveRoleCleanup(ctx, role string) (jobID string, err error)
svc.GetCleanupJobStatus(ctx, jobID string) (*cache.CleanupJobStatus, error)zitadel.RoleInput structure:
type RoleInput struct {
Name string `json:"name"`
Desc string `json:"desc,omitempty"`
}ctx := context.Background()
// Alternatively create many roles in bulk
roles := []zitadel.RoleInput{{Name: "viewer", Desc: "Read only"}, {Name: "editor", Desc: "Edit content"}}
keys, err := svc.CreateRoles(ctx, roles)
if err != nil {
log.Fatalf("create roles bulk: %v", err)
}
log.Printf("bulk created keys: %v", keys)
// Assign multiple role keys to the user at once
if err := svc.AssignRolesToUser(ctx, "user-123", keys); err != nil {
log.Fatalf("assign roles: %v", err)
}If you prefer to run the bundled HTTP server (cmd/server), here are example requests (replace host:port and token/user ids):
Create role
curl -X POST http://localhost:8081/v1/roles \
-H "Content-Type: application/json" \
-d '{"name":"team:lead","desc":"Team lead"}'
# Response: {"role_id":"team:lead"}Assign role to user
curl -X POST http://localhost:8081/v1/roles/assign \
-H "Content-Type: application/json" \
-d '{"role_id":"team:lead","user_id":"user-123"}'
# Response: {"ok": true}Create roles in bulk
curl -X POST http://localhost:8081/v1/roles/batch \
-H "Content-Type: application/json" \
-d '[{"name":"viewer","desc":"read only"},{"name":"editor","desc":"edit"}]'
# Response: {"ok": true} or errorAssign many role keys to user
curl -X POST http://localhost:8081/v1/roles/assign/batch \
-H "Content-Type: application/json" \
-d '{"user_id":"user-123","role_ids":["viewer","editor"]}'
# Response: {"ok": true}Delete role (triggers async cleanup job)
curl -X DELETE http://localhost:8081/v1/roles/team:lead
# Response: {"ok": true}
# or async remove request (explicit)
curl -X POST http://localhost:8081/v1/roles/remove/async \
-H "Content-Type: application/json" \
-d '{"role":"team:lead"}'
# Response: {"job_id":"<job id>"}Check cleanup job status
curl http://localhost:8081/v1/jobs/<job id>
# Response: JSON with job status and countsRemove role from a particular user's grants
curl -X DELETE http://localhost:8081/v1/roles/team:lead/users/user-123
# Response: {"ok": true}The package exposes pkg/middleware with a RoleMiddleware(svc) which populates the Gin context with the resolved user ID and roles. Use it like this in your Gin routes:
r := gin.New()
// attach middleware to some routes that require role lookup
r.GET("/v1/me/profile", middleware.RoleMiddleware(svc), func(c *gin.Context) {
rolesI, _ := c.Get(middleware.ContextRolesKey)
userID, _ := c.Get(middleware.ContextUserIDKey)
c.JSON(200, gin.H{"user": userID, "roles": rolesI})
})HasAnyRole helper usage (to check authorization within your code):
userRoles := []string{"viewer", "editor"}
if middleware.HasAnyRole(userRoles, "admin", "editor") {
// user has at least one of the required roles
}HasAnyRole is a simple set membership helper that returns true if any of rolesToCheck is present in userRoles.
- When
DeleteRoleis called in Zitadel, the service starts a background job (stored in Redis keyjob:roles_cleanup:<jobid>) that scansroles:*keys and removes the role from cached role lists. - The job updates its progress periodically and can be polled via
svc.GetCleanupJobStatusor the HTTP endpoint/v1/jobs/:id. - The job runs in the background inside the running process. If your process restarts, the job will not be resumed. You can use the job status key (it lives 24h in Redis) to inspect the last result.
Important: the long-running background job is implemented as a goroutine that scans Redis keys and updates them using pipelines. If you expect extremely large user counts, consider running this cleanup in a dedicated worker process or offloading to a separate job queue for better reliability.
To build the image (server) provided in cmd/server/Dockerfile the repository contains a multi-stage Dockerfile.
Build locally:
# from repo root
docker build -f cmd/server/DockerFile -t my-authz:latest .
# run (provide env values)
docker run -e ZITADEL_DOMAIN="http://host.docker.internal:8080" \
-e SERVICE_ACCOUNT_TOKEN="<token>" \
-e PROJECT_ID="<project id>" \
-e REDIS_ADDR="host.docker.internal:6379" \
-p 8081:8081 my-authz:latestNotes about Dockerfile in repo:
- Builds Go binary with
CGO_ENABLED=0 GOOS=linux GOARCH=amd64. - Uses
alpine:3.18runtime withtiniand a non-rootappuser. - Exposes port
8081by default (from.envin your repo you might usePORT=8081).
- When calling Zitadel, transient errors are retried using
hashicorp/go-retryablehttpwith a circuit breaker around requests (sony/gobreaker). Tune retries and circuit breaker values usingREQUEST_TIMEOUT,RETRY_MAX,CB_INTERVAL, andCB_TIMEOUTenv vars. - Cache entries are JSON objects with
rolesandfetched_atfields. Deleting a role triggers an async background job which may take time depending on number of cached users. - If you call
AssignRole/AssignRolesToUserthe code invalidates the cache for the affected user ID so the next request will fetch fresh roles from Zitadel. - The
RoleMiddlewareresolves user id either fromX-User-IDheader (preferred for internal calls) or by calling the Zitadel/oidc/v1/userinfoendpoint with the Bearer token present inAuthorizationheader. For opaque tokens, ensureZITADEL_DOMAINis set.
Q: I want the background cleanup to survive restarts. A: The current implementation runs jobs in-process. For resilience, extract the cleanup loop into a separate worker service (could be the same binary but a different process mode) or push jobs to a durable queue (e.g., Redis streams, RabbitMQ) and run dedicated workers.
Q: How do I handle many role keys per user? A: The cache stores role arrays. The cleanup logic uses in-place slice filtering and pipelines. If you expect huge role lists, consider paginating or sharding cache keys.
Q: How is TTL handled when cleaning up? A: The cleaner attempts to preserve TTLs by reading existing TTL and re-applying it. If TTL can't be read, the default TTL from config is used.
- Follow existing repository style
- Add unit tests for
pkg/cacheandpkg/zitadelclient parsing where possible - Do not commit secrets
(Include your license here — e.g., MIT)
If you'd like, I can also:
- Provide a shorter
README(one-page quickstart) - Generate
examples/with small Go programs demonstrating each operation - Translate to Arabic