forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
255
backend/internal/handler/rp_manifest_handler.go
Normal file
255
backend/internal/handler/rp_manifest_handler.go
Normal 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:<baron_identity_id></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:<client_id>)
|
||||
check(User:abc, members, Tenant:<tenant_id>)
|
||||
check(User:abc, viewers, Resource:<resource_type>:<resource_id>)</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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
123
backend/internal/handler/rp_manifest_handler_test.go
Normal file
123
backend/internal/handler/rp_manifest_handler_test.go
Normal 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:<client_id>")
|
||||
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")
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user