forked from baron/baron-sso
feat(org): enhance bulk import to support multi-level hierarchy, auto-provision users, and map matrix organizations (#500)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[지주사<br>Type: COMPANY_GROUP] --> C1[한맥<br>Type: COMPANY]
|
||||
G --> C2[삼안<br>Type: COMPANY]
|
||||
|
||||
### 3.2 `users` 테이블 확장 (직급 및 직무)
|
||||
CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다.
|
||||
* **`position` (String):** 직급 (예: "수석", "책임", "사원").
|
||||
* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획").
|
||||
* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)*
|
||||
%% 통합 조직도 (지주사 직속 논리적 연결)
|
||||
G -.-> T1[전략기획그룹<br>Type: USER_GROUP]
|
||||
G -.-> T2[엔지니어링 기획그룹<br>Type: USER_GROUP]
|
||||
|
||||
T2 --> T2_1[일반구조물 디비젼<br>Type: USER_GROUP]
|
||||
T2_1 --> T2_1_1[구조물계획 팀<br>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:<A팀_ID>#parent@UserGroup:<B본부_ID>` 튜플이 생성됩니다.
|
||||
2. **소속원 매핑 (Membership):**
|
||||
* 유저가 A팀에 속하면 `UserGroup:<A팀_ID>#members@User:<유저_ID>` 튜플이 생성됩니다.
|
||||
3. **조직장 및 어드민 승격 (Leadership):**
|
||||
* CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:<A팀_ID>#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) 컴포넌트로 구현됩니다.
|
||||
이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다.
|
||||
|
||||
Reference in New Issue
Block a user