1
0
forked from baron/baron-sso

4단계 역할 정규화 및 dev 권한 스코프 검증 강화

This commit is contained in:
2026-03-04 13:16:34 +09:00
parent 0f8b19a9b1
commit eac16cfcd9
11 changed files with 521 additions and 177 deletions

View File

@@ -140,6 +140,127 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"`
}
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 (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
@@ -151,11 +272,19 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
}
}
if ok && profile != nil {
// Super Admin bypass
if profile.Role == domain.RoleSuperAdmin {
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
@@ -203,16 +332,24 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
if tokenEmail == "" {
tokenEmail = strings.TrimSpace(info.Email)
}
tokenRole = strings.TrimSpace(info.Role)
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
@@ -237,7 +374,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
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 && rawRole == domain.RoleSuperAdmin {
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
}
@@ -383,90 +520,20 @@ 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)
}
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 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)
}
}
}
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)
@@ -503,6 +570,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
}
}
// 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)
}
@@ -529,22 +603,27 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
}
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 := 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
}
}
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
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" {
@@ -598,22 +677,27 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
}
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 := 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
}
}
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
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)
@@ -655,6 +739,15 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
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")
@@ -706,7 +799,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
}
// [Tenant Isolation] Record owner information
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
if profile != nil {
metadata["user_id"] = profile.ID
if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID
@@ -805,22 +898,27 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
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 := 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
}
}
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string)
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 {
@@ -931,22 +1029,27 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
}
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 := 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
}
}
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
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" {
@@ -986,6 +1089,18 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
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)
@@ -995,8 +1110,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
// [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 {
if profile != nil {
if role != domain.RoleSuperAdmin && profile.TenantID != nil {
adminTenantID = *profile.TenantID
}
}
@@ -1091,6 +1206,17 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
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 {
@@ -1138,22 +1264,27 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
}
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 := 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
}
}
isSuperAdmin := role == domain.RoleSuperAdmin
userTenantID := tenantIDFromProfile(profile)
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
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" {
@@ -1216,13 +1347,18 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
}
h.injectTenantContextFromHeader(c)
allowed, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
if !allowed {
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 {
@@ -1239,6 +1375,9 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
if tenantFilter == "" {
tenantFilter = h.resolveDevTenantScope(c)
}
if role != domain.RoleSuperAdmin && tenantFilter == "" {
tenantFilter = tenantIDFromProfile(profile)
}
cursorRaw := c.Query("cursor")
cursor, err := parseAuditCursor(cursorRaw)
@@ -1263,7 +1402,7 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
for _, logItem := range page {
scanned++
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter) {
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter, allowedClientIDs) {
collected = append(collected, logItem)
if len(collected) == limit+1 {
break
@@ -1308,7 +1447,7 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
userTenantID := ""
isSuperAdmin := false
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
@@ -1553,6 +1692,7 @@ func clientTypeOrDefault(tokenEndpointAuthMethod string) string {
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
@@ -1574,6 +1714,20 @@ func (h *DevHandler) matchesDevAuditFilter(
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