+
+
+
+ {userSchema.length > 0 && (
+
+
보안 설정
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 28486211..ddd96095 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -26,6 +26,7 @@ export type TenantSummary = {
description: string;
status: string;
domains?: string[];
+ config?: Record;
createdAt: string;
updatedAt: string;
};
@@ -36,6 +37,7 @@ export type TenantCreateRequest = {
description?: string;
status?: string;
domains?: string[];
+ config?: Record;
};
export type TenantListResponse = {
@@ -51,6 +53,7 @@ export type TenantUpdateRequest = {
description?: string;
status?: string;
domains?: string[];
+ config?: Record;
};
export type ApiKeySummary = {
@@ -172,6 +175,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
+ metadata?: Record;
department?: string;
createdAt: string;
updatedAt: string;
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 2935d298..ecf26a7b 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -248,7 +248,7 @@ func main() {
tenantHandler := handler.NewTenantHandler(db, tenantService)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
- userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService)
+ userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
// 3. Initialize Fiber
diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go
index 7f267ff1..a0cacfcd 100644
--- a/backend/internal/domain/auth_models.go
+++ b/backend/internal/domain/auth_models.go
@@ -72,9 +72,10 @@ type UserProfileResponse struct {
Name string `json:"name"`
Phone string `json:"phone"`
Department string `json:"department"`
- AffiliationType string `json:"affiliationType"`
- CompanyCode string `json:"companyCode,omitempty"`
- Tenant *Tenant `json:"tenant,omitempty"`
+ AffiliationType string `json:"affiliationType"`
+ CompanyCode string `json:"companyCode,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Tenant *Tenant `json:"tenant,omitempty"`
}
type UpdateUserRequest struct {
diff --git a/backend/internal/domain/json_map.go b/backend/internal/domain/json_map.go
new file mode 100644
index 00000000..ccbd9776
--- /dev/null
+++ b/backend/internal/domain/json_map.go
@@ -0,0 +1,42 @@
+package domain
+
+import (
+ "database/sql/driver"
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
+type JSONMap map[string]any
+
+// Value implements the driver.Valuer interface
+func (m JSONMap) Value() (driver.Value, error) {
+ if m == nil {
+ return nil, nil
+ }
+ ba, err := json.Marshal(m)
+ return string(ba), err
+}
+
+// Scan implements the sql.Scanner interface
+func (m *JSONMap) Scan(value any) error {
+ if value == nil {
+ *m = make(JSONMap)
+ return nil
+ }
+ var bytes []byte
+ switch v := value.(type) {
+ case []byte:
+ bytes = v
+ case string:
+ bytes = []byte(v)
+ default:
+ return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
+ }
+
+ result := make(JSONMap)
+ err := json.Unmarshal(bytes, &result)
+ *m = result
+ return err
+}
diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go
index 52acc9c0..d0d2e13a 100644
--- a/backend/internal/domain/tenant.go
+++ b/backend/internal/domain/tenant.go
@@ -15,6 +15,7 @@ type Tenant struct {
Description string `json:"description"`
Status string `gorm:"default:'active'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
+ Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go
index 4ef96583..307fea35 100644
--- a/backend/internal/domain/user.go
+++ b/backend/internal/domain/user.go
@@ -20,6 +20,7 @@ type User struct {
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
Department string `json:"department"`
+ Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 3f4078db..75041fdc 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -2338,6 +2338,7 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
+ Metadata: userResponse.CustomAttributes,
}
if compCode != "" {
@@ -4132,7 +4133,20 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
+ Metadata: make(map[string]any),
}
+
+ coreTraits := map[string]bool{
+ "email": true, "name": true, "phone_number": true,
+ "grade": true, "companyCode": true, "department": true,
+ "affiliationType": true,
+ }
+ for k, v := range traits {
+ if !coreTraits[k] {
+ profile.Metadata[k] = v
+ }
+ }
+
return profile, nil
}
@@ -4157,7 +4171,20 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
+ Metadata: make(map[string]any),
}
+
+ coreTraits := map[string]bool{
+ "email": true, "name": true, "phone_number": true,
+ "grade": true, "companyCode": true, "department": true,
+ "affiliationType": true,
+ }
+ for k, v := range traits {
+ if !coreTraits[k] {
+ profile.Metadata[k] = v
+ }
+ }
+
return profile, nil
}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index f9b04ad1..affed786 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -21,14 +21,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
}
type tenantSummary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- Status string `json:"status"`
- Domains []string `json:"domains,omitempty"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Domains []string `json:"domains,omitempty"`
+ Config domain.JSONMap `json:"config,omitempty"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
}
type tenantListResponse struct {
@@ -99,9 +100,10 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
- Description string `json:"description"`
- Status string `json:"status"`
- Domains []string `json:"domains"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Domains []string `json:"domains"`
+ Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
@@ -134,6 +136,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ if req.Config != nil {
+ tenant.Config = req.Config
+ h.DB.Save(tenant)
+ }
+
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
}
@@ -158,9 +165,10 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
var req struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
- Description *string `json:"description"`
- Status *string `json:"status"`
- Domains []string `json:"domains"`
+ Description *string `json:"description"`
+ Status *string `json:"status"`
+ Domains []string `json:"domains"`
+ Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
@@ -198,6 +206,9 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
tenant.Status = status
}
+ if req.Config != nil {
+ tenant.Config = req.Config
+ }
if err := h.DB.Save(&tenant).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -262,6 +273,7 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
Description: t.Description,
Status: t.Status,
Domains: domains,
+ Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index ab3a6f91..8c37408c 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"context"
@@ -16,13 +17,15 @@ type UserHandler struct {
KratosAdmin *service.KratosAdminService
OryProvider *service.OryProvider
TenantService service.TenantService
+ UserRepo repository.UserRepository
}
-func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService) *UserHandler {
+func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, userRepo repository.UserRepository) *UserHandler {
return &UserHandler{
KratosAdmin: kratosAdmin,
OryProvider: oryProvider,
TenantService: tenantService,
+ UserRepo: userRepo,
}
}
@@ -34,6 +37,7 @@ type userSummary struct {
Role string `json:"role"`
Status string `json:"status"`
CompanyCode string `json:"companyCode"`
+ Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
Department string `json:"department"`
CreatedAt string `json:"createdAt"`
@@ -128,13 +132,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
var req struct {
- Email string `json:"email"`
- Password string `json:"password"`
- Name string `json:"name"`
- Phone string `json:"phone"`
- Role string `json:"role"`
- CompanyCode string `json:"companyCode"`
- Department string `json:"department"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Name string `json:"name"`
+ Phone string `json:"phone"`
+ Role string `json:"role"`
+ CompanyCode string `json:"companyCode"`
+ Department string `json:"department"`
+ Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
@@ -191,6 +196,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"grade": role,
}
+ // Merge custom metadata into attributes
+ for k, v := range req.Metadata {
+ // Don't overwrite core fields
+ if _, exists := attributes[k]; !exists {
+ attributes[k] = v
+ }
+ }
+
brokerUser := &domain.BrokerUser{
Email: email,
Name: name,
@@ -206,6 +219,32 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // [New] Local DB Sync
+ localUser := &domain.User{
+ ID: identityID,
+ Email: email,
+ Name: name,
+ Phone: normalizePhoneNumber(req.Phone),
+ AffiliationType: "internal",
+ CompanyCode: req.CompanyCode,
+ Department: req.Department,
+ Role: role,
+ Status: "active",
+ Metadata: req.Metadata,
+ }
+
+ if req.CompanyCode != "" && h.TenantService != nil {
+ if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
+ localUser.TenantID = &tenant.ID
+ }
+ }
+
+ if h.UserRepo != nil {
+ if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
+ slog.Error("[UserHandler] Failed to sync user to local DB", "email", email, "error", err)
+ }
+ }
+
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -240,13 +279,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
var req struct {
- Password *string `json:"password"`
- Name *string `json:"name"`
- Phone *string `json:"phone"`
- Role *string `json:"role"`
- Status *string `json:"status"`
- CompanyCode *string `json:"companyCode"`
- Department *string `json:"department"`
+ Password *string `json:"password"`
+ Name *string `json:"name"`
+ Phone *string `json:"phone"`
+ Role *string `json:"role"`
+ Status *string `json:"status"`
+ CompanyCode *string `json:"companyCode"`
+ Department *string `json:"department"`
+ Metadata map[string]any `json:"metadata"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
@@ -276,12 +316,66 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["grade"] = role
}
+ // [Refined] Metadata synchronization: replace non-core traits with new Metadata
+ coreTraits := map[string]bool{
+ "email": true, "name": true, "phone_number": true,
+ "grade": true, "companyCode": true, "department": true,
+ "affiliationType": true,
+ }
+
+ // 1. Remove existing non-core traits to handle deletions
+ for k := range traits {
+ if !coreTraits[k] {
+ delete(traits, k)
+ }
+ }
+
+ // 2. Add new metadata fields
+ for k, v := range req.Metadata {
+ if !coreTraits[k] {
+ traits[k] = v
+ }
+ }
+
state := normalizeKratosState(req.Status)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // [New] Local DB Sync
+ if h.UserRepo != nil {
+ if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
+ if req.Name != nil {
+ localUser.Name = *req.Name
+ }
+ if req.Phone != nil {
+ localUser.Phone = normalizePhoneNumber(*req.Phone)
+ }
+ if req.CompanyCode != nil {
+ localUser.CompanyCode = *req.CompanyCode
+ if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
+ localUser.TenantID = &tenant.ID
+ }
+ }
+ if req.Department != nil {
+ localUser.Department = *req.Department
+ }
+ if req.Role != nil {
+ localUser.Role = *req.Role
+ }
+ if req.Status != nil {
+ localUser.Status = *req.Status
+ }
+ if req.Metadata != nil {
+ localUser.Metadata = req.Metadata
+ }
+ if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
+ slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
+ }
+ }
+ }
+
if req.Password != nil && *req.Password != "" {
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -326,10 +420,23 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
+ Metadata: make(domain.JSONMap),
CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
}
+ // Filter out core traits and put everything else in Metadata
+ coreTraits := map[string]bool{
+ "email": true, "name": true, "phone_number": true,
+ "grade": true, "companyCode": true, "department": true,
+ "affiliationType": true,
+ }
+ for k, v := range traits {
+ if !coreTraits[k] {
+ summary.Metadata[k] = v
+ }
+ }
+
if compCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant
diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json
index d967d074..91ed0358 100644
--- a/docker/ory/kratos/identity.schema.json
+++ b/docker/ory/kratos/identity.schema.json
@@ -98,7 +98,7 @@
"required": [
"email"
],
- "additionalProperties": false
+ "additionalProperties": true
}
}
}
diff --git a/userfront/lib/features/profile/data/models/user_profile_model.dart b/userfront/lib/features/profile/data/models/user_profile_model.dart
index ed12c4ba..a12187fd 100644
--- a/userfront/lib/features/profile/data/models/user_profile_model.dart
+++ b/userfront/lib/features/profile/data/models/user_profile_model.dart
@@ -38,6 +38,7 @@ class UserProfile {
final String department;
final String affiliationType;
final String companyCode;
+ final Map? metadata;
final Tenant? tenant;
UserProfile({
@@ -48,6 +49,7 @@ class UserProfile {
required this.department,
required this.affiliationType,
required this.companyCode,
+ this.metadata,
this.tenant,
});
@@ -60,6 +62,7 @@ class UserProfile {
department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '',
+ metadata: json['metadata'] != null ? Map.from(json['metadata']) : null,
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
);
}
@@ -73,6 +76,7 @@ class UserProfile {
'department': department,
'affiliationType': affiliationType,
'companyCode': companyCode,
+ 'metadata': metadata,
'tenant': tenant?.toJson(),
};
}