1
0
forked from baron/baron-sso

조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인

This commit is contained in:
2026-05-11 20:13:54 +09:00
parent d3853fac2a
commit 3063450ee0
59 changed files with 5086 additions and 549 deletions

View File

@@ -782,8 +782,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": companyCode,
// grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member",
"grade": "",
"role": domain.RoleUser,
}
// Sync all custom login IDs based on tenant schemas
@@ -7275,6 +7275,11 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
if grade := extractTraitString(traits, "grade"); grade != "" {
if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
localUser.Grade = grade
}
}
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}
@@ -7302,13 +7307,12 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
localUser.RelyingPartyID = &relyingPartyID
}
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(extractTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
localUser.Role = role
if localUser.Status == "" {

View File

@@ -0,0 +1,255 @@
package handler
import (
"html"
"os"
"strings"
"github.com/gofiber/fiber/v2"
)
type RPManifestHandler struct{}
const rpObjectLookupMermaid = `flowchart TD
A[RP request] --> B{obj_id supplied?}
B -->|yes| C[Normalize object type and obj_id]
B -->|no| D{Route has client_id?}
D -->|yes| E[obj_id = RelyingParty:<client_id>]
D -->|no| F{Route has tenant_id?}
F -->|yes| G[obj_id = Tenant:<tenant_id>]
F -->|no| H[Reject: explicit obj_id required]
C --> I[Check Keto relation]
E --> I
G --> I
I --> J{allowed?}
J -->|yes| K[Inject trusted Baron headers]
J -->|no| L[Reject request]
K --> M[Write audit with obj_id, relation, client_id, X-Request-Id]`
const rpExternalKeyMermaid = `flowchart TD
A[User authenticates through Baron SSO] --> B[Baron resolves internal identity]
B --> C[Baron derives or loads Baron-issued alias]
C --> D[Baron injects X-Baron-External-Key]
D --> E[Baron injects X-Baron-Subject]
E --> I[RP receives trusted headers from Baron gateway]
I --> F[RP upserts local user with provider + X-Baron-External-Key]
F --> G[RP stores the full external key as opaque value]
G --> H[RP never parses or stores raw kratos_identity_id]`
func NewRPManifestHandler() *RPManifestHandler {
return &RPManifestHandler{}
}
func (h *RPManifestHandler) GetJSON(c *fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
return c.JSON(buildRPManifest(c))
}
func (h *RPManifestHandler) GetSchema(c *fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
return c.JSON(rpManifestSchema())
}
func (h *RPManifestHandler) GetHTML(c *fiber.Ctx) error {
manifest := buildRPManifest(c)
issuer, _ := manifest["issuer"].(string)
c.Set(fiber.HeaderCacheControl, "public, max-age=300")
c.Type("html", "utf-8")
return c.SendString(`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Baron RP IAM Manifest</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.6; max-width: 920px; }
code, pre { background: #f5f5f5; border-radius: 4px; padding: .1rem .3rem; }
pre { padding: 1rem; overflow: auto; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: .5rem; text-align: left; }
</style>
</head>
<body>
<h1>Baron RP IAM Manifest</h1>
<p>외부 RP가 Baron SSO/Ory Stack/Keto 기반 공용 IAM을 연동하기 위한 공개 규격입니다.</p>
<ul>
<li>Machine-readable manifest: <a href="/.well-known/baron-rp-manifest.json">/.well-known/baron-rp-manifest.json</a></li>
<li>JSON schema: <a href="/.well-known/baron-rp-manifest.schema.json">/.well-known/baron-rp-manifest.schema.json</a></li>
</ul>
<h2>Issuer</h2>
<pre>` + html.EscapeString(issuer) + `</pre>
<h2>Identity Contract</h2>
<table>
<tr><th>용도</th><th>Header</th><th>정책</th></tr>
<tr><td>Keto subject</td><td><code>X-Baron-Subject</code></td><td><code>User:&lt;baron_identity_id&gt;</code> 전체 문자열을 opaque subject로 취급합니다.</td></tr>
<tr><td>RP upsert key</td><td><code>X-Baron-External-Key</code></td><td>Baron-issued alias입니다. RP가 만들거나 제출하지 않고, Baron이 주입한 전체 문자열을 local user external key로 저장합니다.</td></tr>
<tr><td>RP client</td><td><code>X-Baron-Client-ID</code></td><td>현재 접근 중인 RP client id입니다.</td></tr>
</table>
<h2>External Key Flow</h2>
<p><code>X-Baron-External-Key</code>는 RP 입력값이 아니라 Baron이 인증된 subject에서 발급/조회해 주입하는 opaque alias입니다. RP upserts local user from the Baron-issued alias.</p>
<pre>` + "```mermaid\n" + html.EscapeString(rpExternalKeyMermaid) + "\n```" + `</pre>
<h2>Object Lookup</h2>
<pre>check(User:abc, viewers, RelyingParty:&lt;client_id&gt;)
check(User:abc, members, Tenant:&lt;tenant_id&gt;)
check(User:abc, viewers, Resource:&lt;resource_type&gt;:&lt;resource_id&gt;)</pre>
<h2>audit_contract</h2>
<p>권한과 설정을 변경하는 command는 sync audit write에 실패하면 요청도 실패해야 합니다. Read audit은 allowlist된 조회에 한해 best effort로 취급합니다.</p>
<pre>{
"mutating_command_mode": "fail_closed_sync",
"missing_audit_sink_behavior": "reject_mutation",
"correlation_header": "X-Request-Id"
}</pre>
<h2>Object Lookup Flow</h2>
<pre>` + "```mermaid\n" + html.EscapeString(rpObjectLookupMermaid) + "\n```" + `</pre>
</body>
</html>`)
}
func buildRPManifest(c *fiber.Ctx) map[string]any {
issuer := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL"))
if issuer == "" {
issuer = strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
}
if issuer == "" {
issuer = "https://sso.hmac.kr"
}
issuer = strings.TrimRight(issuer, "/")
return map[string]any{
"version": "2026-05-11",
"issuer": issuer,
"oidc": map[string]any{
"discovery_url": issuer + "/.well-known/openid-configuration",
"jwks_url": issuer + "/.well-known/jwks.json",
"supported_flows": []string{"authorization_code_pkce"},
"required_scopes": []string{"openid", "profile", "email"},
},
"iam": map[string]any{
"authorization_engine": "ory-keto",
"subject_format": "User:<baron_identity_id>",
"target_object_patterns": []string{
"RelyingParty:<client_id>",
"Tenant:<tenant_id>",
"Resource:<resource_type>:<resource_id>",
},
"supported_relations": []string{
"admins",
"users",
"viewers",
"operators",
"members",
"owners",
"editors",
},
},
"identity_contract": map[string]any{
"subject_header": "X-Baron-Subject",
"external_key_header": "X-Baron-External-Key",
"external_key_is_opaque": true,
"external_key_issuer": "baron",
"external_key_delivery": "baron_injected_header",
"external_key_lifecycle": "issued_or_loaded_after_successful_authentication_before_rp_request",
"rp_supplied_external_key_allowed": false,
"rp_user_upsert_source": "rp_must_upsert_from_header_value",
"raw_kratos_identity_id_exposed": false,
"rp_user_upsert_key": "provider + external_key",
"email_is_stable_primary_key": false,
"initial_external_key_expression": "X-Baron-External-Key",
"fallback_to_subject_allowed": false,
},
"trusted_headers": map[string]any{
"subject": "X-Baron-Subject",
"external_key": "X-Baron-External-Key",
"email": "X-Baron-Email",
"tenant": "X-Baron-Tenant",
"relations": "X-Baron-Relations",
"client_id": "X-Baron-Client-ID",
},
"object_lookup": map[string]any{
"rp_level": map[string]any{
"object": "RelyingParty:<client_id>",
"relations": []string{"viewers", "users", "operators", "admins"},
"example": "check(User:abc, viewers, RelyingParty:mh-dashboard)",
},
"tenant_level": map[string]any{
"object": "Tenant:<tenant_id>",
"relations": []string{"members", "admins", "owners"},
"example": "check(User:abc, members, Tenant:9caf62e1-297d-4e8f-870b-61780998bbe)",
},
"resource_level": map[string]any{
"object": "Resource:<resource_type>:<resource_id>",
"relations": []string{"viewers", "editors", "owners"},
"example": "check(User:abc, viewers, Resource:dashboard:mh-monthly-2026-05)",
},
"recommended_order": []string{
"authenticated",
"rp_level",
"tenant_or_resource_level",
"trusted_header_injection",
},
},
"object_lookup_flow": map[string]any{
"format": "mermaid",
"mermaid": rpObjectLookupMermaid,
},
"external_key_flow": map[string]any{
"format": "mermaid",
"mermaid": rpExternalKeyMermaid,
},
"audit_contract": map[string]any{
"mutating_command_mode": "fail_closed_sync",
"missing_audit_sink_behavior": "reject_mutation",
"read_audit_mode": "best_effort_allowlisted",
"correlation_header": "X-Request-Id",
"rp_business_audit_required": true,
"baron_gateway_audit_required": true,
"required_detail_fields": []string{
"obj_id",
"relation",
"client_id",
"subject",
"decision",
},
"guarantee_scope": "Baron-mediated IAM mutations fail closed on audit write failure; RP-owned business events must be emitted by the RP with the same correlation header.",
},
"security_requirements": map[string]any{
"strip_external_identity_headers": true,
"backend_direct_exposure_allowed": false,
"static_snapshot_requires_auth": true,
"email_as_primary_key_allowed": false,
},
}
}
func rpManifestSchema() map[string]any {
return map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Baron RP IAM Manifest",
"type": "object",
"required": []string{
"version",
"issuer",
"oidc",
"iam",
"trusted_headers",
"identity_contract",
"object_lookup",
"object_lookup_flow",
"external_key_flow",
"audit_contract",
"security_requirements",
},
"properties": map[string]any{
"version": map[string]any{"type": "string"},
"issuer": map[string]any{"type": "string", "format": "uri"},
"oidc": map[string]any{"type": "object"},
"iam": map[string]any{"type": "object"},
"trusted_headers": map[string]any{"type": "object"},
"identity_contract": map[string]any{"type": "object"},
"object_lookup": map[string]any{"type": "object"},
"object_lookup_flow": map[string]any{"type": "object"},
"external_key_flow": map[string]any{"type": "object"},
"audit_contract": map[string]any{"type": "object"},
"security_requirements": map[string]any{"type": "object"},
},
}
}

View File

@@ -0,0 +1,123 @@
package handler
import (
"encoding/json"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
func TestRPManifestJSONIncludesIAMAndExternalKeyContract(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest.json", h.GetJSON)
req := httptest.NewRequest("GET", "/.well-known/baron-rp-manifest.json", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "sso.hmac.kr")
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, "https://sso.hmac.kr", body["issuer"])
oidc := body["oidc"].(map[string]any)
require.Equal(t, "https://sso.hmac.kr/.well-known/openid-configuration", oidc["discovery_url"])
require.Equal(t, "https://sso.hmac.kr/.well-known/jwks.json", oidc["jwks_url"])
iam := body["iam"].(map[string]any)
require.Equal(t, "ory-keto", iam["authorization_engine"])
require.Equal(t, "User:<baron_identity_id>", iam["subject_format"])
require.Contains(t, iam["target_object_patterns"].([]any), "RelyingParty:<client_id>")
require.Contains(t, iam["target_object_patterns"].([]any), "Tenant:<tenant_id>")
require.Contains(t, iam["target_object_patterns"].([]any), "Resource:<resource_type>:<resource_id>")
identity := body["identity_contract"].(map[string]any)
require.Equal(t, "X-Baron-External-Key", identity["external_key_header"])
require.Equal(t, true, identity["external_key_is_opaque"])
require.Equal(t, false, identity["raw_kratos_identity_id_exposed"])
require.Equal(t, "baron", identity["external_key_issuer"])
require.Equal(t, "baron_injected_header", identity["external_key_delivery"])
require.Equal(t, false, identity["rp_supplied_external_key_allowed"])
require.Equal(t, "rp_must_upsert_from_header_value", identity["rp_user_upsert_source"])
headers := body["trusted_headers"].(map[string]any)
require.Equal(t, "X-Baron-Subject", headers["subject"])
require.Equal(t, "X-Baron-External-Key", headers["external_key"])
require.Equal(t, "X-Baron-Client-ID", headers["client_id"])
security := body["security_requirements"].(map[string]any)
require.Equal(t, true, security["strip_external_identity_headers"])
require.Equal(t, false, security["backend_direct_exposure_allowed"])
audit := body["audit_contract"].(map[string]any)
require.Equal(t, "fail_closed_sync", audit["mutating_command_mode"])
require.Equal(t, "reject_mutation", audit["missing_audit_sink_behavior"])
require.Equal(t, "X-Request-Id", audit["correlation_header"])
require.Contains(t, audit["required_detail_fields"].([]any), "obj_id")
require.Contains(t, audit["required_detail_fields"].([]any), "client_id")
flow := body["object_lookup_flow"].(map[string]any)
require.Contains(t, flow["mermaid"].(string), "flowchart TD")
require.Contains(t, flow["mermaid"].(string), "obj_id")
aliasFlow := body["external_key_flow"].(map[string]any)
require.Contains(t, aliasFlow["mermaid"].(string), "Baron resolves internal identity")
require.Contains(t, aliasFlow["mermaid"].(string), "Baron injects X-Baron-External-Key")
require.Contains(t, aliasFlow["mermaid"].(string), "RP upserts local user")
require.NotContains(t, aliasFlow["mermaid"].(string), "RP creates external key")
}
func TestRPManifestSchemaRequiresLookupAndIdentityContracts(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest.schema.json", h.GetSchema)
resp, err := app.Test(httptest.NewRequest("GET", "/.well-known/baron-rp-manifest.schema.json", nil))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
required := body["required"].([]any)
require.Contains(t, required, "iam")
require.Contains(t, required, "trusted_headers")
require.Contains(t, required, "identity_contract")
require.Contains(t, required, "object_lookup")
require.Contains(t, required, "audit_contract")
require.Contains(t, required, "object_lookup_flow")
require.Contains(t, required, "external_key_flow")
}
func TestRPManifestHTMLLinksMachineReadableManifest(t *testing.T) {
app := fiber.New()
h := NewRPManifestHandler()
app.Get("/.well-known/baron-rp-manifest", h.GetHTML)
resp, err := app.Test(httptest.NewRequest("GET", "/.well-known/baron-rp-manifest", nil))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "text/html")
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
text := string(raw)
require.Contains(t, text, "/.well-known/baron-rp-manifest.json")
require.Contains(t, text, "X-Baron-External-Key")
require.Contains(t, text, "RelyingParty:&lt;client_id&gt;")
require.Contains(t, text, "```mermaid")
require.Contains(t, text, "audit_contract")
require.Contains(t, text, "Baron-issued alias")
require.Contains(t, text, "RP upserts local user")
}

