1
0
forked from baron/baron-sso

custom claim 권한체크 확인

This commit is contained in:
2026-06-11 08:29:25 +09:00
parent 839ca9d407
commit 4d77060b5d
79 changed files with 4268 additions and 670 deletions

View File

@@ -1612,6 +1612,17 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
}
for _, claim := range normalizedClaims {
if claim.Nullable && strings.TrimSpace(claim.Value) == "" {
if claim.Namespace == "rp_claims" {
rpClaims[claim.Key] = nil
continue
}
if _, exists := baseClaims[claim.Key]; !exists {
baseClaims[claim.Key] = nil
}
continue
}
value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
if err != nil {
slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)

View File

@@ -34,6 +34,7 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository
AuditRepo domain.AuditRepository
KratosAdmin service.KratosAdminService
IdentityWriter service.IdentityWriteService
ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
@@ -78,13 +79,18 @@ func NewDevHandler(
authProvider = auth[0]
}
kratosAdmin := service.NewKratosAdminService()
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redis, nil),
SecretRepo: secretRepo,
AuditRepo: nil,
KratosAdmin: service.NewKratosAdminService(),
KratosAdmin: kratosAdmin,
IdentityWriter: service.NewIdentityWriteService(
kratosAdmin,
redis,
),
ConsentRepo: consentRepo,
Keto: keto,
KetoOutbox: ketoOutbox,
@@ -233,6 +239,7 @@ type normalizedIDTokenClaim struct {
Key string `json:"key"`
Value string `json:"value"`
ValueType string `json:"valueType"`
Nullable bool `json:"nullable"`
ReadPermission string `json:"readPermission"`
WritePermission string `json:"writePermission"`
}
@@ -1659,7 +1666,17 @@ func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID stri
}
rawRPClaims[clientID] = metadata
traits["rp_custom_claims"] = rawRPClaims
_, err = h.KratosAdmin.UpdateIdentity(ctx, identity.ID, traits, identity.State)
identityWriter := h.IdentityWriter
if identityWriter == nil {
identityWriter = service.NewIdentityWriteService(h.KratosAdmin, h.Redis)
}
_, err = identityWriter.UpdateIdentity(ctx, service.IdentityUpdateRequest{
IdentityID: identity.ID,
Traits: traits,
State: identity.State,
Reason: "rp_custom_claims_sync",
Source: "dev_handler",
})
if err != nil {
return fmt.Errorf("failed to update kratos rp user metadata: %w", err)
}
@@ -3459,8 +3476,11 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
}
value := strings.TrimSpace(readInterfaceString(record["value"], ""))
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
nullable, _ := record["nullable"].(bool)
if !(nullable && value == "") {
if _, err := parseConfiguredClaimValue(value, valueType); err != nil {
return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err)
}
}
signature := namespace + ":" + key
@@ -3474,6 +3494,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
Key: key,
Value: value,
ValueType: valueType,
Nullable: nullable,
ReadPermission: normalizeCustomClaimPermission(record["readPermission"]),
WritePermission: normalizeCustomClaimPermission(record["writePermission"]),
})
@@ -3854,6 +3875,85 @@ func (h *DevHandler) countScopedDashboardAuditMetrics(
return failureCount, int64(len(activeSessions)), nil
}
func appendDevTenantUnique(tenants []domain.Tenant, tenant domain.Tenant) []domain.Tenant {
tenantID := strings.TrimSpace(tenant.ID)
tenantSlug := strings.TrimSpace(tenant.Slug)
if tenantID == "" && tenantSlug == "" {
return tenants
}
for _, existing := range tenants {
if tenantID != "" && strings.EqualFold(existing.ID, tenantID) {
return tenants
}
if tenantSlug != "" && strings.EqualFold(existing.Slug, tenantSlug) {
return tenants
}
}
return append(tenants, tenant)
}
func shouldListDevManageableTenants(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "tenant_admin", "tenantadmin", "tenant-admin", "rp_admin", "admin":
return true
default:
return false
}
}
func resolveDevProfileAppointmentTenants(ctx context.Context, tenantSvc service.TenantService, metadata map[string]any) []domain.Tenant {
if tenantSvc == nil || metadata == nil {
return nil
}
appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
if len(appointments) == 0 {
return nil
}
tenants := make([]domain.Tenant, 0, len(appointments))
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
if tenantID := normalizeMetadataString(appointment["tenantId"]); tenantID != "" {
if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil {
tenants = appendDevTenantUnique(tenants, *tenant)
continue
}
}
if tenantID := normalizeMetadataString(appointment["tenant_id"]); tenantID != "" {
if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil {
tenants = appendDevTenantUnique(tenants, *tenant)
continue
}
}
if tenantSlug := normalizeMetadataString(appointment["tenantSlug"]); tenantSlug != "" {
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
tenants = appendDevTenantUnique(tenants, *tenant)
continue
}
}
if tenantSlug := normalizeMetadataString(appointment["tenant_slug"]); tenantSlug != "" {
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
tenants = appendDevTenantUnique(tenants, *tenant)
continue
}
}
if tenantSlug := normalizeMetadataString(appointment["slug"]); tenantSlug != "" {
if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil {
tenants = appendDevTenantUnique(tenants, *tenant)
}
}
}
return tenants
}
// ListMyTenants returns the list of tenants the current user manages or belongs to.
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
profile, err := h.Auth.GetEnrichedProfile(c)
@@ -3862,20 +3962,6 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
}
role := normalizeUserRole(profile.Role)
if role == domain.RoleUser {
if profile.TenantID == nil || strings.TrimSpace(*profile.TenantID) == "" {
return c.JSON([]domain.Tenant{})
}
tenant, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
}
if tenant == nil {
return c.JSON([]domain.Tenant{})
}
return c.JSON([]domain.Tenant{*tenant})
}
if role == domain.RoleSuperAdmin {
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "", "")
if err != nil {
@@ -3884,26 +3970,32 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
return c.JSON(tenants)
}
tenants, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
tenants := make([]domain.Tenant, 0, 1+len(profile.JoinedTenants))
if shouldListDevManageableTenants(profile.Role) {
manageable, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
}
for _, tenant := range manageable {
tenants = appendDevTenantUnique(tenants, tenant)
}
}
if profile.TenantID != nil && *profile.TenantID != "" {
found := false
for _, t := range tenants {
if t.ID == *profile.TenantID {
found = true
break
}
}
if !found {
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err == nil && primary != nil {
tenants = append(tenants, *primary)
}
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
} else if primary != nil {
tenants = appendDevTenantUnique(tenants, *primary)
}
}
for _, tenant := range profile.JoinedTenants {
tenants = appendDevTenantUnique(tenants, tenant)
}
for _, tenant := range resolveDevProfileAppointmentTenants(c.Context(), h.TenantSvc, profile.Metadata) {
tenants = appendDevTenantUnique(tenants, tenant)
}
return c.JSON(tenants)
}

