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 +이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다.