|
|
|
|
@@ -10,7 +10,9 @@ import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
@@ -70,6 +72,12 @@ type devAuditListResponse struct {
|
|
|
|
|
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"`
|
|
|
|
|
@@ -153,27 +161,75 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check with Keto: System:AppManager#member
|
|
|
|
|
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
|
|
|
|
|
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 = strings.TrimSpace(info.Role)
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if tokenRole == domain.RoleSuperAdmin {
|
|
|
|
|
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
if isAdminEmail(tokenEmail) {
|
|
|
|
|
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
if tokenSubject == "" {
|
|
|
|
|
if isTrustedLocalDevfrontRequest(c) {
|
|
|
|
|
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
|
|
|
|
|
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -195,7 +251,9 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|
|
|
|
// Check with Keto: System:AppManager#member
|
|
|
|
|
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
|
|
|
@@ -247,25 +305,71 @@ func isAdminEmail(email string) bool {
|
|
|
|
|
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
|
|
|
|
if c == nil {
|
|
|
|
|
return false
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
|
|
|
|
|
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
|
|
|
|
|
allowedPrefixes := []string{
|
|
|
|
|
"http://localhost:5174",
|
|
|
|
|
"https://localhost:5174",
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, prefix := range allowedPrefixes {
|
|
|
|
|
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
|
|
|
|
|
return true
|
|
|
|
|
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 false
|
|
|
|
|
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 {
|
|
|
|
|
@@ -279,6 +383,92 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|
|
|
|
offset = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation] Get current user's tenant ID
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
|
|
|
if (!ok || profile == nil) && h.Auth != nil {
|
|
|
|
|
enriched, _ := h.Auth.GetEnrichedProfile(c)
|
|
|
|
|
if enriched != nil {
|
|
|
|
|
profile = enriched
|
|
|
|
|
ok = true
|
|
|
|
|
c.Locals("user_profile", enriched)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ok && profile != nil {
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
} else {
|
|
|
|
|
// If profile resolution failed, verify bearer token via OIDC userinfo fallback.
|
|
|
|
|
authHeader := c.Get("Authorization")
|
|
|
|
|
bearerToken := extractBearerToken(authHeader)
|
|
|
|
|
if bearerToken == "" {
|
|
|
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sub, email := extractAuthClaimsFromBearer(authHeader)
|
|
|
|
|
if sub == "" {
|
|
|
|
|
info, infoErr := h.fetchOIDCUserInfo(c.Context(), bearerToken)
|
|
|
|
|
if infoErr != nil {
|
|
|
|
|
slog.Warn("ListClients userinfo fallback failed", "error", infoErr)
|
|
|
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
|
|
|
|
}
|
|
|
|
|
sub = strings.TrimSpace(info.Sub)
|
|
|
|
|
if email == "" {
|
|
|
|
|
email = strings.TrimSpace(info.Email)
|
|
|
|
|
}
|
|
|
|
|
if userTenantID == "" {
|
|
|
|
|
userTenantID = strings.TrimSpace(info.TenantID)
|
|
|
|
|
}
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(info.Role), domain.RoleSuperAdmin) {
|
|
|
|
|
isSuperAdmin = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if sub == "" && email == "" {
|
|
|
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.KratosAdmin != nil && (userTenantID == "" || !isSuperAdmin) {
|
|
|
|
|
identityID := strings.TrimSpace(sub)
|
|
|
|
|
if identityID == "" && email != "" {
|
|
|
|
|
if resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil {
|
|
|
|
|
identityID = strings.TrimSpace(resolved)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if identityID != "" {
|
|
|
|
|
if identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID); err == nil && identity != nil {
|
|
|
|
|
if userTenantID == "" {
|
|
|
|
|
if tid, ok := identity.Traits["tenant_id"].(string); ok {
|
|
|
|
|
userTenantID = strings.TrimSpace(tid)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
role := ""
|
|
|
|
|
if rawRole, ok := identity.Traits["role"].(string); ok {
|
|
|
|
|
role = strings.TrimSpace(rawRole)
|
|
|
|
|
}
|
|
|
|
|
if role == domain.RoleSuperAdmin {
|
|
|
|
|
isSuperAdmin = true
|
|
|
|
|
}
|
|
|
|
|
profile = &domain.UserProfileResponse{
|
|
|
|
|
ID: identityID,
|
|
|
|
|
Email: email,
|
|
|
|
|
Role: role,
|
|
|
|
|
TenantID: nil,
|
|
|
|
|
}
|
|
|
|
|
if userTenantID != "" {
|
|
|
|
|
tid := userTenantID
|
|
|
|
|
profile.TenantID = &tid
|
|
|
|
|
}
|
|
|
|
|
c.Locals("user_profile", profile)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isAppManager, err := h.checkAppManagerPermission(c)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("Failed to check app manager permission", "error", err)
|
|
|
|
|
@@ -299,10 +489,20 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|
|
|
|
items := make([]clientSummary, 0, len(clients))
|
|
|
|
|
for _, client := range clients {
|
|
|
|
|
summary := h.mapClientSummary(client)
|
|
|
|
|
// Filter out 'private' clients if user is not an AppManager
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items = append(items, summary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -330,6 +530,22 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|
|
|
|
|
|
|
|
|
summary := h.mapClientSummary(*client)
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation] Check if user has access to this client
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !isSuperAdmin {
|
|
|
|
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
|
|
|
|
if clientTenantID != userTenantID {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check permission for private clients
|
|
|
|
|
if summary.Type == "private" {
|
|
|
|
|
isAppManager, err := h.checkAppManagerPermission(c)
|
|
|
|
|
@@ -374,20 +590,39 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|
|
|
|
|
|
|
|
|
// [Security] Check permission before patching
|
|
|
|
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
|
|
|
|
if err == nil {
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
if summary.Type == "private" {
|
|
|
|
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
|
|
|
|
if !isAppManager {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation]
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !isSuperAdmin {
|
|
|
|
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
|
|
|
|
if clientTenantID != userTenantID {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeStatus := ""
|
|
|
|
|
if current != nil {
|
|
|
|
|
beforeStatus = h.mapClientSummary(*current).Status
|
|
|
|
|
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,
|
|
|
|
|
@@ -402,15 +637,12 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|
|
|
|
|
|
|
|
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary := h.mapClientSummary(*updated)
|
|
|
|
|
updatedSummary := h.mapClientSummary(*updated)
|
|
|
|
|
return c.JSON(clientDetailResponse{
|
|
|
|
|
Client: summary,
|
|
|
|
|
Client: updatedSummary,
|
|
|
|
|
Endpoints: clientEndpoints{
|
|
|
|
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
|
|
|
|
Issuer: h.Hydra.PublicURL,
|
|
|
|
|
@@ -472,6 +704,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|
|
|
|
if metadata == nil {
|
|
|
|
|
metadata = map[string]interface{}{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation] Record owner information
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
metadata["user_id"] = profile.ID
|
|
|
|
|
if tenantID == "" && profile.TenantID != nil {
|
|
|
|
|
tenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if tenantID != "" {
|
|
|
|
|
metadata["tenant_id"] = tenantID
|
|
|
|
|
}
|
|
|
|
|
@@ -563,6 +804,24 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentSummary := h.mapClientSummary(*current)
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation]
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !isSuperAdmin {
|
|
|
|
|
clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string)
|
|
|
|
|
if clientTenantID != userTenantID {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clientType := ""
|
|
|
|
|
if req.Type != nil {
|
|
|
|
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
|
|
|
|
@@ -572,7 +831,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Security] Check permission for private clients (both current and new type)
|
|
|
|
|
currentSummary := h.mapClientSummary(*current)
|
|
|
|
|
if currentSummary.Type == "private" || clientType == "private" {
|
|
|
|
|
isAppManager, err := h.checkAppManagerPermission(c)
|
|
|
|
|
if err != nil {
|
|
|
|
|
@@ -641,9 +899,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|
|
|
|
|
|
|
|
|
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -667,15 +922,37 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|
|
|
|
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Security] Check permission for private clients
|
|
|
|
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
|
|
|
|
if err == nil {
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
if summary.Type == "private" {
|
|
|
|
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
|
|
|
|
if !isAppManager {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation]
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !isSuperAdmin {
|
|
|
|
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
|
|
|
|
if clientTenantID != userTenantID {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -686,9 +963,6 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -719,8 +993,17 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|
|
|
|
limit = 50
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Isolation] Get admin tenant ID from header or locals
|
|
|
|
|
adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev
|
|
|
|
|
// [Isolation] Get admin tenant ID from locals or header
|
|
|
|
|
adminTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
if profile.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
|
|
|
|
|
@@ -735,12 +1018,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|
|
|
|
subject = resolved
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Single user fetch from Hydra (to get latest status) or Local DB
|
|
|
|
|
// Issue says: "List All", so we prefer Local DB for consistency in listing
|
|
|
|
|
// But for a single user, we could still use Hydra.
|
|
|
|
|
// Let's use Local DB to support tenant filtering even for search.
|
|
|
|
|
// For simplicity, we just filter the list later if search is used.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if adminTenantID != "" {
|
|
|
|
|
@@ -852,15 +1129,37 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|
|
|
|
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [Security] Check permission for private clients
|
|
|
|
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
|
|
|
|
if err == nil {
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
if summary.Type == "private" {
|
|
|
|
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
|
|
|
|
if !isAppManager {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary := h.mapClientSummary(*current)
|
|
|
|
|
|
|
|
|
|
// [Tenant Isolation]
|
|
|
|
|
isSuperAdmin := false
|
|
|
|
|
userTenantID := ""
|
|
|
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
|
|
|
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
|
|
|
|
if profile.TenantID != nil {
|
|
|
|
|
userTenantID = *profile.TenantID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !isSuperAdmin {
|
|
|
|
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
|
|
|
|
if clientTenantID != userTenantID {
|
|
|
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -876,22 +1175,14 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Get current client to preserve other fields (already fetched above)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
|
|
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
|
|
|
|
}
|
|
|
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Update Hydra
|
|
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Update Persistence (DB & Redis)
|
|
|
|
|
// 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
|
|
|
|
|
@@ -904,11 +1195,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return the new secret
|
|
|
|
|
summary := h.mapClientSummary(*updated)
|
|
|
|
|
summary.ClientSecret = newSecret
|
|
|
|
|
updatedSummary := h.mapClientSummary(*updated)
|
|
|
|
|
updatedSummary.ClientSecret = newSecret
|
|
|
|
|
|
|
|
|
|
return c.JSON(clientDetailResponse{
|
|
|
|
|
Client: summary,
|
|
|
|
|
Client: updatedSummary,
|
|
|
|
|
Endpoints: clientEndpoints{
|
|
|
|
|
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
|
|
|
|
Issuer: h.Hydra.PublicURL,
|
|
|
|
|
@@ -1002,6 +1293,67 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = 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 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 {
|
|
|
|
|
|