View File

@@ -48,6 +48,18 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"valueType": "text",
"value": "A",
},
{
"namespace": "rp_claims",
"key": "activeMember",
"valueType": "boolean",
"value": "true",
},
{
"namespace": "rp_claims",
"key": "score",
"valueType": "number",
"value": "1",
},
},
},
}), nil
@@ -60,6 +72,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
return row.ClientID == "client-1" &&
row.UserID == "user-1" &&
row.Metadata["approvalLevel"] == "A" &&
row.Metadata["activeMember"] == false &&
row.Metadata["score"] == float64(42) &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin"
})).Return(nil).Once()
@@ -87,6 +101,8 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"metadata": map[string]any{
"approvalLevel": "A",
"activeMember": false,
"score": 42,
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},
@@ -148,6 +164,7 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
kratos.On("UpdateIdentity", mock.Anything, "user-1", mock.Anything, "active").Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{ID: "user-1", State: "active", Traits: map[string]any{}}, nil).Once()
identityWriter := service.NewIdentityWriteService(kratos, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
@@ -155,6 +172,7 @@ func TestDevHandler_RPUserMetadataMirrorsToKratosTraits(t *testing.T) {
HTTPClient: &http.Client{Transport: transport},
},
KratosAdmin: kratos,
IdentityWriter: identityWriter,
RPUserMetadataRepo: repo,
}
app := fiber.New()

View File

