forked from baron/baron-sso
4단계 역할 정규화 및 dev 권한 스코프 검증 강화
This commit is contained in:
@@ -34,15 +34,16 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
|||||||
}
|
}
|
||||||
slog.Info("Syncing users to Keto", "count", len(users))
|
slog.Info("Syncing users to Keto", "count", len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
|
role := domain.NormalizeRole(u.Role)
|
||||||
// Membership
|
// Membership
|
||||||
if u.TenantID != nil {
|
if u.TenantID != nil {
|
||||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
|
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
if u.Role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", "User:"+u.ID)
|
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", "User:"+u.ID)
|
||||||
} else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil {
|
} else if role == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||||
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", "User:"+u.ID)
|
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", "User:"+u.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -15,6 +16,20 @@ const (
|
|||||||
RoleUser = "user" // 일반 사용자
|
RoleUser = "user" // 일반 사용자
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
||||||
|
func NormalizeRole(role string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
switch normalized {
|
||||||
|
case "tenant_member":
|
||||||
|
return RoleUser
|
||||||
|
case "admin":
|
||||||
|
// Legacy admin is treated as tenant admin for least-privilege compatibility.
|
||||||
|
return RoleTenantAdmin
|
||||||
|
default:
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User represents the user model stored in PostgreSQL
|
// User represents the user model stored in PostgreSQL
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
|||||||
29
backend/internal/domain/user_test.go
Normal file
29
backend/internal/domain/user_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeRole(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin},
|
||||||
|
{name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin},
|
||||||
|
{name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin},
|
||||||
|
{name: "user unchanged", in: "user", want: RoleUser},
|
||||||
|
{name: "legacy admin", in: "admin", want: RoleTenantAdmin},
|
||||||
|
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
|
||||||
|
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},
|
||||||
|
{name: "unknown role pass-through", in: "custom_role", want: "custom_role"},
|
||||||
|
{name: "empty", in: " ", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := NormalizeRole(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("NormalizeRole(%q)=%q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4004,17 +4004,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
|||||||
// 2. Role Override for real profile or fallback to Mock Profile
|
// 2. Role Override for real profile or fallback to Mock Profile
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
if isDev && mockRole != "" {
|
if isDev && mockRole != "" {
|
||||||
|
normalizedMockRole := domain.NormalizeRole(mockRole)
|
||||||
slog.Info("🔑 [AUTH] Overriding real profile role",
|
slog.Info("🔑 [AUTH] Overriding real profile role",
|
||||||
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", mockRole)
|
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
|
||||||
profile.Role = mockRole
|
profile.Role = normalizedMockRole
|
||||||
}
|
}
|
||||||
} else if isDev && mockRole != "" && token == "" && cookie == "" {
|
} else if isDev && mockRole != "" && token == "" && cookie == "" {
|
||||||
|
normalizedMockRole := domain.NormalizeRole(mockRole)
|
||||||
slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole)
|
slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole)
|
||||||
profile = &domain.UserProfileResponse{
|
profile = &domain.UserProfileResponse{
|
||||||
ID: "00000000-0000-0000-0000-000000000000",
|
ID: "00000000-0000-0000-0000-000000000000",
|
||||||
Email: "mock@hmac.kr",
|
Email: "mock@hmac.kr",
|
||||||
Name: "Dev Mock User",
|
Name: "Dev Mock User",
|
||||||
Role: mockRole,
|
Role: normalizedMockRole,
|
||||||
}
|
}
|
||||||
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
||||||
profile.TenantID = &tid
|
profile.TenantID = &tid
|
||||||
@@ -4027,6 +4029,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Post-Process (Defaults & Metadata Enrichment)
|
// 3. Post-Process (Defaults & Metadata Enrichment)
|
||||||
|
profile.Role = domain.NormalizeRole(profile.Role)
|
||||||
if profile.Role == "" {
|
if profile.Role == "" {
|
||||||
profile.Role = domain.RoleUser
|
profile.Role = domain.RoleUser
|
||||||
}
|
}
|
||||||
@@ -5115,6 +5118,7 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
|||||||
compCode, _ := traits["companyCode"].(string)
|
compCode, _ := traits["companyCode"].(string)
|
||||||
role, _ := traits["role"].(string)
|
role, _ := traits["role"].(string)
|
||||||
tenantID, _ := traits["tenant_id"].(string)
|
tenantID, _ := traits["tenant_id"].(string)
|
||||||
|
relyingPartyID, _ := traits["relying_party_id"].(string)
|
||||||
|
|
||||||
profile := &domain.UserProfileResponse{
|
profile := &domain.UserProfileResponse{
|
||||||
ID: identityID,
|
ID: identityID,
|
||||||
@@ -5124,18 +5128,22 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
|||||||
Department: dept,
|
Department: dept,
|
||||||
AffiliationType: affType,
|
AffiliationType: affType,
|
||||||
CompanyCode: compCode,
|
CompanyCode: compCode,
|
||||||
Role: role,
|
Role: domain.NormalizeRole(role),
|
||||||
Metadata: make(map[string]any),
|
Metadata: make(map[string]any),
|
||||||
}
|
}
|
||||||
|
|
||||||
if tenantID != "" {
|
if tenantID != "" {
|
||||||
profile.TenantID = &tenantID
|
profile.TenantID = &tenantID
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(relyingPartyID) != "" {
|
||||||
|
rpID := strings.TrimSpace(relyingPartyID)
|
||||||
|
profile.RelyingPartyID = &rpID
|
||||||
|
}
|
||||||
|
|
||||||
coreTraits := map[string]bool{
|
coreTraits := map[string]bool{
|
||||||
"email": true, "name": true, "phone_number": true,
|
"email": true, "name": true, "phone_number": true,
|
||||||
"grade": true, "companyCode": true, "department": true,
|
"grade": true, "companyCode": true, "department": true,
|
||||||
"affiliationType": true, "role": true, "tenant_id": true,
|
"affiliationType": true, "role": true, "tenant_id": true, "relying_party_id": true,
|
||||||
}
|
}
|
||||||
for k, v := range traits {
|
for k, v := range traits {
|
||||||
if !coreTraits[k] {
|
if !coreTraits[k] {
|
||||||
|
|||||||
@@ -140,6 +140,127 @@ type clientUpsertRequest struct {
|
|||||||
Metadata *map[string]interface{} `json:"metadata"`
|
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) {
|
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if (!ok || profile == nil) && h.Auth != nil {
|
if (!ok || profile == nil) && h.Auth != nil {
|
||||||
@@ -151,11 +272,19 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ok && profile != nil {
|
if ok && profile != nil {
|
||||||
// Super Admin bypass
|
role := normalizeUserRole(profile.Role)
|
||||||
if profile.Role == domain.RoleSuperAdmin {
|
switch role {
|
||||||
|
case domain.RoleSuperAdmin:
|
||||||
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
||||||
return true, nil
|
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) {
|
if isAdminEmail(profile.Email) {
|
||||||
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -203,16 +332,24 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
if tokenEmail == "" {
|
if tokenEmail == "" {
|
||||||
tokenEmail = strings.TrimSpace(info.Email)
|
tokenEmail = strings.TrimSpace(info.Email)
|
||||||
}
|
}
|
||||||
tokenRole = strings.TrimSpace(info.Role)
|
tokenRole = normalizeUserRole(info.Role)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
|
slog.Warn("Dev private permission userinfo fallback failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tokenRole = normalizeUserRole(tokenRole)
|
||||||
|
|
||||||
if tokenRole == domain.RoleSuperAdmin {
|
if tokenRole == domain.RoleSuperAdmin {
|
||||||
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
||||||
return true, nil
|
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) {
|
if isAdminEmail(tokenEmail) {
|
||||||
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -237,7 +374,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
if h.KratosAdmin != nil {
|
if h.KratosAdmin != nil {
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
|
||||||
if err == nil && identity != nil {
|
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)
|
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -383,90 +520,20 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Tenant Isolation] Get current user's tenant ID
|
profile := h.getCurrentProfile(c)
|
||||||
userTenantID := ""
|
if profile == nil {
|
||||||
isSuperAdmin := false
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
}
|
||||||
if (!ok || profile == nil) && h.Auth != nil {
|
role := normalizeUserRole(profile.Role)
|
||||||
enriched, _ := h.Auth.GetEnrichedProfile(c)
|
if !isDevConsoleRoleAllowed(role) {
|
||||||
if enriched != nil {
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
profile = enriched
|
|
||||||
ok = true
|
|
||||||
c.Locals("user_profile", enriched)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && profile != nil {
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
if profile.TenantID != nil {
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID = *profile.TenantID
|
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||||
}
|
if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 {
|
||||||
isSuperAdmin = profile.Role == domain.RoleSuperAdmin
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients")
|
||||||
} 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)
|
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)
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,22 +603,27 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*client)
|
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
|
// [Tenant Isolation] Check if user has access to this client
|
||||||
isSuperAdmin := false
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID := ""
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
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 {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
clientTenantID := resolveClientTenantID(summary)
|
||||||
if clientTenantID != userTenantID {
|
if clientTenantID != userTenantID {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
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
|
// Check permission for private clients
|
||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
@@ -598,22 +677,27 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
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]
|
// [Tenant Isolation]
|
||||||
isSuperAdmin := false
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID := ""
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
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 {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
clientTenantID := resolveClientTenantID(summary)
|
||||||
if clientTenantID != userTenantID {
|
if clientTenantID != userTenantID {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
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" {
|
if summary.Type == "private" {
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
@@ -655,6 +739,15 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||||
tenantID := h.injectTenantContextFromHeader(c)
|
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
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
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
|
// [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
|
metadata["user_id"] = profile.ID
|
||||||
if tenantID == "" && profile.TenantID != nil {
|
if tenantID == "" && profile.TenantID != nil {
|
||||||
tenantID = *profile.TenantID
|
tenantID = *profile.TenantID
|
||||||
@@ -805,22 +898,27 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentSummary := h.mapClientSummary(*current)
|
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]
|
// [Tenant Isolation]
|
||||||
isSuperAdmin := false
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID := ""
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
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 {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string)
|
clientTenantID := resolveClientTenantID(currentSummary)
|
||||||
if clientTenantID != userTenantID {
|
if clientTenantID != userTenantID {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
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 := ""
|
clientType := ""
|
||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
@@ -931,22 +1029,27 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
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]
|
// [Tenant Isolation]
|
||||||
isSuperAdmin := false
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID := ""
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
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 {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
clientTenantID := resolveClientTenantID(summary)
|
||||||
if clientTenantID != userTenantID {
|
if clientTenantID != userTenantID {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
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
|
// [Security] Check permission for private clients
|
||||||
if summary.Type == "private" {
|
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")
|
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"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
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
|
// [Isolation] Get admin tenant ID from locals or header
|
||||||
adminTenantID := ""
|
adminTenantID := ""
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
if profile != nil {
|
||||||
if profile.Role != domain.RoleSuperAdmin && profile.TenantID != nil {
|
if role != domain.RoleSuperAdmin && profile.TenantID != nil {
|
||||||
adminTenantID = *profile.TenantID
|
adminTenantID = *profile.TenantID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1091,6 +1206,17 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
||||||
}
|
}
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
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 subject is not a UUID, try to resolve it as an identifier (email/username)
|
||||||
if _, err := uuid.Parse(subject); err != nil {
|
if _, err := uuid.Parse(subject); err != nil {
|
||||||
@@ -1138,22 +1264,27 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
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]
|
// [Tenant Isolation]
|
||||||
isSuperAdmin := false
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||||
userTenantID := ""
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
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 {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
clientTenantID := resolveClientTenantID(summary)
|
||||||
if clientTenantID != userTenantID {
|
if clientTenantID != userTenantID {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
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
|
// [Security] Check permission for private clients
|
||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
@@ -1216,13 +1347,18 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.injectTenantContextFromHeader(c)
|
h.injectTenantContextFromHeader(c)
|
||||||
allowed, err := h.checkAppManagerPermission(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if err != nil {
|
if profile == nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
if !allowed {
|
role := normalizeUserRole(profile.Role)
|
||||||
|
if !isDevConsoleRoleAllowed(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
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)
|
limit := c.QueryInt("limit", 50)
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -1239,6 +1375,9 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
if tenantFilter == "" {
|
if tenantFilter == "" {
|
||||||
tenantFilter = h.resolveDevTenantScope(c)
|
tenantFilter = h.resolveDevTenantScope(c)
|
||||||
}
|
}
|
||||||
|
if role != domain.RoleSuperAdmin && tenantFilter == "" {
|
||||||
|
tenantFilter = tenantIDFromProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
cursorRaw := c.Query("cursor")
|
cursorRaw := c.Query("cursor")
|
||||||
cursor, err := parseAuditCursor(cursorRaw)
|
cursor, err := parseAuditCursor(cursorRaw)
|
||||||
@@ -1263,7 +1402,7 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
for _, logItem := range page {
|
for _, logItem := range page {
|
||||||
scanned++
|
scanned++
|
||||||
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter) {
|
if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter, allowedClientIDs) {
|
||||||
collected = append(collected, logItem)
|
collected = append(collected, logItem)
|
||||||
if len(collected) == limit+1 {
|
if len(collected) == limit+1 {
|
||||||
break
|
break
|
||||||
@@ -1308,7 +1447,7 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
|||||||
userTenantID := ""
|
userTenantID := ""
|
||||||
isSuperAdmin := false
|
isSuperAdmin := false
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
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 {
|
if profile.TenantID != nil {
|
||||||
userTenantID = *profile.TenantID
|
userTenantID = *profile.TenantID
|
||||||
}
|
}
|
||||||
@@ -1553,6 +1692,7 @@ func clientTypeOrDefault(tokenEndpointAuthMethod string) string {
|
|||||||
func (h *DevHandler) matchesDevAuditFilter(
|
func (h *DevHandler) matchesDevAuditFilter(
|
||||||
logItem domain.AuditLog,
|
logItem domain.AuditLog,
|
||||||
tenantFilter, clientFilter, actionFilter, statusFilter string,
|
tenantFilter, clientFilter, actionFilter, statusFilter string,
|
||||||
|
allowedClientIDs map[string]struct{},
|
||||||
) bool {
|
) bool {
|
||||||
if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
|
if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
|
||||||
return false
|
return false
|
||||||
@@ -1574,6 +1714,20 @@ func (h *DevHandler) matchesDevAuditFilter(
|
|||||||
return false
|
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 actionFilter != "" {
|
||||||
if normalizeAuditAction(logItem.EventType, details) != actionFilter {
|
if normalizeAuditAction(logItem.EventType, details) != actionFilter {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
ID: "user-a",
|
ID: "user-a",
|
||||||
Role: domain.RoleUser,
|
Role: domain.RoleTenantAdmin,
|
||||||
TenantID: &tenantA,
|
TenantID: &tenantA,
|
||||||
})
|
})
|
||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "AppManager", "member").Return(false, nil)
|
mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "AppManager", "member").Return(true, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -105,7 +105,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetClient should enforce tenant isolation", func(t *testing.T) {
|
t.Run("Tenant member should be forbidden from DevFront clients", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
tenantA := "tenant-a"
|
tenantA := "tenant-a"
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
@@ -116,6 +116,52 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RP Admin should only see managed clients", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "rp-admin-a",
|
||||||
|
Role: domain.RoleRPAdmin,
|
||||||
|
TenantID: &tenantA,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"managed_client_ids": []any{"client-tenant-a"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var res struct {
|
||||||
|
Items []clientSummary `json:"items"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
assert.Equal(t, 1, len(res.Items))
|
||||||
|
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetClient should enforce tenant isolation", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-a",
|
||||||
|
Role: domain.RoleTenantAdmin,
|
||||||
|
TenantID: &tenantA,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
// Case 1: Same tenant
|
// Case 1: Same tenant
|
||||||
@@ -135,7 +181,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
|||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
ID: "user-a",
|
ID: "user-a",
|
||||||
Role: domain.RoleUser,
|
Role: domain.RoleTenantAdmin,
|
||||||
TenantID: &tenantA,
|
TenantID: &tenantA,
|
||||||
})
|
})
|
||||||
return c.Next()
|
return c.Next()
|
||||||
|
|||||||
@@ -266,8 +266,6 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
mockKeto := new(devMockKetoService)
|
||||||
// mockKeto setup to allow stats view
|
|
||||||
mockKeto.On("CheckPermission", mock.Anything, "u1", "System", "AppManager", "member").Return(true, nil)
|
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
@@ -281,7 +279,7 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
tenantID := "t1"
|
tenantID := "t1"
|
||||||
app.Use(func(c *fiber.Ctx) error {
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
ID: "u1", Role: domain.RoleUser, TenantID: &tenantID,
|
ID: "u1", Role: domain.RoleTenantAdmin, TenantID: &tenantID,
|
||||||
})
|
})
|
||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
@@ -297,7 +295,7 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
assert.Equal(t, int64(2), res.TotalClients)
|
assert.Equal(t, int64(2), res.TotalClients)
|
||||||
assert.Equal(t, int64(7), res.AuthFailures)
|
assert.Equal(t, int64(7), res.AuthFailures)
|
||||||
assert.Equal(t, int64(3), res.ActiveSessions)
|
assert.Equal(t, int64(3), res.ActiveSessions)
|
||||||
mockKeto.AssertExpectations(t)
|
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||||
@@ -323,3 +321,78 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||||
|
AuditRepo: &mockAuditRepo{},
|
||||||
|
Keto: new(devMockKetoService),
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "u-member",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||||
|
auditRepo := &mockAuditRepo{
|
||||||
|
logs: []domain.AuditLog{
|
||||||
|
{
|
||||||
|
EventID: "evt-1",
|
||||||
|
EventType: "POST /api/v1/dev/clients",
|
||||||
|
Status: "success",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EventID: "evt-2",
|
||||||
|
EventType: "POST /api/v1/dev/clients",
|
||||||
|
Status: "success",
|
||||||
|
Timestamp: time.Now().UTC().Add(-time.Minute),
|
||||||
|
Details: `{"target_id":"client-other","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||||
|
AuditRepo: auditRepo,
|
||||||
|
Keto: new(devMockKetoService),
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "u-rp-admin",
|
||||||
|
Role: domain.RoleRPAdmin,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"managed_client_ids": []any{"client-allowed"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result devAuditListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Len(t, result.Items, 1)
|
||||||
|
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,13 +44,14 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var rps []domain.RelyingParty
|
var rps []domain.RelyingParty
|
||||||
var err error
|
var err error
|
||||||
|
role := domain.NormalizeRole(profile.Role)
|
||||||
|
|
||||||
if profile.Role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
rps, err = h.Service.ListAll(c.Context())
|
rps, err = h.Service.ListAll(c.Context())
|
||||||
} else if profile.Role == domain.RoleTenantAdmin && profile.TenantID != nil {
|
} else if role == domain.RoleTenantAdmin && profile.TenantID != nil {
|
||||||
rps, err = h.Service.List(c.Context(), *profile.TenantID)
|
rps, err = h.Service.List(c.Context(), *profile.TenantID)
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", profile.Role)
|
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", role)
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient role to list all applications")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient role to list all applications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
|
||||||
// If Tenant Admin, only list manageable tenants
|
// If Tenant Admin, only list manageable tenants
|
||||||
if profile != nil && profile.Role == domain.RoleTenantAdmin {
|
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleTenantAdmin {
|
||||||
slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID)
|
slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID)
|
||||||
tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID)
|
tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
var requesterRole string
|
var requesterRole string
|
||||||
var requesterCompany string
|
var requesterCompany string
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||||
requesterRole = profile.Role
|
requesterRole = domain.NormalizeRole(profile.Role)
|
||||||
requesterCompany = profile.CompanyCode
|
requesterCompany = profile.CompanyCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [New] Check access scope
|
// [New] Check access scope
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
||||||
@@ -263,9 +263,9 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
role := strings.TrimSpace(req.Role)
|
role := domain.NormalizeRole(req.Role)
|
||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = domain.RoleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
@@ -380,7 +380,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [New] Check access scope
|
// [New] Check access scope
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
|
||||||
@@ -402,7 +402,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [New] Tenant Admin restriction: Cannot change companyCode
|
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||||
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
||||||
}
|
}
|
||||||
@@ -437,9 +437,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
traits["department"] = strings.TrimSpace(*req.Department)
|
traits["department"] = strings.TrimSpace(*req.Department)
|
||||||
}
|
}
|
||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
role := strings.TrimSpace(*req.Role)
|
role := domain.NormalizeRole(*req.Role)
|
||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = domain.RoleUser
|
||||||
}
|
}
|
||||||
traits["grade"] = role
|
traits["grade"] = role
|
||||||
traits["role"] = role
|
traits["role"] = role
|
||||||
@@ -507,7 +507,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [New] Check access scope before deletion
|
// [New] Check access scope before deletion
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
if err == nil && identity != nil {
|
if err == nil && identity != nil {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
@@ -541,7 +541,11 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := extractTraitString(traits, "grade")
|
role := extractTraitString(traits, "grade")
|
||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = extractTraitString(traits, "role")
|
||||||
|
}
|
||||||
|
role = domain.NormalizeRole(role)
|
||||||
|
if role == "" {
|
||||||
|
role = domain.RoleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
compCode := extractTraitString(traits, "companyCode")
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
@@ -589,7 +593,11 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := extractTraitString(traits, "grade")
|
role := extractTraitString(traits, "grade")
|
||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = extractTraitString(traits, "role")
|
||||||
|
}
|
||||||
|
role = domain.NormalizeRole(role)
|
||||||
|
if role == "" {
|
||||||
|
role = domain.RoleUser
|
||||||
}
|
}
|
||||||
compCode := extractTraitString(traits, "companyCode")
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
|
|
||||||
@@ -633,6 +641,9 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
||||||
|
newRole = domain.NormalizeRole(newRole)
|
||||||
|
oldRole = domain.NormalizeRole(oldRole)
|
||||||
|
|
||||||
// Remove old roles
|
// Remove old roles
|
||||||
if oldRole == domain.RoleSuperAdmin {
|
if oldRole == domain.RoleSuperAdmin {
|
||||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
// Store profile in locals for further use in handlers
|
// Store profile in locals for further use in handlers
|
||||||
c.Locals("user_profile", profile)
|
c.Locals("user_profile", profile)
|
||||||
|
|
||||||
|
role := domain.NormalizeRole(profile.Role)
|
||||||
|
|
||||||
// Super Admin bypass
|
// Super Admin bypass
|
||||||
if profile.Role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
slog.Warn("Keto permission denied", "userID", profile.ID, "userRole", profile.Role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role"))
|
slog.Warn("Keto permission denied", "userID", profile.ID, "userRole", role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role"))
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID)
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +93,17 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
|||||||
// Store profile in locals for further use in handlers
|
// Store profile in locals for further use in handlers
|
||||||
c.Locals("user_profile", profile)
|
c.Locals("user_profile", profile)
|
||||||
|
|
||||||
|
userRole := domain.NormalizeRole(profile.Role)
|
||||||
|
|
||||||
// Super Admin always has access
|
// Super Admin always has access
|
||||||
if profile.Role == domain.RoleSuperAdmin {
|
if userRole == domain.RoleSuperAdmin {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user's role is in allowed roles
|
// Check if user's role is in allowed roles
|
||||||
roleAllowed := false
|
roleAllowed := false
|
||||||
for _, role := range config.AllowedRoles {
|
for _, role := range config.AllowedRoles {
|
||||||
if profile.Role == role {
|
if userRole == domain.NormalizeRole(role) {
|
||||||
roleAllowed = true
|
roleAllowed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
|||||||
if !roleAllowed {
|
if !roleAllowed {
|
||||||
slog.Warn("RBAC access denied",
|
slog.Warn("RBAC access denied",
|
||||||
"userID", profile.ID,
|
"userID", profile.ID,
|
||||||
"userRole", profile.Role,
|
"userRole", userRole,
|
||||||
"allowedRoles", config.AllowedRoles,
|
"allowedRoles", config.AllowedRoles,
|
||||||
"path", c.Path(),
|
"path", c.Path(),
|
||||||
"X-Test-Role", c.Get("X-Test-Role"),
|
"X-Test-Role", c.Get("X-Test-Role"),
|
||||||
@@ -136,13 +140,15 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
|||||||
// Store profile in locals for further use in handlers
|
// Store profile in locals for further use in handlers
|
||||||
c.Locals("user_profile", profile)
|
c.Locals("user_profile", profile)
|
||||||
|
|
||||||
|
userRole := domain.NormalizeRole(profile.Role)
|
||||||
|
|
||||||
// Super Admin bypass
|
// Super Admin bypass
|
||||||
if profile.Role == domain.RoleSuperAdmin {
|
if userRole == domain.RoleSuperAdmin {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant Admin check
|
// Tenant Admin check
|
||||||
if profile.Role == domain.RoleTenantAdmin {
|
if userRole == domain.RoleTenantAdmin {
|
||||||
targetTenantID := c.Params("tenantId")
|
targetTenantID := c.Params("tenantId")
|
||||||
if targetTenantID == "" {
|
if targetTenantID == "" {
|
||||||
targetTenantID = c.Params("id") // common for /tenants/:id
|
targetTenantID = c.Params("id") // common for /tenants/:id
|
||||||
|
|||||||
Reference in New Issue
Block a user