가맹점+회원 백본
_template v1.35.0(Phase 1) + v1.36.0(Phase 2) 에서 정착한 가맹점(Tenant/Organization 운영주체) + 회원 계층 백본을 한 페이지로 정리합니다. 세 가지 주제를 다룹니다.
- 계층 가시성 격리 — 같은 레벨의 다른 조직 회원을 차단하는 보안 격리
- 드릴다운 UX — 가맹점별로 회원을 묶어서 보는 운영 화면
- 회원가입 org 자동소속 — 가입한 회원을 적절한 조직에 자동 배치
모두 ADR-058 의 고정 백본 + 가변 트리 모델을 전제로 합니다.
개요
권한 시스템은 세 층으로 구성됩니다.
Platform > SaaS > Tenant > [Orgs ...] > Member
└─ 고정 백본 ─┘ └ 가변 트리 ┘ └ 단말 ┘
| 구분 | 레벨 | 테이블 | 설명 |
|---|---|---|---|
| 고정 백본 | L0 Platform / L1 SaaS / L2 Tenant | saas_products, tenants 등 별도 테이블 | 3계층 고정 구조 |
| 가변 트리 | L3 Org / L4 Workspace / L5 Group | organizations 단일 테이블(자기 참조) | parent_id 기반 임의 깊이(depth 무제한) 트리 |
| 단말 | L6 Member | users | users.organization_id 가 트리의 단말 노드를 가리킴 |
여기서 가맹점은 운영주체인 Tenant 와 그 아래 Organization 트리를 가리키고, 회원은 그 트리의 단말에 매달리는 L6 사용자를 가리킵니다. L3~L5 는 모두 organizations 동일 테이블의 노드이며 org_level 로 권장 매핑(3/4/5)될 뿐, 실제 트리 깊이와는 독립된 개념입니다.
이 구조의 핵심은 임의 깊이 org 트리입니다. 단순 SaaS 는 Tenant > Member 로 끝낼 수도 있고, 대기업은 Tenant > Org₁ > Org₂ > ... > Member 로 깊게 확장할 수도 있습니다. 백본 작업은 이 임의 깊이 트리 위에서 가시성/드릴다운/가입 배치가 항상 정확하게 동작하도록 보장합니다.
계층 가시성 격리 (보안)
일반화 격리 원칙
v1.35.0 Phase 1 에서 확립한 원칙은 다음 한 줄로 요약됩니다.
모든 레벨은 자기 subtree 의 하위(descendants)만 보고, 동일 레벨 sibling 데이터는 차단한다.
수정 전에는 L3 Organization Admin · L5 Group Leader 가 같은 Tenant 의 다른 Org 회원을 열람할 수 있는 권한 누수가 있었습니다. 예를 들어 같은 가맹점 안에 Org A / Org B 가 나란히 있을 때, Org A 관리자가 Org B 의 회원 목록을 볼 수 있었습니다. Phase 1 은 이 sibling 누수를 차단합니다.
레벨별 적용
뷰어(로그인 사용자)의 레벨에 따라 스코프 처리가 달라집니다.
| 뷰어 레벨 | 격리 방식 |
|---|---|
| L0 Platform | 전역 — org 추가 제약 없음 |
| L1 SaaS / L2 Tenant | saas_product_id / tenant_id 스코프(SaasProductScope · TenantScope)로 sibling 격리가 이미 처리됨 → org 추가 제약 없음 |
| L3 Org / L4 Workspace / L5 Group | organizations 가변 트리이므로 자기 Org 서브트리(self + descendants) 의 회원만 organization_id 로 격리 → 같은 상위의 다른 Org/Workspace/Group(sibling) 차단 |
ViewerScopeResolver
핵심 구현은 Core 의 ViewerScopeResolver(App\Core\Base\Permission\Support\ViewerScopeResolver) 입니다. 뷰어가 볼 수 있는 organization_id 목록을 해석합니다.
use App\Core\Base\Permission\Support\ViewerScopeResolver;
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
// null = org 스코프 미적용 (L0/L1/L2 또는 organization_id 없음)
// array = 자기 Org + 하위 Org id 목록 (whereIn 대상)
// [] = fail-safe (org 해석 실패 → 아무것도 안 보이게)
반환 규칙은 viewerOrgIds() 가 뷰어의 level 과 organization_id 를 보고 결정합니다. level 이 <= 2 이거나 organization_id 가 없으면 null 을 반환해 추가 제약을 걸지 않고, 그 외에는 orgSubtreeIds() 로 서브트리를 계산합니다.
주요 메서드는 세 가지입니다.
| 메서드 | 역할 |
|---|---|
viewerOrgIds(mixed $user): ?array | 뷰어 기준 가시 org 목록(레벨 게이트 포함). 반환 타입은 ?array (org id 정수 배열 또는 null) |
orgSubtreeIds(int $orgId): array | 레벨 게이트 없는 순수 서브트리(self + 모든 하위). RelationManager / resolver 재사용 |
orgAncestorIds(int $orgId): array | 조상 체인(self + 모든 상위). 공지 노출 / 문의 에스컬레이션용 |
parent_id BFS 서브트리
서브트리 해석은 항상 신뢰 가능한 parent_id(FK) BFS 로 수행합니다. path 컬럼(Materialized Path)은 boot hook 의존이라 환경/시점에 따라 미설정될 수 있으므로 의존하지 않습니다. org 트리는 얕아서 깊이당 1쿼리로 충분합니다.
// orgSubtreeIds: parent_id 기반 BFS (cycle / runaway 가드)
$ids = [$orgId];
$frontier = [$orgId];
$guard = 0;
while (! empty($frontier) && $guard++ < 50) {
$children = DB::table($table)
->whereIn('parent_id', $frontier)
->pluck('id')->map(static fn ($id) => (int) $id)->all();
$children = array_values(array_diff($children, $ids)); // 이미 본 노드 제외(cycle 방어)
if (empty($children)) {
break;
}
$ids = array_merge($ids, $children);
$frontier = $children;
}
- fail-safe: 대상 Org 가 존재하지 않으면 빈 배열을 반환해, 잘못된 입력 시 데이터가 노출되지 않고 차단되는 방향으로 동작합니다.
- cycle / runaway 가드: 이미 본 노드를 제외하고, 가드 카운터(
guard < 50)로 무한 루프를 방지합니다. - Core 독립성:
App\Models\Organization을 직접 참조하지 않고organizations테이블을 직접 쿼리합니다(테이블명은config('core.tables.organizations')).
Filament 적용 지점
격리는 두 경로로 쿼리에 반영됩니다.
1. HasLevelBasedAuthorization Trait — 레벨 기반 Resource 의 getEloquentQuery() 가 applyAdditionalQueryConstraints() 를 자동 호출합니다.
public static function applyAdditionalQueryConstraints(Builder $query, $user): Builder
{
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
if (is_null($orgIds)) {
return $query; // L0/L1/L2 — saas/tenant 스코프로 충분
}
return $query->whereIn('organization_id', $orgIds);
}
2. BaseAllUsersResource::buildQuery() — 전체 사용자 조회 화면에서 레벨별로 직접 격리합니다.
$level = (int) ($user->level ?? 6);
if ($level === 0) {
return $query; // Platform: 전체 SaaS 열람
}
if ($level === 1 && $user->saas_product_id) {
return $query->where('saas_product_id', $user->saas_product_id); // SaaS: 자기 SaaS
}
// Level 2: TenantScope 가 이미 tenant_id 로 sibling(다른 Tenant) 차단
// Level 3+: 자기 Org 서브트리로 추가 제한 → 다른 Org/Workspace/Group(sibling) 차단
if ($level >= 3) {
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
if (! is_null($orgIds)) {
$query->whereIn('organization_id', $orgIds);
}
}
L1 SaaS Admin 이 Platform Panel 류 화면에서 cross-SaaS 사용자를 보지 못하도록 saas_product_id 도 명시적으로 제한합니다. 검증은 HierarchicalVisibilityScopeTest(5 passed)가 sibling 격리를 입증합니다.
드릴다운 UX
v1.36.0 Phase 2 는 가맹점별로 회원을 묶어서 보는 운영 화면을 추가했습니다. 두 가지 도구가 역할을 분담합니다.
| 도구 | 보이는 범위 | 메커니즘 |
|---|---|---|
| RelationManager (RM) | 해당 레코드의 직속 관계 | Filament 관계 FK 제약(AND 조건) |
| AllUsersResource | 임의 깊이 서브트리 집계 | Phase 1 서브트리 스코프 + 그룹핑/필터 |
역할 분리가 필요한 이유
Filament RelationManager 의 관계 FK 제약은 AND 조건만 가능합니다. 서브트리를 OR 로 확장하려면 TenantScope / SoftDelete 격리가 훼손되므로, 서브트리 집계는 RM 이 아니라 AllUsersResource(서브트리 스코프 + 그룹핑/필터)가 담당합니다. RM 은 "이 노드에 직접 매달린 것" 만 보여주는 정확한 엔티티 드릴다운에 집중합니다.
BaseAllUsersResource — 그룹핑 + 조건부 필터
전체 사용자 화면은 Tenant → Organization 단위 그룹핑 헤더(접기 가능)와 상위 패널 전용 조건부 SelectFilter 를 제공합니다.
// 가맹점별 드릴다운: Tenant → Organization 묶음 헤더
->groups([
Group::make('tenant.name')->label('Tenant')->collapsible(),
Group::make('organization.name')->label('Organization')->collapsible(),
])
필터는 패널의 최소 레벨(getMinimumLevel())에 따라 조건부로 노출됩니다.
| 필터 | 노출 조건 |
|---|---|
| Level | 항상 (최소 레벨 이상만 옵션) |
Tenant (tenant_id) | getMinimumLevel() 이 <= 2 인 상위 패널만 |
Organization (organization_id) | getMinimumLevel() 이 <= 3 인 패널만 |
임의 깊이 org 트리의 멤버십 정확성은 buildQuery() 의 서브트리 스코프가 보장하므로, 그룹핑/필터는 그 위에서 안전하게 동작합니다.