@@ -342,6 +342,97 @@ func TestGetCurrentProfile_PreservesExistingAuditUserContext(t *testing.T) {
assert.Equal(t, "existing-user", body["user_id"])
}
func TestListMyTenants_UserIncludesPrimaryJoinedAndMetadataAppointments(t *testing.T) {
primaryTenantID := "tenant-primary"
mockAuth := new(devMockAuthProvider)
mockTenant := new(MockTenantService)
handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant}
app := fiber.New()
app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants)
profile := &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &primaryTenantID,
JoinedTenants: []domain.Tenant{
{ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive},
},
Metadata: map[string]any{
"additionalAppointments": []any{
map[string]any{"tenantId": "tenant-extra"},
map[string]any{"tenantSlug": "slug-extra"},
},
},
}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once()
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive,
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "tenant-extra").Return(&domain.Tenant{
ID: "tenant-extra", Slug: "extra-id", Name: "Extra Tenant By ID", Status: domain.TenantStatusActive,
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "slug-extra").Return(&domain.Tenant{
ID: "tenant-slug-extra", Slug: "slug-extra", Name: "Extra Tenant By Slug", Status: domain.TenantStatusActive,
}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var tenants []domain.Tenant
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants))
assert.NoError(t, resp.Body.Close())
assertTenantIDs(t, tenants, []string{"tenant-primary", "tenant-joined", "tenant-extra", "tenant-slug-extra"})
mockAuth.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestListMyTenants_TenantAdminIncludesManageableJoinedAndPrimary(t *testing.T) {
primaryTenantID := "tenant-primary"
mockAuth := new(devMockAuthProvider)
mockTenant := new(MockTenantService)
handler := &DevHandler{Auth: mockAuth, TenantSvc: mockTenant}
app := fiber.New()
app.Get("/api/v1/dev/my-tenants", handler.ListMyTenants)
profile := &domain.UserProfileResponse{
ID: "tenant-admin-1",
Role: "tenant_admin",
TenantID: &primaryTenantID,
JoinedTenants: []domain.Tenant{
{ID: "tenant-joined", Slug: "joined", Name: "Joined Tenant", Status: domain.TenantStatusActive},
},
}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil).Once()
mockTenant.On("ListManageableTenants", mock.Anything, "tenant-admin-1").Return([]domain.Tenant{
{ID: "tenant-managed", Slug: "managed", Name: "Managed Tenant", Status: domain.TenantStatusActive},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
ID: primaryTenantID, Slug: "primary", Name: "Primary Tenant", Status: domain.TenantStatusActive,
}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/my-tenants", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var tenants []domain.Tenant
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&tenants))
assert.NoError(t, resp.Body.Close())
assertTenantIDs(t, tenants, []string{"tenant-managed", "tenant-joined", "tenant-primary"})
mockAuth.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func assertTenantIDs(t *testing.T, tenants []domain.Tenant, expected []string) {
t.Helper()
actual := make([]string, 0, len(tenants))
for _, tenant := range tenants {
actual = append(actual, tenant.ID)
}
assert.ElementsMatch(t, expected, actual)
}
func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
@@ -3334,6 +3425,32 @@ func TestNormalizeIDTokenClaimsMetadata_AllowsDateAndDatetime(t *testing.T) {
assert.Equal(t, "datetime", claims[1].ValueType)
}
func TestNormalizeIDTokenClaimsMetadata_PreservesNullableDefaultValue(t *testing.T) {
metadata, err := normalizeIDTokenClaimsMetadata(map[string]any{
domain.MetadataIDTokenClaims: []any{
map[string]any{
"namespace": "rp_claims",
"key": "contract_date",
"value": "",
"valueType": "date",
"nullable": true,
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
},
},
})
assert.NoError(t, err)
claims := metadata[domain.MetadataIDTokenClaims].([]normalizedIDTokenClaim)
if assert.Len(t, claims, 1) {
assert.Equal(t, "contract_date", claims[0].Key)
assert.Equal(t, "date", claims[0].ValueType)
assert.True(t, claims[0].Nullable)
assert.Equal(t, "user_and_admin", claims[0].ReadPermission)
assert.Equal(t, "user_and_admin", claims[0].WritePermission)
}
}
func TestUpdateClient_RejectsTopLevelIDTokenClaimsFromDevConsole(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {

View File

@@ -16,6 +16,7 @@ import (
"net/http"
"net/mail"
"os"
"reflect"
"regexp"
"sort"
"strconv"
@@ -170,6 +171,145 @@ func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]an
return metadata
}
func removeUserTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
if tenant == nil {
return metadata
}
if metadata == nil {
metadata = map[string]any{}
}
targetID := strings.ToLower(strings.TrimSpace(tenant.ID))
targetSlug := strings.ToLower(strings.TrimSpace(tenant.Slug))
matchesTarget := func(raw any) bool {
appointment, ok := raw.(map[string]any)
if !ok {
return false
}
tenantID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
tenantSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
if tenantSlug == "" {
tenantSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
}
return (targetID != "" && tenantID == targetID) ||
(targetSlug != "" && tenantSlug == targetSlug)
}
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
if len(appointments) == 0 {
if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
}
}
if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
appointments = incoming
}
filtered := make([]any, 0, len(appointments))
removedPrimary := false
for _, appointment := range appointments {
if matchesTarget(appointment) {
if value, ok := metadataBoolFromMap(appointment.(map[string]any), "isPrimary", "primary", "representative", "isRepresentative"); ok && value {
removedPrimary = true
}
continue
}
filtered = append(filtered, appointment)
}
if len(filtered) > 0 {
traits["additionalAppointments"] = filtered
metadata["additionalAppointments"] = filtered
} else {
delete(traits, "additionalAppointments")
delete(metadata, "additionalAppointments")
}
delete(traits, tenant.ID)
delete(metadata, tenant.ID)
if primaryTenantID := strings.ToLower(normalizeMetadataString(traits["primaryTenantId"])); primaryTenantID == targetID && targetID != "" {
removedPrimary = true
}
if primaryTenantSlug := strings.ToLower(normalizeMetadataString(traits["primaryTenantSlug"])); primaryTenantSlug == targetSlug && targetSlug != "" {
removedPrimary = true
}
if removedPrimary {
delete(traits, "primaryTenantId")
delete(traits, "primaryTenantSlug")
delete(traits, "primaryTenantName")
delete(traits, "primaryTenantIsOwner")
delete(metadata, "primaryTenantId")
delete(metadata, "primaryTenantSlug")
delete(metadata, "primaryTenantName")
delete(metadata, "primaryTenantIsOwner")
}
return metadata
}
func userMetadataRecordFromAny(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
case domain.JSONMap:
return map[string]any(typed)
default:
return nil
}
}
func enforceGlobalCustomClaimWritePermissions(traits map[string]any, metadata map[string]any, isAdmin bool) error {
if isAdmin || metadata == nil {
return nil
}
incomingClaims := userMetadataRecordFromAny(metadata["global_custom_claims"])
if incomingClaims == nil {
return nil
}
existingClaims := userMetadataRecordFromAny(traits["global_custom_claims"])
existingPermissions := userMetadataRecordFromAny(traits["global_custom_claim_permissions"])
existingTypes := userMetadataRecordFromAny(traits["global_custom_claim_types"])
claimKeys := map[string]bool{}
for key := range incomingClaims {
claimKeys[strings.TrimSpace(key)] = true
}
for key := range existingClaims {
claimKeys[strings.TrimSpace(key)] = true
}
for key := range claimKeys {
if key == "" {
continue
}
incomingValue, incomingExists := incomingClaims[key]
existingValue, existingExists := existingClaims[key]
if incomingExists && existingExists && reflect.DeepEqual(incomingValue, existingValue) {
continue
}
if !incomingExists && !existingExists {
continue
}
permission := "admin_only"
if rawPermission := userMetadataRecordFromAny(existingPermissions[key]); rawPermission != nil {
permission = normalizeCustomClaimPermission(rawPermission["writePermission"])
}
if permission != "user_and_admin" {
return fmt.Errorf("global custom claim %s is admin only", key)
}
}
if len(existingPermissions) > 0 {
metadata["global_custom_claim_permissions"] = existingPermissions
}
if len(existingTypes) > 0 {
metadata["global_custom_claim_types"] = existingTypes
}
return nil
}
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
if tenantService == nil || metadata == nil {
return false, nil
@@ -1864,6 +2004,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
Role *string `json:"role"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
IsAddTenant bool `json:"isAddTenant"`
Department *string `json:"department"`
Grade *string `json:"grade"`
Position *string `json:"position"`
@@ -1950,6 +2091,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Prepare updates
traits := identity.Traits
oldRoleForSync := roleFromTraits(traits)
oldTenantIDForSync := extractTraitString(traits, "tenant_id")
if req.Role != nil {
traits["role"] = *req.Role
}
@@ -1957,8 +2100,30 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
delete(traits, "companyCode")
delete(traits, "companyCodes")
// Resolve and update tenant_id in traits if changed
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
if req.IsAddTenant {
if h.TenantService == nil {
results = append(results, map[string]any{"id": id, "success": false, "message": "tenant service not available"})
continue
}
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
if err != nil || tenant == nil {
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
continue
}
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
if appointments, ok := metadata["additionalAppointments"]; ok {
traits["additionalAppointments"] = appointments
}
if h.KetoOutboxRepo != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + id,
Action: domain.KetoOutboxActionCreate,
})
}
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
traits["tenant_id"] = tItem.ID
} else if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
@@ -1990,7 +2155,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
}
_, err = h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
if err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
@@ -1998,9 +2163,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Sync to local DB
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
oldRole := roleFromTraits(identity.Traits)
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
localUser := h.mapToLocalUser(*updated)
if req.Role != nil {
localUser.Role = *req.Role
@@ -2035,7 +2198,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// [Keto Sync]
if h.KetoOutboxRepo != nil {
h.syncKetoRole(c.Context(), localUser.ID,
localUser.Role, oldRole, oldTenantID, localUser.TenantID)
localUser.Role, oldRoleForSync, oldTenantIDForSync, localUser.TenantID)
}
}
@@ -2241,6 +2404,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [Validation] Based on Tenant Schema (Multi-tenant aware)
isAdmin := requester != nil && requester.Role == domain.RoleSuperAdmin
if err := enforceGlobalCustomClaimWritePermissions(identity.Traits, req.Metadata, isAdmin); err != nil {
return errorJSON(c, fiber.StatusForbidden, "forbidden: "+err.Error())
}
// If metadata is namespaced (key is tenant ID), validate each namespace
// If it's flat, validate using schemaCompCode
@@ -2329,26 +2495,22 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
code := strings.TrimSpace(*req.CompanyCode)
if req.IsRemoveTenant {
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
go func(removedSlug string) {
bgCtx := context.Background()
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
}(code)
}
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
currentTenantID := extractTraitString(traits, "tenant_id")
if currentTenantID == tenant.ID {
traits["tenant_id"] = ""
}
req.Metadata = removeUserTenantAppointment(traits, req.Metadata, tenant)
if h.KetoOutboxRepo != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
}
}
} else if !req.IsAddTenant {

View File

@@ -1400,6 +1400,84 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
})
}
func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
KetoOutboxRepo: mockOutbox,
}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.BulkUpdateUsers(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "primary-tenant-id",
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "team-a").Return(&domain.Tenant{
ID: "team-a-id",
Name: "Team A",
Slug: "team-a",
}, nil).Once()
mockKratos.On(
"UpdateIdentity",
mock.Anything,
"u-1",
mock.MatchedBy(func(traits map[string]any) bool {
if extractTraitString(traits, "tenant_id") != "primary-tenant-id" {
return false
}
appointments, ok := traits["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
return ok &&
appointment["tenantId"] == "team-a-id" &&
appointment["tenantSlug"] == "team-a" &&
appointment["tenantName"] == "Team A"
}),
mock.Anything,
).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "u1@test.com",
"name": "Bulk User",
"tenant_id": "primary-tenant-id",
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
},
}, nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "team-a-id" &&
entry.Relation == "members" &&
entry.Subject == "User:u-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil).Once()
body := `{"userIds":["u-1"],"tenantSlug":"team-a","isAddTenant":true}`
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1702,6 +1780,137 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
})
}
func TestUserHandler_UpdateUser_GlobalCustomClaimWritePermission(t *testing.T) {
newApp := func(t *testing.T, existingPermission string, updateIdentity bool) (*fiber.App, *MockKratosAdmin, *MockTenantServiceForUser, *map[string]any) {
t.Helper()
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
capturedTraits := map[string]any(nil)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
tenantID := "t-123"
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "requester-1",
Role: domain.RoleUser,
TenantID: &tenantID,
ManageableTenants: []domain.Tenant{
{ID: tenantID, Slug: "test-tenant"},
},
})
return h.UpdateUser(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Custom Claim User",
"tenant_id": tenantID,
"global_custom_claims": map[string]any{
"contract_date": "2026-06-09",
},
"global_custom_claim_types": map[string]any{
"contract_date": "date",
},
"global_custom_claim_permissions": map[string]any{
"contract_date": map[string]any{
"readPermission": "user_and_admin",
"writePermission": existingPermission,
},
},
},
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"userSchema": []any{},
},
}, nil).Maybe()
if updateIdentity {
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Custom Claim User",
},
}, nil).Once()
} else {
mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: "u-1",
State: "active",
Traits: map[string]any{},
}, nil).Maybe()
}
return app, mockKratos, mockTenant, &capturedTraits
}
requestBody := func(nextValue string) *bytes.Reader {
body, _ := json.Marshal(map[string]any{
"metadata": map[string]any{
"global_custom_claims": map[string]any{
"contract_date": nextValue,
},
"global_custom_claim_types": map[string]any{
"contract_date": "date",
},
"global_custom_claim_permissions": map[string]any{
"contract_date": map[string]any{
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
},
},
},
})
return bytes.NewReader(body)
}
t.Run("regular user cannot change admin_only global custom claim value", func(t *testing.T) {
app, mockKratos, mockTenant, _ := newApp(t, "admin_only", false)
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
})
t.Run("regular user can change user_and_admin global custom claim value", func(t *testing.T) {
app, mockKratos, mockTenant, capturedTraits := newApp(t, "user_and_admin", true)
req := httptest.NewRequest(http.MethodPut, "/users/u-1", requestBody("2026-07-01"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, *capturedTraits)
claims := (*capturedTraits)["global_custom_claims"].(map[string]any)
require.Equal(t, "2026-07-01", claims["contract_date"])
permissions := (*capturedTraits)["global_custom_claim_permissions"].(map[string]any)
require.Equal(t, map[string]any{
"readPermission": "user_and_admin",
"writePermission": "user_and_admin",
}, permissions["contract_date"])
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
})
}
func TestUserHandler_UpdateUser_AcceptsDeprecatedAdminRolesAsUser(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -2569,6 +2778,117 @@ func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testin
require.Equal(t, false, added["isPrimary"])
}
func TestUserHandler_UpdateUserRemoveTenantDropsAdditionalAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
KetoOutboxRepo: mockOutbox,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/:id", h.UpdateUser)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
map[string]any{
"tenantId": "private-team-id",
"tenantSlug": "private-team",
"tenantName": "비공개 팀",
"isPrimary": false,
},
},
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "private-team").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil)
mockTenant.On("GetTenant", mock.Anything, "private-team-id").Return(&domain.Tenant{
ID: "private-team-id",
Name: "비공개 팀",
Slug: "private-team",
Config: domain.JSONMap{
"visibility": "private",
},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "primary-tenant-id").Return(&domain.Tenant{
ID: "primary-tenant-id",
Name: "대표 조직",
Slug: "primary-tenant",
}, nil).Maybe()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "private-team-id" &&
entry.Relation == "members" &&
entry.Subject == "User:user-id" &&
entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Maybe()
var capturedTraits map[string]any
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
capturedTraits = args.Get(2).(map[string]any)
}).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "primary-tenant-id",
"role": domain.RoleUser,
"additionalAppointments": []any{
map[string]any{
"tenantId": "primary-tenant-id",
"tenantSlug": "primary-tenant",
"tenantName": "대표 조직",
"isPrimary": true,
},
},
},
}, nil)
body := `{"tenantSlug":"private-team","isRemoveTenant":true}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "primary-tenant-id", capturedTraits["tenant_id"])
appointments, ok := capturedTraits["additionalAppointments"].([]any)
require.True(t, ok)
require.Len(t, appointments, 1)
remaining := appointments[0].(map[string]any)
require.Equal(t, "primary-tenant-id", remaining["tenantId"])
require.Equal(t, "primary-tenant", remaining["tenantSlug"])
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAddTenantRejectsUnmanageableTenantForTenantAdmin(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)