1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/dev_handler.go

1989 lines
59 KiB
Go

package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
type DevHandler struct {
Hydra *service.HydraAdminService
Redis domain.RedisRepository
SecretRepo domain.ClientSecretRepository
AuditRepo domain.AuditRepository
KratosAdmin service.KratosAdminService
ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
RPSvc service.RelyingPartyService
TenantSvc service.TenantService
Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
}
func NewDevHandler(
redis domain.RedisRepository,
secretRepo domain.ClientSecretRepository,
consentRepo repository.ClientConsentRepository,
rpSvc service.RelyingPartyService,
keto service.KetoService, tenantSvc service.TenantService,
auth ...interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
},
) *DevHandler {
var authProvider interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
if len(auth) > 0 {
authProvider = auth[0]
}
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,
SecretRepo: secretRepo,
AuditRepo: nil,
KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo,
Keto: keto,
RPSvc: rpSvc,
TenantSvc: tenantSvc,
Auth: authProvider,
}
}
type devAuditListResponse struct {
Items []domain.AuditLog `json:"items"`
Limit int `json:"limit"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"next_cursor,omitempty"`
}
type devStatsResponse struct {
TotalClients int64 `json:"total_clients"`
ActiveSessions int64 `json:"active_sessions"`
AuthFailures int64 `json:"auth_failures_24h"`
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
JwksUri string `json:"jwksUri,omitempty"`
Jwks interface{} `json:"jwks,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type clientListResponse struct {
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type clientDetailResponse struct {
Client clientSummary `json:"client"`
Endpoints clientEndpoints `json:"endpoints"`
}
type clientEndpoints struct {
Discovery string `json:"discovery"`
Issuer string `json:"issuer"`
Authorization string `json:"authorization"`
Token string `json:"token"`
UserInfo string `json:"userinfo"`
}
type consentSummary struct {
Subject string `json:"subject"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName,omitempty"`
GrantedScopes []string `json:"grantedScopes"`
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
Status string `json:"status"`
TenantID string `json:"tenantId,omitempty"`
TenantName string `json:"tenantName,omitempty"`
}
type consentListResponse struct {
Items []consentSummary `json:"items"`
}
type clientUpsertRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
Type *string `json:"type"`
Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
JwksUri *string `json:"jwksUri"`
Jwks interface{} `json:"jwks"`
Metadata *map[string]interface{} `json:"metadata"`
}
var protectedSystemClientIDs = map[string]struct{}{
"oathkeeper-introspect": {},
}
var reservedSystemClientNames = map[string]string{
"adminfront": "adminfront",
"devfront": "devfront",
}
func normalizeUserRole(role string) string {
return domain.NormalizeRole(role)
}
func isDevConsoleRoleAllowed(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin:
return true
default:
return false
}
}
func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse {
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
return profile
}
if h.Auth != nil {
enriched, err := h.Auth.GetEnrichedProfile(c)
if err == nil && enriched != nil {
c.Locals("user_profile", enriched)
return enriched
}
}
return nil
}
func tenantIDFromProfile(profile *domain.UserProfileResponse) string {
if profile == nil || profile.TenantID == nil {
return ""
}
return strings.TrimSpace(*profile.TenantID)
}
func addClientIDToSet(set map[string]struct{}, raw any) {
switch value := raw.(type) {
case string:
for _, chunk := range strings.Split(value, ",") {
id := strings.TrimSpace(chunk)
if id != "" {
set[id] = struct{}{}
}
}
case []string:
for _, item := range value {
id := strings.TrimSpace(item)
if id != "" {
set[id] = struct{}{}
}
}
case []any:
for _, item := range value {
if str, ok := item.(string); ok {
id := strings.TrimSpace(str)
if id != "" {
set[id] = struct{}{}
}
}
}
}
}
func managedClientIDsFromProfile(profile *domain.UserProfileResponse) map[string]struct{} {
ids := make(map[string]struct{})
if profile == nil {
return ids
}
if profile.RelyingPartyID != nil {
if id := strings.TrimSpace(*profile.RelyingPartyID); id != "" {
ids[id] = struct{}{}
}
}
if profile.Metadata == nil {
return ids
}
for _, key := range []string{
"managed_client_ids",
"managedClientIds",
"relying_party_id",
"relyingPartyId",
"client_id",
"clientId",
} {
if raw, ok := profile.Metadata[key]; ok {
addClientIDToSet(ids, raw)
}
}
return ids
}
func resolveClientTenantID(summary clientSummary) string {
if summary.Metadata == nil {
return ""
}
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
return strings.TrimSpace(clientTenantID)
}
func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool {
if normalizeUserRole(profileRole(profile)) != domain.RoleRPAdmin {
return true
}
allowed := managedClientIDsFromProfile(profile)
if len(allowed) == 0 {
return false
}
_, ok := allowed[strings.TrimSpace(clientID)]
return ok
}
func profileRole(profile *domain.UserProfileResponse) string {
if profile == nil {
return ""
}
return strings.TrimSpace(profile.Role)
}
func isProtectedSystemClientID(clientID string) bool {
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
return ok
}
func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID)
}
func isReservedSystemClientAlias(client domain.HydraClient) bool {
ownerID, reserved := reservedSystemClientOwnerID(client.ClientName)
if !reserved {
return false
}
return !strings.EqualFold(strings.TrimSpace(client.ClientID), ownerID)
}
func isHiddenSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClient(client) || isReservedSystemClientAlias(client)
}
func reservedSystemClientOwnerID(name string) (string, bool) {
ownerID, ok := reservedSystemClientNames[strings.ToLower(strings.TrimSpace(name))]
return ownerID, ok
}
func validateReservedSystemClientName(clientID, name string) error {
ownerID, reserved := reservedSystemClientOwnerID(name)
if !reserved {
return nil
}
if strings.EqualFold(strings.TrimSpace(clientID), ownerID) {
return nil
}
return fmt.Errorf("forbidden: reserved system client name")
}
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
enriched, err := h.Auth.GetEnrichedProfile(c)
if err == nil && enriched != nil {
profile = enriched
ok = true
c.Locals("user_profile", enriched)
}
}
if ok && profile != nil {
role := normalizeUserRole(profile.Role)
switch role {
case domain.RoleSuperAdmin:
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
return true, nil
case domain.RoleTenantAdmin, domain.RoleRPAdmin:
slog.Info("Dev private permission granted by role", "user_id", profile.ID, "role", role)
return true, nil
case domain.RoleUser:
return false, nil
}
// Super Admin bypass
if isAdminEmail(profile.Email) {
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
return true, nil
}
subject := strings.TrimSpace(profile.ID)
if subject == "" && strings.TrimSpace(profile.Email) != "" && h.KratosAdmin != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), strings.TrimSpace(profile.Email))
if err == nil && strings.TrimSpace(resolved) != "" {
subject = strings.TrimSpace(resolved)
}
}
if subject == "" {
slog.Warn("Dev private permission denied: missing subject in profile", "email", profile.Email)
return false, nil
}
if h.Keto == nil {
slog.Warn("Dev private permission denied: keto service unavailable")
return false, nil
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "AppManager", "member")
if err != nil {
// Fail closed for dev private endpoints: deny on permission backend error.
slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err)
return false, nil
}
slog.Info("Dev private permission evaluated by Keto", "subject", subject, "allowed", allowed)
return allowed, nil
}
authHeader := c.Get("Authorization")
bearerToken := extractBearerToken(authHeader)
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(authHeader)
tokenRole := ""
// Fallback for OIDC access tokens that do not include full claims locally.
if bearerToken != "" && (tokenSubject == "" || tokenEmail == "") {
if info, err := h.fetchOIDCUserInfo(c.Context(), bearerToken); err == nil && info != nil {
if tokenSubject == "" {
tokenSubject = strings.TrimSpace(info.Sub)
}
if tokenEmail == "" {
tokenEmail = strings.TrimSpace(info.Email)
}
tokenRole = normalizeUserRole(info.Role)
} else if err != nil {
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
}
}
tokenRole = normalizeUserRole(tokenRole)
if tokenRole == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil
}
if tokenRole == domain.RoleTenantAdmin || tokenRole == domain.RoleRPAdmin {
slog.Info("Dev private permission granted by token role", "role", tokenRole)
return true, nil
}
if tokenRole == domain.RoleUser {
return false, nil
}
if isAdminEmail(tokenEmail) {
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
return true, nil
}
// If subject is missing, resolve it from Kratos by identifier(email) so Keto checks can still run.
if tokenSubject == "" && tokenEmail != "" && h.KratosAdmin != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), tokenEmail)
if err == nil && strings.TrimSpace(resolved) != "" {
tokenSubject = strings.TrimSpace(resolved)
}
}
if tokenSubject == "" {
return false, nil
}
if h.Keto == nil {
slog.Warn("Dev private permission denied: keto service unavailable")
return false, nil
}
// Fallback: resolve role from Kratos identity traits when user_profile is not injected.
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
if err == nil && identity != nil {
if rawRole, ok := identity.Traits["role"].(string); ok && normalizeUserRole(rawRole) == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
return true, nil
}
if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) {
slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email)
return true, nil
}
}
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
if err != nil {
// Fail closed for dev private endpoints: deny on permission backend error.
slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err)
return false, nil
}
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
return allowed, nil
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return "", ""
}
token := strings.TrimSpace(authHeader[len("Bearer "):])
if token == "" || strings.Count(token, ".") != 2 {
return "", ""
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
payload, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return "", ""
}
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", ""
}
sub := ""
if sub, ok := claims["sub"].(string); ok {
sub = strings.TrimSpace(sub)
}
email := ""
if claimEmail, ok := claims["email"].(string); ok {
email = strings.TrimSpace(claimEmail)
}
return sub, email
}
func isAdminEmail(email string) bool {
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
}
func extractBearerToken(authHeader string) string {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return ""
}
return strings.TrimSpace(authHeader[len("Bearer "):])
}
type oidcUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
TenantID string `json:"tenant_id"`
Role string `json:"role"`
}
func (h *DevHandler) fetchOIDCUserInfo(ctx context.Context, accessToken string) (*oidcUserInfo, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("missing access token")
}
if h.Hydra == nil || strings.TrimSpace(h.Hydra.PublicURL) == "" {
return nil, fmt.Errorf("hydra public url is not configured")
}
endpoint := strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("userinfo failed status=%d body=%s", resp.StatusCode, string(body))
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
pick := func(keys ...string) string {
for _, key := range keys {
if raw, ok := payload[key]; ok {
if value, ok := raw.(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
}
return ""
}
return &oidcUserInfo{
Sub: pick("sub"),
Email: pick("email"),
TenantID: pick("tenant_id", "tenantId"),
Role: pick("role"),
}, nil
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients")
}
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "clients not found")
}
errMsg := err.Error()
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
}
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
if summary.Type == "private" && !isAppManager {
continue
}
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID {
continue
}
}
// 3. [Role Scope] RP Admin can only access managed RP IDs
if role == domain.RoleRPAdmin {
if _, ok := allowedClientIDs[summary.ID]; !ok {
continue
}
}
items = append(items, summary)
}
return c.JSON(clientListResponse{
Items: items,
Limit: limit,
Offset: offset,
})
}
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
clientID := c.Params("id")
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isHiddenSystemClient(*client) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
summary := h.mapClientSummary(*client)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
// [Tenant Isolation] Check if user has access to this client
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID := resolveClientTenantID(summary)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
if !isRPAdminClientAllowed(profile, summary.ID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
// Check permission for private clients
if summary.Type == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := c.Params("id")
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
var req struct {
Status string `json:"status"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
status := strings.ToLower(strings.TrimSpace(req.Status))
if status != "active" && status != "inactive" {
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
}
// [Security] Check permission before patching
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isHiddenSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
// [Tenant Isolation]
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID := resolveClientTenantID(summary)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
if !isRPAdminClientAllowed(profile, summary.ID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
beforeStatus := summary.Status
h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT_STATUS",
"target_id": clientID,
"tenant_id": tenantID,
"before": map[string]any{
"status": beforeStatus,
},
"after": map[string]any{
"status": status,
},
})
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
updatedSummary := h.mapClientSummary(*updated)
return c.JSON(clientDetailResponse{
Client: updatedSummary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
var req clientUpsertRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
clientID := strings.TrimSpace(valueOr(req.ID, ""))
if clientID == "" {
clientID = uuid.NewString()
}
if isProtectedSystemClientID(clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
}
name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" {
name = clientID
}
if err := validateReservedSystemClientName(clientID, name); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
}
redirectURIs := derefSlice(req.RedirectURIs, nil)
if len(redirectURIs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required")
}
scopes := derefSlice(req.Scopes, defaultClientScopes())
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
if clientType != "pkce" && clientType != "private" {
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
}
// [Security] Check permission for private clients
if clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
}
}
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
if status != "active" && status != "inactive" {
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
}
metadata := mergeMetadata(nil, req.Metadata)
if metadata == nil {
metadata = map[string]interface{}{}
}
// [Tenant Isolation] Record owner information
if profile != nil {
metadata["user_id"] = profile.ID
if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID
}
}
if tenantID != "" {
metadata["tenant_id"] = tenantID
}
metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339)
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" {
if clientType == "pkce" {
tokenAuthMethod = "none"
} else {
tokenAuthMethod = "client_secret_basic"
}
}
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod,
valueOr(req.JwksUri, ""),
req.Jwks,
metadata,
)
clientReq := domain.HydraClient{
ClientID: clientID,
ClientName: name,
RedirectURIs: redirectURIs,
GrantTypes: grantTypes,
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
JWKSUri: jwksURI,
JWKS: jwks,
Metadata: metadata,
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "CREATE_CLIENT",
"target_id": clientID,
"tenant_id": tenantID,
"after": map[string]any{
"type": clientType,
"status": status,
"redirect_uri_count": len(redirectURIs),
"scope_count": len(scopes),
},
})
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// Store secret in metadata for later retrieval
if created.ClientSecret != "" {
// 1. Store in PostgreSQL (Source of Truth)
if h.SecretRepo != nil {
_ = h.SecretRepo.Upsert(c.Context(), created.ClientID, created.ClientSecret)
}
// 2. Also store in Redis (Cache)
if h.Redis != nil {
_ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0)
}
}
h.setAuditDetailsExtra(c, map[string]any{"target_id": created.ClientID})
summary := h.mapClientSummary(*created)
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
var req clientUpsertRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isHiddenSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
currentSummary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
// [Tenant Isolation]
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID := resolveClientTenantID(currentSummary)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
if !isRPAdminClientAllowed(profile, currentSummary.ID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
if clientType != "pkce" && clientType != "private" {
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
}
}
// [Security] Check permission for private clients (both current and new type)
if currentSummary.Type == "private" || clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
status := ""
if req.Status != nil {
status = strings.ToLower(strings.TrimSpace(*req.Status))
if status != "active" && status != "inactive" {
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
}
}
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" && clientType != "" {
if clientType == "pkce" {
tokenAuthMethod = "none"
} else {
tokenAuthMethod = "client_secret_basic"
}
}
if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty")
}
metadata := mergeMetadata(current.Metadata, req.Metadata)
if status != "" {
if metadata == nil {
metadata = map[string]interface{}{}
}
metadata["status"] = status
}
resolvedClientType := currentSummary.Type
if clientType != "" {
resolvedClientType = clientType
}
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
resolvedJWKS := req.Jwks
if req.Jwks == nil {
resolvedJWKS = current.JWKS
}
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
resolvedClientType,
resolvedTokenAuthMethod,
resolvedJWKSURI,
resolvedJWKS,
metadata,
)
updated := domain.HydraClient{
ClientID: current.ClientID,
ClientName: valueOr(req.Name, current.ClientName),
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
Metadata: metadata,
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT",
"target_id": clientID,
"tenant_id": tenantID,
"before": map[string]any{
"name": currentSummary.Name,
"type": currentSummary.Type,
"status": currentSummary.Status,
},
"after": map[string]any{
"name": strings.TrimSpace(updated.ClientName),
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
"status": resolveStatusFromMetadata(updated.Metadata),
},
})
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if updatedClient.ClientSecret != "" {
if h.SecretRepo != nil {
_ = h.SecretRepo.Upsert(c.Context(), updatedClient.ClientID, updatedClient.ClientSecret)
}
if h.Redis != nil {
_ = h.Redis.Set("client_secret:"+updatedClient.ClientID, updatedClient.ClientSecret, 0)
}
}
summary := h.mapClientSummary(*updatedClient)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isHiddenSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
// [Tenant Isolation]
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID := resolveClientTenantID(summary)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
if !isRPAdminClientAllowed(profile, summary.ID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
// [Security] Check permission for private clients
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "DELETE_CLIENT",
"target_id": clientID,
"tenant_id": tenantID,
})
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// 1. Clean up PostgreSQL
if h.SecretRepo != nil {
_ = h.SecretRepo.Delete(c.Context(), clientID)
}
// 2. Clean up Redis
if h.Redis != nil {
_ = h.Redis.Delete("client_secret:" + clientID)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Query("client_id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client_id is required")
}
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if !isRPAdminClientAllowed(profile, clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
// [Isolation] Get admin tenant ID from locals or header
adminTenantID := ""
if profile != nil {
if role != domain.RoleSuperAdmin && profile.TenantID != nil {
adminTenantID = *profile.TenantID
}
}
if adminTenantID == "" {
adminTenantID = c.Get("X-Tenant-ID")
}
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
var consents []domain.ClientConsentWithTenantInfo
var total int64
var err error
if subject != "" {
// Resolve subject if it's email/name (Legacy support)
if _, err := uuid.Parse(subject); err != nil {
resolved, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
if resolved != "" {
subject = resolved
}
}
}
if adminTenantID != "" {
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset)
} else {
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset)
}
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
items := make([]consentSummary, 0, len(consents))
for _, consent := range consents {
// Filter by subject if search is active
if subject != "" && consent.Subject != subject {
continue
}
var deletedAt *time.Time
status := "active"
if consent.DeletedAt.Valid {
deletedAt = &consent.DeletedAt.Time
status = "revoked"
}
// Filter by status if requested
if statusFilter != "" && statusFilter != "all" {
if statusFilter == "active" && status != "active" {
continue
}
if statusFilter == "revoked" && status != "revoked" {
continue
}
}
userName := ""
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject)
if err == nil && identity != nil {
if name, ok := identity.Traits["name"].(string); ok {
userName = name
} else if email, ok := identity.Traits["email"].(string); ok {
userName = email
}
}
items = append(items, consentSummary{
Subject: consent.Subject,
UserName: userName,
ClientID: consent.ClientID,
GrantedScopes: consent.GrantedScopes,
AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339),
CreatedAt: consent.CreatedAt,
DeletedAt: deletedAt,
Status: status,
TenantID: consent.TenantID,
TenantName: consent.TenantName,
})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
})
}
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
subject := strings.TrimSpace(c.Query("subject"))
if subject == "" {
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
}
clientID := strings.TrimSpace(c.Query("client_id"))
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if clientID != "" && !isRPAdminClientAllowed(profile, clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
// If subject is not a UUID, try to resolve it as an identifier (email/username)
if _, err := uuid.Parse(subject); err != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
if err == nil && resolved != "" {
subject = resolved
}
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "REVOKE_CONSENT",
"target_id": clientID,
"tenant_id": tenantID,
"after": map[string]any{
"subject": subject,
},
})
// 1. Revoke in Hydra
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// 2. Sync to Local DB (Delete)
if h.ConsentRepo != nil {
_ = h.ConsentRepo.Delete(c.Context(), subject, clientID)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
}
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isHiddenSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
// [Tenant Isolation]
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID := resolveClientTenantID(summary)
if clientTenantID != userTenantID {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
}
}
if !isRPAdminClientAllowed(profile, summary.ID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
}
// [Security] Check permission for private clients
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "ROTATE_SECRET",
"target_id": clientID,
"tenant_id": tenantID,
})
// 1. Generate new secret
newSecret, err := generateRandomSecret(20)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
}
// 2. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// 3. Update Persistence (DB & Redis)
if h.SecretRepo != nil {
if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil {
// Log error but don't fail the request as Hydra is already updated
fmt.Printf("failed to update secret in repo: %v\n", err)
}
}
if h.Redis != nil {
_ = h.Redis.Set("client_secret:"+clientID, newSecret, 0)
}
// Return the new secret
updatedSummary := h.mapClientSummary(*updated)
updatedSummary.ClientSecret = newSecret
return c.JSON(clientDetailResponse{
Client: updatedSummary,
Endpoints: clientEndpoints{
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
Issuer: h.Hydra.PublicURL,
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
},
})
}
func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
if h.AuditRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
}
h.injectTenantContextFromHeader(c)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
allowedClientIDs := managedClientIDsFromProfile(profile)
if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 {
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients")
}
limit := c.QueryInt("limit", 50)
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action")))
clientFilter := strings.TrimSpace(c.Query("client_id"))
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
tenantFilter := strings.TrimSpace(c.Query("tenant_id"))
if tenantFilter == "" {
tenantFilter = h.resolveDevTenantScope(c)
}
if role != domain.RoleSuperAdmin && tenantFilter == "" {
tenantFilter = tenantIDFromProfile(profile)
}
cursorRaw := c.Query("cursor")
cursor, err := parseAuditCursor(cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
}
collected := make([]domain.AuditLog, 0, limit+1)
nextCursor := cursor
scanned := 0
const pageSize = 100
const maxScan = 3000
for len(collected) < limit+1 && scanned < maxScan {
page, findErr := h.AuditRepo.FindPage(c.Context(), pageSize, nextCursor, tenantFilter)
if findErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
}
if len(page) == 0 {
break
}
for _, logItem := range page {
scanned++
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter, allowedClientIDs) {
collected = append(collected, logItem)
if len(collected) == limit+1 {
break
}
}
}
last := page[len(page)-1]
nextCursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID}
if len(page) < pageSize {
break
}
}
nextCursorRaw := ""
if len(collected) > limit {
last := collected[limit-1]
nextCursorRaw = encodeAuditCursor(last)
collected = collected[:limit]
}
return c.JSON(devAuditListResponse{
Items: collected,
Limit: limit,
Cursor: cursorRaw,
NextCursor: nextCursorRaw,
})
}
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
// [Security] Check permission
allowed, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
}
if !allowed {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := ""
isSuperAdmin := false
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
}
// 1. Total Clients (Tenant Scoped)
// Hydra doesn't support tenant filtering natively, so we list and filter.
// For stats, we might want to fetch a larger batch or use a cached count.
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
var totalClients int64
if err == nil {
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue
}
if client.Metadata != nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
totalClients++
}
}
}
}
// 2. Auth Failures (24h)
var authFailures int64
if h.AuditRepo != nil {
since := time.Now().Add(-24 * time.Hour)
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID)
}
// 3. Active Sessions (1h)
var activeSessions int64
if h.AuditRepo != nil {
since := time.Now().Add(-1 * time.Hour)
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
}
return c.JSON(devStatsResponse{
TotalClients: totalClients,
ActiveSessions: activeSessions,
AuthFailures: authFailures,
})
}
func generateRandomSecret(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Use Base64 URL encoding (no padding) to look like Hydra's native secrets
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
status := "active"
var createdAt *time.Time
if client.Metadata != nil {
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
status = "inactive"
}
if value, ok := client.Metadata["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, value); err == nil {
createdAt = &t
}
}
}
clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "pkce"
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil {
if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val {
clientType = "pkce"
}
}
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = client.ClientID
}
scopes := strings.Fields(client.Scope)
clientSecret := client.ClientSecret
// 1. Check Metadata (Legacy/Fallback)
if clientSecret == "" && client.Metadata != nil {
if val, ok := client.Metadata["client_secret"].(string); ok {
clientSecret = val
}
}
// 2. Check Redis (Cache)
if clientSecret == "" && h.Redis != nil {
if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" {
clientSecret = val
}
}
// 3. Check PostgreSQL (Source of Truth) & Cache Warming
if clientSecret == "" && h.SecretRepo != nil {
if val, err := h.SecretRepo.GetByID(context.Background(), client.ClientID); err == nil && val != "" {
clientSecret = val
// Warm up cache
if h.Redis != nil {
_ = h.Redis.Set("client_secret:"+client.ClientID, clientSecret, 0)
}
}
}
return clientSummary{
ID: client.ClientID,
Name: name,
Type: clientType,
Status: status,
CreatedAt: createdAt,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
ClientSecret: clientSecret,
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
JwksUri: client.JWKSUri,
Jwks: client.JWKS,
Metadata: client.Metadata,
}
}
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
if metadata == nil {
return ""
}
raw, _ := metadata[key].(string)
return strings.TrimSpace(raw)
}
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
if metadata == nil {
return false
}
value, _ := metadata[key].(bool)
return value
}
func normalizeHeadlessClientConfig(
clientType string,
tokenAuthMethod string,
jwksURI string,
jwks interface{},
metadata map[string]interface{},
) (string, string, interface{}, map[string]interface{}) {
if metadata == nil {
metadata = map[string]interface{}{}
}
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
if clientType == "pkce" && headlessEnabled {
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") {
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
}
if headlessTokenAuthMethod == "" {
headlessTokenAuthMethod = "private_key_jwt"
}
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" {
headlessJWKSURI = strings.TrimSpace(jwksURI)
}
if headlessJWKSURI != "" {
metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI
} else {
delete(metadata, domain.MetadataHeadlessJWKSURI)
}
if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil {
metadata[domain.MetadataHeadlessJWKS] = jwks
}
if metadata[domain.MetadataHeadlessJWKS] == nil {
delete(metadata, domain.MetadataHeadlessJWKS)
}
return "none", "", nil, metadata
}
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
delete(metadata, domain.MetadataHeadlessJWKSURI)
delete(metadata, domain.MetadataHeadlessJWKS)
return tokenAuthMethod, jwksURI, jwks, metadata
}
func defaultClientScopes() []string {
return []string{"openid", "profile", "email"}
}
func defaultGrantTypes() []string {
return []string{"authorization_code", "refresh_token"}
}
func defaultResponseTypes() []string {
return []string{"code"}
}
func buildScope(scopes []string) string {
return strings.Join(scopes, " ")
}
func valueOr(ptr *string, fallback string) string {
if ptr == nil {
return fallback
}
return *ptr
}
func valueOrSlice(ptr *[]string, fallback []string) []string {
if ptr == nil {
return fallback
}
return *ptr
}
func derefSlice(ptr *[]string, fallback []string) []string {
if ptr == nil {
return fallback
}
return *ptr
}
func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} {
if incoming == nil {
return current
}
merged := map[string]interface{}{}
for k, v := range current {
merged[k] = v
}
for k, v := range *incoming {
merged[k] = v
}
return merged
}
func resolveTokenAuthMethod(requested, fallback string) string {
if strings.TrimSpace(requested) == "" {
return fallback
}
return requested
}
func (h *DevHandler) injectTenantContextFromHeader(c *fiber.Ctx) string {
tenantID := strings.TrimSpace(c.Get("X-Tenant-ID"))
if tenantID != "" {
c.Locals("tenant_id", tenantID)
}
return tenantID
}
func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
if c == nil || len(extra) == 0 {
return
}
if existing := c.Locals("audit_details_extra"); existing != nil {
if m, ok := existing.(map[string]any); ok {
for k, v := range extra {
m[k] = v
}
c.Locals("audit_details_extra", m)
return
}
}
c.Locals("audit_details_extra", extra)
}
func normalizeAuditAction(eventType string, details map[string]any) string {
if raw, ok := details["action"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.ToUpper(strings.TrimSpace(raw))
}
normalized := strings.TrimSpace(eventType)
switch {
case normalized == "POST /api/v1/dev/clients":
return "CREATE_CLIENT"
case strings.HasPrefix(normalized, "PUT /api/v1/dev/clients/"):
return "UPDATE_CLIENT"
case strings.HasPrefix(normalized, "PATCH /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/status"):
return "UPDATE_CLIENT_STATUS"
case strings.HasPrefix(normalized, "POST /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/secret/rotate"):
return "ROTATE_SECRET"
case strings.HasPrefix(normalized, "DELETE /api/v1/dev/clients/"):
return "DELETE_CLIENT"
case normalized == "DELETE /api/v1/dev/consents":
return "REVOKE_CONSENT"
default:
return ""
}
}
func resolveStatusFromMetadata(metadata map[string]interface{}) string {
if metadata != nil {
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
return "inactive"
}
}
return "active"
}
func clientTypeOrDefault(tokenEndpointAuthMethod string) string {
if strings.EqualFold(tokenEndpointAuthMethod, "none") {
return "pkce"
}
return "private"
}
func (h *DevHandler) matchesDevAuditFilter(
logItem domain.AuditLog,
tenantFilter, clientFilter, actionFilter, statusFilter string,
allowedClientIDs map[string]struct{},
) bool {
if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
return false
}
details, _ := parseAuditDetails(logItem.Details)
if statusFilter != "" && statusFilter != "all" && strings.ToLower(logItem.Status) != statusFilter {
return false
}
if tenantFilter != "" {
detailTenant, _ := details["tenant_id"].(string)
if strings.TrimSpace(detailTenant) != tenantFilter {
return false
}
}
if clientFilter != "" {
targetID, _ := details["target_id"].(string)
clientID, _ := details["client_id"].(string)
if strings.TrimSpace(targetID) != clientFilter && strings.TrimSpace(clientID) != clientFilter {
return false
}
}
if len(allowedClientIDs) > 0 {
targetID, _ := details["target_id"].(string)
clientID, _ := details["client_id"].(string)
resolvedID := strings.TrimSpace(targetID)
if resolvedID == "" {
resolvedID = strings.TrimSpace(clientID)
}
if resolvedID == "" {
return false
}
if _, ok := allowedClientIDs[resolvedID]; !ok {
return false
}
}
if actionFilter != "" {
if normalizeAuditAction(logItem.EventType, details) != actionFilter {
return false
}
}
return true
}
func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
fromHeader := strings.TrimSpace(c.Get("X-Tenant-ID"))
if fromHeader != "" {
return fromHeader
}
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil && profile.TenantID != nil {
return strings.TrimSpace(*profile.TenantID)
}
return ""
}
// ListMyTenants returns the list of tenants the current user manages or belongs to.
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
profile, err := h.Auth.GetEnrichedProfile(c)
if err != nil || profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
}
role := normalizeUserRole(profile.Role)
if role == domain.RoleUser {
return errorJSON(c, fiber.StatusForbidden, "access denied")
}
if role == domain.RoleSuperAdmin {
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
}
return c.JSON(tenants)
}
tenants, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
}
if profile.TenantID != nil && *profile.TenantID != "" {
found := false
for _, t := range tenants {
if t.ID == *profile.TenantID {
found = true
break
}
}
if !found {
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err == nil && primary != nil {
tenants = append(tenants, *primary)
}
}
}
return c.JSON(tenants)
}