User Management & Feature Flags System - Implementation Todo
User Management & Feature Flags System - Implementation Todo
Overview
Implement a comprehensive user management system with role-based access control (RBAC), group-based organization, and group-level feature flags for Jan Server.
Architecture Decision:
- Use Keycloak as the single source of truth for users, roles, and groups
- No local cache/sync - Read directly from JWT claims
- Feature flags stored in Keycloak group attributes and included in JWT
- Jan Server only stores feature flag definitions and audit logs
- Users inherit ALL feature flags from ALL their groups (additive inheritance)
Technology Stack
Backend
- Language: Go (Golang)
- Framework: Gin (HTTP router)
- Database: PostgreSQL (audit logs & feature flag definitions only)
- Keycloak Client:
github.com/Nerzal/gocloak/v13(Admin API for management operations only) - Authentication: JWT validation via JWKS
- Circuit Breaker:
github.com/sony/gobreaker(Keycloak resilience)
Frontend (Future)
- Framework: React or Next.js
- UI Library: shadcn/ui or Material-UI
- State Management: React Query (for cache management)
- Tables: TanStack Table (React Table)
- Forms: React Hook Form + Zod validation
Infrastructure
- Identity Provider: Keycloak (users, groups, roles, attributes)
- API Gateway: Kong (already in use)
- JWT Validation: JWKS endpoint from Keycloak
- Observability: Prometheus + Grafana (already in use)
User Roles
Admin Role
- Privileges: Full system access, can manage all users and groups
- Identification:
- Keycloak realm role:
adminOR - Keycloak user attribute:
is_admin: true
- Keycloak realm role:
- Source of Truth: Keycloak realm roles
- Capabilities:
- Create, read, update, delete users (via Keycloak Admin API)
- Assign/revoke admin privileges
- Manage user groups (via Keycloak Admin API)
- Activate/deactivate user accounts
- View all system users
- Manage group feature flags (admin-only)
User Role
- Privileges: Standard platform access
- Identification: Default role for authenticated users
- Source of Truth: Keycloak realm roles
- Capabilities:
- Access own profile
- Use platform features (based on group feature flags)
- Cannot manage other users
- Cannot modify feature flags
User Groups
Source of Truth: All groups are managed in Keycloak. Jan Server reads group membership from JWT claims.
jan_group
- Description: Internal Jan team members
- Membership Criteria: Email domain
@jan.aiOR@menlo.ai - Auto-assignment: ✅ Automatic via Keycloak event listeners or registration flow
- Keycloak Setup: Create group in Keycloak realm
- Default Feature Flags: Experimental model access enabled
feature_flags: - experimental_models # Can see all models including experimental ones
pilot_users
- Description: Beta testers and early adopters
- Membership Criteria: Manual assignment by admin
- Auto-assignment: ❌ Manual only
- Keycloak Setup: Create group in Keycloak realm
- Default Feature Flags: Experimental model access enabled
feature_flags: - experimental_models # Can see experimental models for testing
standard
- Description: Verified regular users
- Membership Criteria: Verified email address
- Auto-assignment: ✅ Automatic after email verification
- Keycloak Setup: Create group in Keycloak realm
- Default Feature Flags: None (stable models only)
feature_flags: [] # Can only see stable/production models
guest
- Description: Guest/temporary access
- Membership Criteria: Guest login without full registration
- Auto-assignment: ✅ Automatic for guest login flow
- Keycloak Setup: Create group in Keycloak realm
- Default Feature Flags: None (stable models only)
feature_flags: [] # Can only see stable/production models
Feature Flags System
Overview
Feature flags allow fine-grained control over which features are available to users based on their group membership.
Current Implementation: Single feature flag for experimental model access
Design Principles:
- ✅ Group-level only: Feature flags defined per group
- ✅ Additive inheritance: Users get ALL flags from ALL their groups
- ✅ Simple management: No user-level overrides, no global flags
- ✅ Keycloak-first: Stored as Keycloak group attributes
- ✅ JWT-based: All feature flags included in JWT, no server-side caching or database lookups needed
Feature Flag Architecture
Source of Truth: Keycloak group attributes
Resolution Logic:
// User has a feature if ANY of their groups has that feature
// All data comes from JWT claims - no database queries
func IsFeatureEnabled(c *gin.Context, flagKey string) bool {
claims := getJWTClaims(c) // Already validated by auth middleware
// Defensive parsing of feature_flags array from JWT
featureFlagsRaw, ok := claims["feature_flags"]
if !ok {
return false
}
// Handle different possible types
switch flags := featureFlagsRaw.(type) {
case []interface{}:
for _, flag := range flags {
if flagStr, ok := flag.(string); ok && flagStr == flagKey {
return true
}
}
case []string:
for _, flag := range flags {
if flag == flagKey {
return true
}
}
default:
// Unexpected type, log warning
log.Warn().Msgf("Unexpected feature_flags type: %T", flags)
}
return false
}
// Get user info from JWT with defensive parsing
func GetUserInfo(c *gin.Context) (UserInfo, error) {
claims := getJWTClaims(c)
userInfo := UserInfo{}
// Parse sub (required)
sub, ok := claims["sub"].(string)
if !ok {
return userInfo, fmt.Errorf("missing or invalid sub claim")
}
userInfo.ID = sub
// Parse email (required)
email, ok := claims["email"].(string)
if !ok {
return userInfo, fmt.Errorf("missing or invalid email claim")
}
userInfo.Email = email
// Parse name (optional)
if name, ok := claims["name"].(string); ok {
userInfo.Name = name
}
// Parse email_verified (optional, defaults to false)
if emailVerified, ok := claims["email_verified"].(bool); ok {
userInfo.EmailVerified = emailVerified
}
// Parse groups with type checking
if groupsRaw, ok := claims["groups"]; ok {
switch groups := groupsRaw.(type) {
case []interface{}:
for _, g := range groups {
if groupStr, ok := g.(string); ok {
userInfo.Groups = append(userInfo.Groups, groupStr)
}
}
case []string:
userInfo.Groups = groups
}
}
// Check admin status
userInfo.IsAdmin = hasAdminRole(claims)
return userInfo, nil
}
### Feature Flag Storage
#### In Keycloak (Source of Truth)
Store as group attributes:
```json
{
"name": "jan_group",
"attributes": {
"feature_flags": ["experimental_models"]
}
}In Jan Server Database (Definitions Only)
feature_flagstable - Feature flag metadata (key, name, description, category)audit_logstable - Audit trail for admin actions
In JWT (All Runtime Data)
{
"sub": "user_id",
"email": "[email protected]",
"name": "John Doe",
"preferred_username": "john",
"email_verified": true,
"groups": ["/jan_group", "/pilot_users"],
"realm_access": {
"roles": ["admin"]
},
"feature_flags": ["experimental_models"]
}JWT contains everything needed - no database queries for user/group info
Available Feature Flags
# Model Access Control
experimental_models: Enable access to experimental/beta models in model_catalogs table
- Description: Users with this flag can see models marked as experimental=true
- Usage: Filter model catalog API based on this flag
- Groups with access: jan_group, pilot_users
- Default groups (standard, guest): No access to experimental modelsModel Catalog Integration
Models in model_catalogs table have an experimental flag:
-- Example model catalog entries
INSERT INTO model_catalogs (id, model_id, name, experimental) VALUES
('1', 'gpt-4', 'GPT-4', false), -- Stable, visible to all users
('2', 'gpt-4-turbo', 'GPT-4 Turbo', true), -- Experimental, requires flag
('3', 'claude-3', 'Claude 3', false), -- Stable, visible to all users
('4', 'jan-v3-experimental', 'Jan v3', true); -- Experimental, requires flagAPI filtering:
func GetModelCatalog(c *gin.Context) {
userID := c.GetString("user_id")
hasExperimentalAccess := featureFlagService.IsFeatureEnabled(userID, "experimental_models")
query := "SELECT * FROM model_catalogs WHERE 1=1"
if !hasExperimentalAccess {
query += " AND experimental = false"
}
query += " ORDER BY name"
// Execute query...
}User Status
🟢 Active
- User can log in and use the platform
- All features accessible based on role and group feature flags
- Default status for new users after verification
- Keycloak Representation:
enabled: true
⚪ Inactive
- User account disabled
- Cannot log in
- Sessions invalidated by Keycloak
- Reversible by admin
- Keycloak Representation:
enabled: false
🔵 Pending Verification
- User registered but email not verified
- Limited or no access until verification
- Keycloak Representation:
emailVerified: false
🔴 Banned
- User permanently restricted from access
- Cannot log in, requires admin intervention
- Keycloak Representation:
enabled: falsewith custom attributebanned: true
API Security - Admin-Only Endpoints
Protected Routes with RequireAdmin Middleware
All endpoints under /api/v1/admin/** require admin authentication:
adminRoutes := router.Group("/api/v1/admin")
adminRoutes.Use(middleware.RequireAdmin())
{
// User Management
adminRoutes.GET("/users", handlers.ListUsers)
adminRoutes.POST("/users", handlers.CreateUser)
adminRoutes.GET("/users/:id", handlers.GetUser)
adminRoutes.PATCH("/users/:id", handlers.UpdateUser)
adminRoutes.DELETE("/users/:id", handlers.DeleteUser)
adminRoutes.POST("/users/:id/activate", handlers.ActivateUser)
adminRoutes.POST("/users/:id/deactivate", handlers.DeactivateUser)
adminRoutes.POST("/users/:id/roles/:roleName", handlers.AssignRole)
adminRoutes.DELETE("/users/:id/roles/:roleName", handlers.RemoveRole)
// Group Management
adminRoutes.GET("/groups", handlers.ListGroups)
adminRoutes.POST("/groups", handlers.CreateGroup)
adminRoutes.GET("/groups/:id", handlers.GetGroup)
adminRoutes.PATCH("/groups/:id", handlers.UpdateGroup)
adminRoutes.DELETE("/groups/:id", handlers.DeleteGroup)
adminRoutes.GET("/groups/:id/members", handlers.GetGroupMembers)
adminRoutes.POST("/users/:userId/groups/:groupId", handlers.AddUserToGroup)
adminRoutes.DELETE("/users/:userId/groups/:groupId", handlers.RemoveUserFromGroup)
// Feature Flag Management (Group Level)
adminRoutes.GET("/feature-flags", handlers.ListFeatureFlags)
adminRoutes.POST("/feature-flags", handlers.CreateFeatureFlag)
adminRoutes.PATCH("/feature-flags/:id", handlers.UpdateFeatureFlag)
adminRoutes.DELETE("/feature-flags/:id", handlers.DeleteFeatureFlag)
adminRoutes.GET("/groups/:id/feature-flags", handlers.GetGroupFeatureFlags)
adminRoutes.PATCH("/groups/:id/feature-flags", handlers.SetGroupFeatureFlags)
adminRoutes.POST("/groups/:id/feature-flags/:flagKey", handlers.EnableGroupFeatureFlag)
adminRoutes.DELETE("/groups/:id/feature-flags/:flagKey", handlers.DisableGroupFeatureFlag)
// System Monitoring
adminRoutes.GET("/system/health", handlers.GetSystemHealth)
adminRoutes.GET("/system/metrics", handlers.GetSystemMetrics)
adminRoutes.GET("/audit-logs", handlers.GetAuditLogs)
}RequireAdmin Middleware
// filepath: services/llm-api/internal/middleware/admin.go
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Helper function to check admin status with defensive parsing
func isAdmin(claims map[string]interface{}) bool {
// Check realm_access.roles for 'admin'
realmAccessRaw, ok := claims["realm_access"]
if ok {
realmAccess, ok := realmAccessRaw.(map[string]interface{})
if ok {
rolesRaw, ok := realmAccess["roles"]
if ok {
// Handle both []interface{} and []string
switch roles := rolesRaw.(type) {
case []interface{}:
for _, role := range roles {
if roleStr, ok := role.(string); ok && roleStr == "admin" {
return true
}
}
case []string:
for _, role := range roles {
if role == "admin" {
return true
}
}
}
}
}
}
// Fallback: check is_admin attribute
if attributes, ok := claims["attributes"].(map[string]interface{}); ok {
if isAdminAttr, ok := attributes["is_admin"].(bool); ok && isAdminAttr {
return true
}
}
return false
}
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
claimsRaw, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
"message": "Authentication required",
})
c.Abort()
return
}
claims, ok := claimsRaw.(map[string]interface{})
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
"message": "Invalid claims format",
})
c.Abort()
return
}
// Verify required claims exist
if _, ok := claims["sub"]; !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
"message": "Missing user identifier",
})
c.Abort()
return
}
if _, ok := claims["email"]; !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized",
"message": "Missing user email",
})
c.Abort()
return
}
// Check admin status using helper
if !isAdmin(claims) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden",
"message": "Admin access required",
})
c.Abort()
return
}
c.Next()
}
}Security Enhancements
-
Audit Logging: Log to PostgreSQL
audit_logstable- All admin actions with full context (user_id, email, action, resource, payload)
- Include IP address and user agent for security tracking
- Retention: 90 days (configurable)
- Indexed for fast querying by admin_user_id, resource_type, and created_at
-
Input Validation: Use
github.com/go-playground/validator/v10- Validate email format, name length, etc.
- Sanitize to prevent injection
- Validate flag keys (alphanumeric + underscore)
-
CSRF Protection: Use Gin CSRF middleware
- Token validation on all mutations
-
MFA: Enforce via Keycloak for admin users
Operational Guardrails
Rate Limiting
- Implement rate limiting on admin endpoints
- Use Kong rate limiting plugin or middleware
- Limit: 100 requests/minute per admin user
- Separate limits for read (higher) vs write (lower) operations
- Return 429 Too Many Requests with Retry-After header
Request Logging
- Structured logging for all admin endpoints
- Log level: INFO for successful operations, WARN for 4xx, ERROR for 5xx
- Include: timestamp, admin_user_id, admin_email, action, resource_type, resource_id, status_code, duration_ms
- Use correlation ID for request tracing across services
- Integrate with existing observability stack (Prometheus/Grafana)
Keycloak Resilience
-
Circuit breaker for Keycloak Admin API calls
- Use
github.com/sony/gobreaker(already in tech stack) - Threshold: 5 consecutive failures
- Timeout: 30 seconds
- Reset after: 60 seconds of successful calls
- Fallback: Return 503 Service Unavailable with meaningful error
- Use
-
Retry logic with exponential backoff
- Max retries: 3
- Initial delay: 100ms
- Max delay: 2 seconds
- Only retry on network errors or 5xx responses
- Do not retry on 4xx client errors
-
Monitoring and alerting
- Alert on circuit breaker open state
- Alert on Keycloak API latency > 1 second
- Alert on Keycloak API error rate > 5%
- Dashboard showing Keycloak health, request rate, error rate, latency percentiles
Implementation Tasks
Phase 0: Keycloak Integration
-
Install Dependencies
go get github.com/Nerzal/gocloak/v13 go get github.com/sony/gobreaker go get github.com/go-playground/validator/v10 -
Keycloak Client Setup
pkg/keycloak/client.go- Admin client wrapper with connection poolingpkg/keycloak/users.go- User operationspkg/keycloak/groups.go- Group operationspkg/keycloak/roles.go- Role operations
Phase 1: Database Schema (Minimal)
-
Create Migration:
services/llm-api/migrations/YYYYMMDD_user_management.sql -
feature_flags table (definitions only):
CREATE TABLE feature_flags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, category VARCHAR(50), metadata JSONB, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_feature_flags_key ON feature_flags(key); CREATE INDEX idx_feature_flags_category ON feature_flags(category); -- Insert the experimental_models flag INSERT INTO feature_flags (key, name, description, category) VALUES ('experimental_models', 'Experimental Models', 'Access to experimental/beta models in model catalog', 'model_access'); -
audit_logs table:
CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), admin_user_id VARCHAR(255) NOT NULL, -- From JWT sub admin_email VARCHAR(255) NOT NULL, -- From JWT email action VARCHAR(100) NOT NULL, resource_type VARCHAR(50) NOT NULL, resource_id VARCHAR(255), payload JSONB, ip_address VARCHAR(45), user_agent TEXT, status_code INTEGER, error_message TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC); CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id); CREATE INDEX idx_audit_logs_resource_type ON audit_logs(resource_type); -
Add experimental column to model_catalogs:
ALTER TABLE model_catalogs ADD COLUMN IF NOT EXISTS experimental BOOLEAN DEFAULT FALSE; CREATE INDEX idx_model_catalogs_experimental ON model_catalogs(experimental);
Phase 2: JWT & Authentication
-
JWT Middleware Enhancement:
services/llm-api/internal/middleware/auth.go// Extract and store JWT claims in context func JWTAuth() gin.HandlerFunc { return func(c *gin.Context) { // Validate JWT (already done by Kong or existing middleware) claims := extractJWTClaims(c) // Store in context for easy access c.Set("claims", claims) c.Set("user_id", claims["sub"]) c.Set("user_email", claims["email"]) c.Set("user_groups", claims["groups"]) c.Set("feature_flags", claims["feature_flags"]) c.Next() } } -
RequireAdmin Middleware: Check JWT for admin role
func RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { claims := c.MustGet("claims").(map[string]interface{}) // Check admin status using helper function if !isAdmin(claims) { c.JSON(http.StatusForbidden, gin.H{ "error": "Forbidden", "message": "Admin access required", }) c.Abort() return } c.Next() } }
Phase 3: Model Catalog Filtering
- Update Model Catalog Handler:
services/llm-api/internal/handlers/model_catalog.gofunc GetModelCatalog(c *gin.Context) { // Check if user has experimental model access from JWT hasExperimentalAccess := IsFeatureEnabled(c, "experimental_models") query := "SELECT * FROM model_catalogs WHERE 1=1" if !hasExperimentalAccess { query += " AND (experimental = false OR experimental IS NULL)" } query += " ORDER BY name" // Execute query and return models rows, err := db.Query(query) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch models"}) return } defer rows.Close() // Parse and return results... }
Phase 4: User Management API
- Implement User Management Handlers:
services/llm-api/internal/handlers/admin/users.go- ListUsers, CreateUser, GetUser, UpdateUser, DeleteUser
- ActivateUser, DeactivateUser
- All operations via Keycloak Admin API
- Audit logging for all actions
Phase 5: Group Management API
- Implement Group Management Handlers:
services/llm-api/internal/handlers/admin/groups.go- ListGroups, CreateGroup, GetGroup, UpdateGroup, DeleteGroup
- GetGroupMembers, AddUserToGroup, RemoveUserFromGroup
- All operations via Keycloak Admin API
- Audit logging for all actions
Phase 6: Feature Flag Management API
- Implement Feature Flag Handlers:
services/llm-api/internal/handlers/admin/feature_flags.go- ListFeatureFlags (from database), CreateFeatureFlag, UpdateFeatureFlag, DeleteFeatureFlag
- GetGroupFeatureFlags (from Keycloak), SetGroupFeatureFlags
- EnableGroupFeatureFlag, DisableGroupFeatureFlag
- Update Keycloak group attributes
- Audit logging for all flag changes
Phase 7: Keycloak JWT Mapper Configuration
-
Configure Group Mapper in Keycloak to include group paths in JWT
- Mapper Type: Group Membership
- Token Claim Name:
groups - Full group path: Yes (includes leading slash:
/jan_group,/pilot_users) - Add to ID token: Yes
- Add to access token: Yes
- Add to userinfo: Yes
-
Configure Group Attribute Mapper for feature flags
- Mapper Type: Group Attribute Mapper (or User Attribute if aggregating)
- Group Attribute:
feature_flags - Token Claim Name:
feature_flags - Claim JSON Type: JSON (array of strings)
- Aggregate attribute values: Yes (combine from all groups)
- Add to ID token: Yes
- Add to access token: Yes
-
Note on Group Paths:
- Keycloak includes leading slash in group paths:
/jan_group,/standard - Nested groups use hierarchy:
/parent/child - Strip leading slash in application code if needed for comparisons
- Group inheritance: Users in child groups automatically in parent groups
- Keycloak includes leading slash in group paths:
-
Example JWT with all needed data:
{ "sub": "user-uuid", "email": "[email protected]", "name": "John Doe", "preferred_username": "john", "email_verified": true, "groups": ["/jan_group", "/standard"], "realm_access": { "roles": ["admin", "user"] }, "feature_flags": ["experimental_models"] }
Phase 8: Testing & Validation
- Unit Tests: Test feature flag resolution, admin middleware, JWT parsing
- Integration Tests: Test model catalog filtering with different user groups
- End-to-End Tests: Test admin operations with Keycloak
- Performance Tests: Validate JWT-only approach performance
Database Queries
Get Users with Experimental Model Access (from audit logs)
-- This is for audit/reporting only, not for runtime checks
SELECT DISTINCT admin_email, action, created_at
FROM audit_logs
WHERE resource_type = 'group_feature_flag'
AND payload->>'flag_key' = 'experimental_models'
ORDER BY created_at DESC;Get User's Effective Feature Flags (from JWT)
// No database query needed - read from JWT claims
func GetUserFeatures(c *gin.Context) []string {
if featureFlags, exists := c.Get("feature_flags"); exists {
if flags, ok := featureFlags.([]string); ok {
return flags
}
}
return []string{}
}Get Experimental Models
-- Get all experimental models (admin view)
SELECT * FROM model_catalogs
WHERE experimental = true
ORDER BY name;
-- Get models available to user without experimental access
SELECT * FROM model_catalogs
WHERE experimental = false OR experimental IS NULL
ORDER BY name;
-- Get all models (for users with experimental_models flag)
SELECT * FROM model_catalogs
ORDER BY name;Feature Flag Audit Trail
-- See who changed feature flags for a group
SELECT
admin_email,
action,
payload->>'group_id' as group_id,
payload->>'group_name' as group_name,
payload->>'flag_key' as flag_key,
payload->>'enabled' as enabled,
created_at
FROM audit_logs
WHERE resource_type = 'group_feature_flag'
ORDER BY created_at DESC
LIMIT 100;Future Enhancements
-
User impersonation (via Keycloak Admin API)
- Allow admins to impersonate users for troubleshooting
- Audit log all impersonation sessions
-
Bulk operations
- Bulk user import/export
- Bulk group membership changes
- Bulk feature flag assignments
-
Additional feature flags as product evolves
fine_tuning: Access to model fine-tuning featurescustom_models: Upload and use custom modelsadvanced_analytics: Access to advanced usage analyticsapi_access: Programmatic API access with keys
-
Advanced feature flag capabilities
- A/B testing with user percentage rollout
- Gradual rollout with canary deployment
- Time-based scheduling (enable/disable at specific times)
- Environment-specific flags (dev/staging/prod)
Frontend Implementation Scope (Future)
Note: Frontend implementation is out of scope for this backend-focused document. If a UI is needed, it should:
-
Admin Dashboard (separate project)
- User management interface (list, create, edit, deactivate users)
- Group management interface (list, create, edit, manage members)
- Feature flag management UI (assign flags to groups)
- Audit log viewer with filtering and export
-
Tech Stack Recommendations
- Framework: React or Next.js
- UI Library: shadcn/ui or Material-UI
- State Management: React Query (for API cache management)
- Tables: TanStack Table
- Forms: React Hook Form + Zod validation
-
Authentication
- Use Keycloak's JavaScript adapter for SSO
- Redirect to Keycloak login
- Store JWT in httpOnly cookie or memory
- Handle token refresh automatically
For now, admin operations can be performed via:
- Direct Keycloak Admin Console
- API testing tools (Postman, Insomnia, curl)
- Custom CLI tools if needed
Architecture Summary
Key Principles
- JWT-Only: All user/group/feature flag data comes from JWT claims - no server-side cache or sync
- Keycloak as Source of Truth: Users, groups, roles, and feature flag assignments managed in Keycloak
- Minimal Database: Only audit logs and feature flag definitions (metadata) stored in PostgreSQL
- Real-Time Admin Operations: Admin API calls directly to Keycloak for immediate effect
- Automatic JWT Updates: Feature flag changes reflected in next JWT after token refresh (handled by Keycloak)