View File

@@ -391,7 +391,12 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
}
tenant, err := h.createTenantCSVRecord(c, record, creatorID)
recordCreatorID := creatorID
if record.Type == domain.TenantTypeOrganization {
recordCreatorID = ""
}
tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
@@ -632,11 +637,142 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized[key] = fields
continue
}
if key == "visibility" {
visibility, ok := value.(string)
if !ok {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
visibility = strings.TrimSpace(strings.ToLower(visibility))
if visibility == "" || visibility == "public" {
normalized[key] = "public"
continue
}
if visibility != "internal" && visibility != "private" {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
normalized[key] = visibility
continue
}
if key == "orgUnitType" {
orgUnitType, ok := value.(string)
if !ok {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
}
orgUnitType = strings.TrimSpace(orgUnitType)
if orgUnitType == "" {
continue
}
if !isAllowedOrgUnitType(orgUnitType) {
return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부")
}
normalized[key] = orgUnitType
continue
}
normalized[key] = value
}
return normalized, nil
}
func isAllowedOrgUnitType(value string) bool {
switch value {
case "실", "팀", "디비전", "셀", "본부", "지역본부", "부":
return true
default:
return false
}
}
func hasTenantOrgConfig(config domain.JSONMap) bool {
if config == nil {
return false
}
_, hasVisibility := config["visibility"]
_, hasOrgUnitType := config["orgUnitType"]
return hasVisibility || hasOrgUnitType
}
func isHanmacFamilyDescendantTenant(tenant domain.Tenant, tenants []domain.Tenant) bool {
if strings.EqualFold(tenant.Slug, "hanmac-family") {
return false
}
byID := make(map[string]domain.Tenant, len(tenants)+1)
for _, item := range tenants {
byID[item.ID] = item
}
byID[tenant.ID] = tenant
parentID := tenant.ParentID
visited := make(map[string]bool)
for parentID != nil && *parentID != "" {
if visited[*parentID] {
return false
}
visited[*parentID] = true
parent, ok := byID[*parentID]
if !ok {
return false
}
if strings.EqualFold(parent.Slug, "hanmac-family") {
return true
}
parentID = parent.ParentID
}
return false
}
func validateTenantOrgConfigScope(tenant domain.Tenant, tenants []domain.Tenant, config domain.JSONMap) error {
if !hasTenantOrgConfig(config) {
return nil
}
if isHanmacFamilyDescendantTenant(tenant, tenants) {
return nil
}
return fmt.Errorf("tenant org config is allowed only hanmac-family descendants")
}
func tenantVisibility(config domain.JSONMap) string {
visibility, _ := config["visibility"].(string)
switch strings.ToLower(strings.TrimSpace(visibility)) {
case "internal":
return "internal"
case "private":
return "private"
default:
return "public"
}
}
func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant {
excludedIDs := make(map[string]bool)
for _, tenant := range tenants {
visibility := tenantVisibility(tenant.Config)
if visibility == "internal" || visibility == "private" {
excludedIDs[tenant.ID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if !excludedIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered
}
func normalizeTenantUserSchema(value any) ([]any, error) {
if value == nil {
return nil, nil
@@ -1023,6 +1159,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(*tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
h.DB.Save(tenant)
summary.Config = tenant.Config
@@ -1162,6 +1307,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
}
@@ -1696,10 +1850,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID {
filteredTenants = append(filteredTenants, t)
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
}
filteredTenants = filterPublicTenants(filteredTenants)
for _, t := range filteredTenants {
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
type publicUserSummary struct {
ID string `json:"id"`

View File

@@ -610,6 +610,52 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id"})
return c.Next()
})
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Org,ORGANIZATION,parent-1,imported-org,,\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Org",
"imported-org",
domain.TenantTypeOrganization,
"",
[]string{},
mock.MatchedBy(func(parentID *string) bool {
return parentID != nil && *parentID == "parent-1"
}),
"",
).Return(&domain.Tenant{ID: "imported-org-id", Name: "Imported Org", Slug: "imported-org"}, nil).Once()
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{}
json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["failed"])
mockSvc.AssertExpectations(t)
}
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
@@ -681,6 +727,62 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
assert.Contains(t, err.Error(), "login ID fields must be text")
}
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"visibility": "internal",
"orgUnitType": "팀",
})
assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "팀", config["orgUnitType"])
}
func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) {
_, err := normalizeTenantConfig(map[string]any{
"visibility": "secret",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "visibility must be public, internal, or private")
}
func TestValidateTenantOrgConfigScopeRequiresHanmacFamilyDescendant(t *testing.T) {
hanmacFamily := domain.Tenant{ID: "family", Slug: "hanmac-family", Type: domain.TenantTypeCompanyGroup}
saman := domain.Tenant{ID: "saman", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &hanmacFamily.ID}
outsider := domain.Tenant{ID: "outsider", Slug: "outsider", Type: domain.TenantTypeCompany}
err := validateTenantOrgConfigScope(saman, []domain.Tenant{hanmacFamily, saman}, domain.JSONMap{
"visibility": "private",
"orgUnitType": "팀",
})
assert.NoError(t, err)
err = validateTenantOrgConfigScope(outsider, []domain.Tenant{hanmacFamily, saman, outsider}, domain.JSONMap{
"visibility": "private",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "only hanmac-family descendants")
}
func TestFilterPublicTenantsExcludesInternalPrivateAndDescendants(t *testing.T) {
root := domain.Tenant{ID: "root", Slug: "hanmac-family"}
publicTenant := domain.Tenant{ID: "public", Slug: "public", ParentID: &root.ID}
internalTenant := domain.Tenant{ID: "internal", Slug: "internal", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "internal"}}
privateTenant := domain.Tenant{ID: "private", Slug: "private", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "private"}}
privateChild := domain.Tenant{ID: "private-child", Slug: "private-child", ParentID: &privateTenant.ID}
filtered := filterPublicTenants([]domain.Tenant{
root,
publicTenant,
internalTenant,
privateTenant,
privateChild,
})
assert.Equal(t, []domain.Tenant{root, publicTenant}, filtered)
}
func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -144,6 +144,27 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
return false, false
}
func roleFromTraits(traits map[string]interface{}) string {
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
return role
}
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "grade")); ok {
return role
}
return domain.RoleUser
}
func gradeFromTraits(traits map[string]interface{}) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
return ""
}
if _, ok := domain.NormalizeRoleAlias(value); ok {
return ""
}
return value
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -158,6 +179,7 @@ type userSummary struct {
Tenant *domain.Tenant `json:"tenant,omitempty"`
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
CreatedAt string `json:"createdAt"`
@@ -429,6 +451,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
@@ -488,11 +511,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": req.Department,
"grade": strings.TrimSpace(req.Grade),
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
"grade": role,
}
// [Override with explicit LoginID if provided]
@@ -648,6 +671,7 @@ type bulkUserItem struct {
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Grade string `json:"grade"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
Metadata map[string]any `json:"metadata"`
@@ -820,12 +844,12 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": dept,
"grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"grade": role,
"role": role,
}
@@ -889,6 +913,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Status: "active",
CompanyCode: tenantSlug,
Department: dept,
Grade: strings.TrimSpace(item.Grade),
AffiliationType: "internal",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -1059,9 +1084,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
// Header row
includeIDs := includeCSVIds(c)
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
if includeIDs {
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Position", "JobTitle", "CreatedAt"}
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
}
// Collect all possible metadata keys for dynamic columns
@@ -1096,6 +1121,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.Phone,
u.Status,
u.CompanyCode,
u.Grade,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),
@@ -1109,6 +1135,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.Status,
tenantID,
u.CompanyCode,
u.Grade,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),
@@ -1142,6 +1169,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
Role *string `json:"role"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Grade *string `json:"grade"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
}
@@ -1233,6 +1261,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = *req.Department
}
if req.Grade != nil {
traits["grade"] = *req.Grade
}
if req.Position != nil {
traits["position"] = *req.Position
}
@@ -1258,7 +1289,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Sync to local DB
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
oldRole := extractTraitString(identity.Traits, "grade")
oldRole := roleFromTraits(identity.Traits)
oldTenantID := extractTraitString(identity.Traits, "tenant_id")
if req.Role != nil {
@@ -1437,6 +1468,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"`
Grade *string `json:"grade"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
PrimaryTenantID string `json:"primaryTenantId"`
@@ -1658,6 +1690,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Grade != nil {
traits["grade"] = strings.TrimSpace(*req.Grade)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
}
@@ -1669,7 +1704,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if role == "" {
role = domain.RoleUser
}
traits["grade"] = role
traits["role"] = role
}
@@ -1757,7 +1791,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
go func() {
bgCtx := context.Background()
h.syncKetoRole(bgCtx, updatedLocalUser.ID,
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
roleFromTraits(updated.Traits), oldRole, oldTenantID, updatedLocalUser.TenantID)
// Try to automatically sync UserGroup membership based on Department
if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil {
@@ -1911,14 +1945,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
@@ -1947,6 +1974,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
Metadata: make(domain.JSONMap),
@@ -1997,14 +2025,7 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
@@ -2019,6 +2040,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
AffiliationType: extractTraitString(traits, "affiliationType"),

View File

@@ -190,7 +190,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
Status: "active",
CompanyCode: "test-tenant",
Department: "Legacy Department",
Position: "책임",
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
@@ -203,8 +204,8 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant")
assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant,책임,팀장")
assert.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
mockRepo.AssertExpectations(t)
@@ -235,7 +236,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
Status: "active",
CompanyCode: "test-tenant",
TenantID: &tenantID,
Position: "책임",
Grade: "책임",
Position: "팀장",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
@@ -248,8 +250,8 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant")
assert.Contains(t, body, "Email,Name,Phone,Status,tenant_slug,Grade,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant,책임,팀장")
assert.NotContains(t, body, "user-uuid")
assert.NotContains(t, body, "tenant-uuid")
assert.NotContains(t, body, "ID,")
@@ -1185,6 +1187,29 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
mockOry.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]interface{}{
"email": "grade@example.com",
"name": "Grade User",
"role": domain.RoleUser,
"grade": "수석",
"position": "팀장",
"companyCode": "hanmac",
},
}
localUser := handler.mapToLocalUser(identity)
assert.Equal(t, domain.RoleUser, localUser.Role)
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
return "", nil
}