forked from baron/baron-sso
custom claim 권한체크 확인
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user