diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go
index 35141017..dce9813a 100644
--- a/backend/internal/service/kratos_admin_service.go
+++ b/backend/internal/service/kratos_admin_service.go
@@ -1,6 +1,7 @@
package service
import (
+ "baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
@@ -34,6 +35,7 @@ type KratosAdminService interface {
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
DeleteIdentity(ctx context.Context, identityID string) error
+ CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
}
type kratosAdminService struct {
@@ -239,6 +241,67 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil
}
+func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
+ if user == nil {
+ return "", fmt.Errorf("kratos admin: user payload is nil")
+ }
+
+ traits := map[string]interface{}{
+ "email": user.Email,
+ "name": user.Name,
+ }
+ if user.PhoneNumber != "" {
+ traits["phone_number"] = user.PhoneNumber
+ }
+ for k, v := range user.Attributes {
+ if k == "id" || k == "email" {
+ continue
+ }
+ traits[k] = v
+ }
+
+ payload := map[string]interface{}{
+ "schema_id": "default",
+ "traits": traits,
+ "credentials": map[string]interface{}{
+ "password": map[string]interface{}{
+ "config": map[string]string{
+ "password": password,
+ },
+ },
+ },
+ "state": "active",
+ }
+
+ body, _ := json.Marshal(payload)
+ endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := s.httpClient().Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ var created struct {
+ ID string `json:"id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
+ return "", err
+ }
+
+ return created.ID, nil
+}
+
func hashPasswordForKratosAdmin(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go
index d8e419cf..560f0055 100644
--- a/backend/internal/service/org_chart_service.go
+++ b/backend/internal/service/org_chart_service.go
@@ -9,6 +9,7 @@ import (
"io"
"log/slog"
"strings"
+ "time"
"github.com/google/uuid"
)
@@ -48,22 +49,43 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
return fmt.Errorf("failed to read CSV header: %w", err)
}
- // Map header columns
+ // Map header columns (Support both English and Korean)
colMap := make(map[string]int)
for i, name := range header {
- colMap[strings.ToLower(strings.TrimSpace(name))] = i
+ cleanName := strings.ToLower(strings.TrimSpace(name))
+ colMap[cleanName] = i
}
- // Required columns
- required := []string{"email", "name", "organization", "position", "jobtitle"}
- for _, req := range required {
- if _, ok := colMap[req]; !ok {
- return fmt.Errorf("missing required column: %s", req)
+ // Dynamic column detection for hierarchy
+ hierarchyCols := []string{"그룹", "디비젼", "팀", "셀"}
+ hierarchyIdx := make([]int, 0)
+ for _, col := range hierarchyCols {
+ if idx, ok := colMap[col]; ok {
+ hierarchyIdx = append(hierarchyIdx, idx)
}
}
- // Cache for created/found organization units to handle hierarchy efficiently
- // key: path (e.g. "HQ/Sales"), value: ID
+ // Map English keys for core fields
+ fieldMapping := map[string][]string{
+ "email": {"email", "이메일"},
+ "name": {"name", "이름"},
+ "position": {"position", "직급"},
+ "jobtitle": {"jobtitle", "직무"},
+ "company": {"company", "소속"},
+ "is_owner": {"is_owner", "구분"},
+ }
+
+ actualMap := make(map[string]int)
+ for key, aliases := range fieldMapping {
+ for _, alias := range aliases {
+ if idx, ok := colMap[alias]; ok {
+ actualMap[key] = idx
+ break
+ }
+ }
+ }
+
+ // Path cache for hierarchy
pathCache := make(map[string]string)
for {
@@ -76,61 +98,98 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
continue
}
- email := strings.TrimSpace(record[colMap["email"]])
- name := strings.TrimSpace(record[colMap["name"]])
- orgPath := strings.TrimSpace(record[colMap["organization"]])
- position := strings.TrimSpace(record[colMap["position"]])
- jobTitle := strings.TrimSpace(record[colMap["jobtitle"]])
+ email := strings.TrimSpace(record[actualMap["email"]])
+ name := strings.TrimSpace(record[actualMap["name"]])
+ position := strings.TrimSpace(record[actualMap["position"]])
+ jobTitle := strings.TrimSpace(record[actualMap["jobtitle"]])
+ companyName := strings.TrimSpace(record[actualMap["company"]])
+
+ // Determine if owner (e.g. "팀장", "그룹장", "센터장", "실장")
isOwner := false
- if idx, ok := colMap["is_owner"]; ok && idx < len(record) {
- val := strings.ToLower(record[idx])
- isOwner = val == "true" || val == "y" || val == "1" || val == "yes"
+ if idx, ok := actualMap["is_owner"]; ok {
+ val := record[idx]
+ isOwner = strings.HasSuffix(val, "장") || strings.EqualFold(val, "true") || val == "1"
}
- if email == "" || name == "" || orgPath == "" {
+ if email == "" || name == "" {
continue
}
- // 1. Process Organization Hierarchy
- leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
- if err != nil {
- slog.Error("Failed to ensure org path", "path", orgPath, "error", err)
- continue
+ // 1. Process Hierarchy (Build path from multiple columns)
+ var parts []string
+ for _, idx := range hierarchyIdx {
+ val := strings.TrimSpace(record[idx])
+ if val != "" && val != "-" {
+ parts = append(parts, val)
+ }
}
+ orgPath := strings.Join(parts, "/")
- // 2. Upsert User
- // Check if user exists in Kratos first (SoT)
- kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
- if err != nil || kratosID == "" {
- slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email)
- continue
- }
-
- // Update User in Local DB (Read-Model)
- user, err := s.userRepo.FindByID(ctx, kratosID)
- if err != nil {
- // If not in local DB, create it
- user = &domain.User{
- ID: kratosID,
- Email: email,
+ leafID := tenantID // Default to root
+ if orgPath != "" {
+ leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
+ if err != nil {
+ slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
+ continue
}
}
- user.Name = name
- user.Position = position
- user.JobTitle = jobTitle
- user.Department = orgPath
- user.TenantID = &tenantID
- user.Status = "active"
+ // 2. Ensure User exists in Kratos (Auto-create if missing)
+ kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
+ if err != nil || kratosID == "" {
+ slog.Info("User not found in Kratos, auto-creating...", "email", email)
+
+ // Map company name to slug (Simple mapping for now)
+ companyCode := strings.ToLower(companyName)
+ if companyCode == "한맥" { companyCode = "hanmac" }
+ if companyCode == "삼안" { companyCode = "saman" }
+
+ brokerUser := &domain.BrokerUser{
+ Email: email,
+ Name: name,
+ Attributes: map[string]interface{}{
+ "affiliationType": "AFFILIATE",
+ "companyCode": companyCode,
+ "department": orgPath,
+ "grade": "member",
+ },
+ }
+ // Default password for bulk import
+ newID, createErr := s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
+ if createErr != nil {
+ slog.Error("Failed to auto-create user in Kratos", "email", email, "error", createErr)
+ continue
+ }
+ kratosID = newID
+ }
+
+ // 3. Update User in Local DB
+ companyCode := strings.ToLower(companyName)
+ if companyCode == "한맥" { companyCode = "hanmac" }
+ if companyCode == "삼안" { companyCode = "saman" }
+
+ user := &domain.User{
+ ID: kratosID,
+ Email: email,
+ Name: name,
+ Position: position,
+ JobTitle: jobTitle,
+ Department: orgPath,
+ TenantID: &leafID,
+ CompanyCode: companyCode,
+ AffiliationType: "AFFILIATE",
+ Status: "active",
+ UpdatedAt: time.Now(),
+ }
if err := s.userRepo.Update(ctx, user); err != nil {
- slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
+ slog.Error("Failed to update user in local DB", "email", email, "error", err)
continue
}
- // 3. Sync Membership to Keto via Outbox
+ // 4. Sync Membership to Keto
if s.ketoOutboxRepo != nil {
- // Add as member of UserGroup (which is a Tenant namespace object)
+ // Add as member of the specific unit
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
@@ -139,18 +198,7 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
Action: domain.KetoOutboxActionCreate,
})
- // [New] Also add as member of the root Tenant (for tenant-level member count)
- if leafID != tenantID {
- _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
- Namespace: "Tenant",
- Object: tenantID,
- Relation: "members",
- Subject: "User:" + kratosID,
- Action: domain.KetoOutboxActionCreate,
- })
- }
-
- // Add as owner if applicable
+ // If owner/leader, assign owner role
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
diff --git a/docs/organization-chart-policy.md b/docs/organization-chart-policy.md
index d3fa77bf..aa6d85fd 100644
--- a/docs/organization-chart-policy.md
+++ b/docs/organization-chart-policy.md
@@ -1,88 +1,111 @@
-# Organization Chart Architecture & Implementation Policy (ADR)
+# 조직도 기반 테넌트 및 권한 매핑 정책 (Organization Chart Policy)
-## 1. Overview (개요)
-본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다.
+이 문서는 실무 부서 및 직급 체계가 포함된 인사(HR) 데이터를 기반으로, Baron SSO의 다형성 테넌트(Polymorphic Tenant)와 Ory Keto 기반의 ReBAC 권한 모델을 어떻게 구축하고 동기화할 것인지에 대한 아키텍처 설계와 가이드라인을 정의합니다.
-이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다.
+## 1. 개요 및 요구사항 분석
+
+제공된 인사 데이터(샘플)는 다음과 같은 특징을 가집니다.
+
+| 연번 | 그룹 | 디비젼 | 팀 | 셀 | 직급 | 이름 | 직무 | 구분 | 소속 |
+|---|---|---|---|---|---|---|---|---|---|
+| 1 | 사장단 | - | - | - | 사장 | 정태원 | 사장단 | 센터장 | 한맥 |
+| 4 | 엔지니어링 기획 | - | - | - | 부사장 | 양병홍 | 엔지니어 | 그룹장 | 삼안 |
+| 5 | 엔지니어링 기획 | 일반구조물 | - | - | 수석 | 이동원 | 엔지니어 | 디비젼장 | 삼안 |
+| 6 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 김일태 | 엔지니어 | 팀장 | 삼안 |
+| 7 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 곽현석 | 엔지니어 | 팀원 | 한맥 |
+
+### 🔍 주요 분석 포인트 (Matrix Organization)
+가장 중요한 점은 6번(삼안 소속)과 7번(한맥 소속)이 **서로 다른 법인 소속임에도 불구하고 "엔지니어링 기획그룹 > 일반구조물 디비젼 > 구조물계획 팀" 이라는 동일한 논리적 부서에 속해 있다**는 것입니다.
+이는 개별 법인(COMPANY) 산하에 부서가 종속되는 Tree 구조가 아니라, 지주사(COMPANY_GROUP) 차원에서 부서를 관리하고 법인 소속은 개인의 속성(Attribute)으로 분리해야 함을 의미합니다.
---
-## 2. Core Architectural Decisions (핵심 아키텍처 결정)
+## 2. 아키텍처 매핑 전략 (Data to DB)
-### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화)
-조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다.
+엑셀의 각 컬럼은 데이터베이스 모델과 다음과 같이 1:1로 매핑됩니다.
-* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다.
-* **Rationale (이유):**
- * **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다.
- * **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다.
- * **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다.
+### 2.1 조직 (Tenant) 매핑
+모든 조직 단위는 `Tenant` 테이블에 저장되며 `type`과 `parent_id`로 계층을 구성합니다.
-### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조)
-고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다.
+* **소속 (한맥, 삼안):** `Tenant` (Type: `COMPANY`) - 법인 격리 공간
+* **그룹 / 디비젼 / 팀 / 셀:** `Tenant` (Type: `USER_GROUP`) - 논리적 사내 조직
+* **계층 연결:** 하위 조직(팀)의 `parent_id`는 상위 조직(디비젼)의 `id`를 참조합니다.
-* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다.
-* **Implementation (구현):**
- * `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다.
- * 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다.
- * 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다.
+### 2.2 사용자 (User) 속성 매핑
+* **이름:** `User.Name`
+* **직급 (사장, 부사장, 수석 등):** `User.Position`
+* **직무 (엔지니어, 기획자 등):** `User.JobTitle`
+* **소속 (법인 코드):** `User.CompanyCode` (`hanmac`, `saman` 등)
+* **식별자:** (엑셀에 누락됨) 시스템 로그인을 위해 반드시 **이메일(Email) 또는 사번(LoginID)** 컬럼이 추가되어야 합니다.
+
+### 2.3 권한 및 역할 (Keto ReBAC) 매핑
+엑셀의 **구분(센터장, 그룹장, 디비젼장, 팀장, 팀원)** 컬럼은 해당 사용자가 조직 내에서 어떤 권한을 가지는지(Ory Keto의 Relation)를 결정합니다.
+
+* **리더 (장급):** 해당 조직 테넌트의 `owners` 또는 `admins` 튜플 부여.
+ * *예:* 양병홍 부사장은 `엔지니어링 기획그룹`의 `owners`가 됩니다.
+* **팀원:** 가장 말단 조직 테넌트의 `members` 튜플 부여.
+ * *예:* 곽현석 수석은 `구조물계획 팀`의 `members`가 됩니다.
---
-## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트)
+## 3. 다이어그램: 통합 조직도 계층 설계
-새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다.
+아래는 위 전략을 바탕으로 구성된 지주사 통합 조직도와 권한 상속(Keto OPL) 다이어그램입니다.
-### 3.1 `user_groups` 테이블 확장
-조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다.
-* `id`, `tenant_id`, `name`, `description` (기존 유지)
-* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성).
-* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다.
+```mermaid
+graph TD
+ %% 지주사 및 법인
+ G[지주사
Type: COMPANY_GROUP] --> C1[한맥
Type: COMPANY]
+ G --> C2[삼안
Type: COMPANY]
-### 3.2 `users` 테이블 확장 (직급 및 직무)
-CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다.
-* **`position` (String):** 직급 (예: "수석", "책임", "사원").
-* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획").
-* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)*
+ %% 통합 조직도 (지주사 직속 논리적 연결)
+ G -.-> T1[전략기획그룹
Type: USER_GROUP]
+ G -.-> T2[엔지니어링 기획그룹
Type: USER_GROUP]
+
+ T2 --> T2_1[일반구조물 디비젼
Type: USER_GROUP]
+ T2_1 --> T2_1_1[구조물계획 팀
Type: USER_GROUP]
+
+ %% 유저 권한 매핑 (Keto Tuples)
+ U2([양병홍 / 삼안]) -. owners (그룹장) .-> T2
+ U3([이동원 / 삼안]) -. owners (디비젼장) .-> T2_1
+ U4([김일태 / 삼안]) -. owners (팀장) .-> T2_1_1
+ U5([곽현석 / 한맥]) -. members (팀원) .-> T2_1_1
+
+ %% Keto OPL 상속 (부모의 권한이 자식으로 흐름)
+ T2 -. 부모/자식 상속 .-> T2_1
+ T2_1 -. 부모/자식 상속 .-> T2_1_1
+
+ %% 결과적인 권한 도달
+ U2 -. 자동 상속 (Read/Write) .-> T2_1_1
+
+ %% 스타일
+ classDef company fill:#e3f2fd,stroke:#0277bd,stroke-width:2px;
+ classDef group fill:#fff3e0,stroke:#e65100,stroke-width:2px;
+ classDef user fill:#f3e5f5,stroke:#4a148c,stroke-width:1px;
+
+ class G,C1,C2 company;
+ class T1,T2,T2_1,T2_1_1 group;
+ class U2,U3,U4,U5 user;
+```
+
+### 💡 ReBAC 상속의 이점 (OPL)
+위 다이어그램에서 **양병홍 부사장(그룹장)**은 최상위 조직인 `엔지니어링 기획그룹`의 `owners`로 한 번만 매핑됩니다.
+하지만 Keto의 `parents` 상속 설계 덕분에, 하위의 `일반구조물 디비젼`과 `구조물계획 팀`, 그리고 향후 생겨날 모든 하위 '셀' 단위까지 **자동으로 관리 권한(Read/Write)을 상속**받게 됩니다. 권한 부여 작업을 1회로 최소화할 수 있습니다.
---
-## 4. ReBAC Integration Policy (Ory Keto 연동 정책)
+## 4. 구축 파이프라인 (Bulk Import) 가이드
-DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수)
+수백, 수천 명의 조직도를 수동으로 입력하는 것은 불가능하므로, 시스템은 **CSV 일괄 등록(Bulk Import)** API를 제공해야 합니다.
-1. **조직 계층 동기화 (Hierarchy):**
- * DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:#parent@UserGroup:` 튜플이 생성됩니다.
-2. **소속원 매핑 (Membership):**
- * 유저가 A팀에 속하면 `UserGroup:#members@User:<유저_ID>` 튜플이 생성됩니다.
-3. **조직장 및 어드민 승격 (Leadership):**
- * CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:#owners@User:<유저_ID>` 튜플이 생성됩니다.
- * 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다.
+1. **데이터 준비:** 엑셀 데이터를 CSV로 변환합니다. (반드시 이메일 또는 사번 컬럼 포함)
+2. **조직(Tenant) 순차 생성 (Upsert):**
+ * 스크립트는 CSV의 그룹 ➔ 디비젼 ➔ 팀 ➔ 셀 순서로 읽으며, 없는 조직은 생성하고 상위 조직의 ID를 `parent_id`로 연결합니다.
+ * 생성 시 백엔드 `TenantService`는 자동으로 Keto에 `parents` 튜플을 동기화합니다.
+3. **사용자(User) 계정 생성:**
+ * Ory Kratos에 계정을 생성하고(`POST /identities`), 로컬 DB `users` 테이블에 직급, 직무 등의 메타데이터를 저장합니다.
+4. **멤버십(Keto 튜플) 매핑:**
+ * 사용자가 속한 **가장 깊은(Deepest) 말단 조직 단위 하나**를 찾습니다.
+ * 직책(장급/일반)에 따라 `owners` 또는 `members` 권한을 Keto에 부여합니다.
----
-
-## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략)
-
-고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다.
-
-### 5.1 Flexible CSV Format (유연한 CSV 포맷)
-* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다.
- * *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]`
- * *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"`
-* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다.
-
-### 5.2 Processing Flow (처리 흐름)
-1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다.
-2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다.
-3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다.
-4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다.
-
----
-
-## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI)
-
-관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다.
-
-* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다.
-* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다.
-* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다.
\ No newline at end of file
+이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다.