가맹점+회원 백본
_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() 의 서브트리 스코프가 보장하므로, 그룹핑/필터는 그 위에서 안전하게 동작합니다.
TenantResource RelationManager (SaaS 패널)
SaaS 패널의 TenantResource 는 가맹점(Tenant) 엔티티에서 두 갈래로 드릴다운합니다.
| RelationManager | relationship | 내용 |
|---|---|---|
OrganizationsRelationManager | organizations | 소속 조직 트리 현황 — 코드/이름/계층(org_level)/깊이(depth)/회원 수/활성. depth 오름차순 정렬 |
UsersRelationManager | users | 소속 회원, 조직(organization.name)별 그룹핑(접기 가능). level 오름차순 정렬 |
OrganizationResource RelationManager (Tenant 패널)
Tenant 패널의 OrganizationResource 는 조직(Org) 노드에서 직속 회원을 보여줍니다.
| RelationManager | relationship | 내용 |
|---|---|---|
MembersRelationManager | users | 해당 Org 노드의 직속 회원(organization_id == record). 이름/이메일/Level/상위자(parent.name) |
하위 Org 까지 포함한 서브트리 집계 뷰는 위에서 설명한 AllUsersResource 가 담당합니다(역할 분리).
회원가입 org 자동소속
v1.36.0 Phase 2 는 회원가입 시 적절한 조직 소속과 계층 부 모를 자동 부여합니다. 핵심은 SignupAssignmentResolver(App\Core\Base\Auth\Signup\Services\SignupAssignmentResolver) 의 resolveOrganization() 입니다.
org 해석 우선순위
가입 회원의 organization_id 는 다음 우선순위로 결정됩니다.
| 순위 | 출처 | 설명 |
|---|---|---|
| 1 | invitation.organization_id | 초대장에 org 가 지정된 경우 |
| 2 | tenant.settings.signup.default_org_code | Tenant 설정의 기본 org 코드(코드로 조회) |
| 3 | tenant HQ 루트 Org | 위가 모두 없으면 해당 Tenant 의 루트 Org(parent_id IS NULL) 폴백 |
세 단계 모두 실패해 org 를 해석하지 못하면 organization_id 는 null 로 두며, 이때는 tenant 격리만으로 충분합니다.
parent_id 결정
parent_id 는 해석된 Org 의 L3 Organization Admin 으로 설정합니다(존재하지 않으면 null). 이로써 가입 회원이 계층 체인에 정상적으로 연결됩니다.
// resolveOrganization 핵심 — parent_id = 해당 Org 의 L3 Organization Admin
$parent = DB::table($userTable)
->where('organization_id', $orgId)
->where('level', UserLevel::ORGANIZATION_ADMIN->value) // L3
->orderBy('id')
->value('id');
해석 결과는 SignupAssignment DTO(organizationId / parentId 필드 추가)에 담겨 전달되고, Fortify CreateNewUser 가 신규 User 의 organization_id 와 parent_id 를 채웁니다.
// CreateNewUser: 가입 회원 org 자동소속
$user->setAttribute('organization_id', $assignment->organizationId);
$user->setAttribute('parent_id', $assignment->parentId);
resolver 는 Core 독립성을 위해 App\Models\Organization 대신 organizations 테이블을 직접 쿼리하며, depth-무관(임의 깊이 트리)으로 동작합니다.
config — auth.member_signup
기존 하드코딩 기본값을 config/core.php 의 auth.member_signup 블록으로 외부화했습니다. 기본 정책은 disabled 이므로, 운영자가 사이트별로 명시적으로 활성화하기 전에는 자율 가입이 닫혀 있습니다(하위 호환 안전).
// config/core.php
'auth' => [
'member_signup' => [
'default_policy' => env('CORE_SIGNUP_DEFAULT_POLICY', 'disabled'),
'default_level' => (int) env('CORE_SIGNUP_DEFAULT_LEVEL', 6),
'default_redirect' => env('CORE_SIGNUP_DEFAULT_REDIRECT', '/app'),
'allowed_policies' => ['auto', 'invitation', 'approval', 'disabled'],
'allowed_redirects' => ['/app', '/dashboard', '/'],
],
],
사이트(SaaS/Tenant)의 settings.signup.* 가 위 기본값보다 우선합니다. 정책 우선순위는 tenant.settings.signup.policy → saas.settings.signup.policy → config('core.auth.member_signup.default_policy') 순입니다.
| env 변수 | 기본값 | 용도 |
|---|---|---|
CORE_SIGNUP_DEFAULT_POLICY | disabled | 가입 정책 폴백 |
CORE_SIGNUP_DEFAULT_LEVEL | 6 | 가입 회원 기본 레벨 |
CORE_SIGNUP_DEFAULT_REDIRECT | /app | 가입 후 리다이렉트 |
멱등 마이그레이션
core_signup_invitations 테이블에 organization_id 컬럼을 추가하는 마이그레이션(2026_05_31_000001_add_organization_id_to_core_signup_invitations)은 멱등하게 작성되어 백포트 재적용이 안전합니다.
if (! Schema::hasTable('core_signup_invitations')) {
return; // 테이블 없으면 graceful skip
}
if (Schema::hasColumn('core_signup_invitations', 'organization_id')) {
return; // 멱등: 이미 존재(백포트 재적용 안전)
}
Schema::table('core_signup_invitations', function (Blueprint $table) {
$table->foreignId('organization_id')
->nullable()
->after('tenant_id')
->constrained('organizations')
->nullOnDelete();
});
- 컬럼은 nullable 이며
organizations에 FK +nullOnDelete(조직 삭제 시 초대장의 org 참조만 null 로) 입니다. down()은 백포트 안전을 위해 컬럼 드롭을 보류합니다(가입 회원의 org 타겟 매핑 데이터를 롤백으로 잃지 않도록).- 초대장 발급 측
SignupInvitationService::issue()도?int $organizationId = null인자를 받아 org 지정 초대를 지원합니다.
검증: SignupOrgAssignmentTest 3 passed (HQ 폴백 / default_org_code / org-null tenant).
관련 설계/참고
| 항목 | 위치 |
|---|---|
| ADR-058 권한 시스템: 고정 백본 + 가변 트리 | workspace/_docs/design/01_decisions/58_permission-hierarchy-fixed-backbone-and-flex-tree.md |
| CHANGELOG — Phase 1 / Phase 2 | workspace/_template/CHANGELOG.md (1.35.0 / 1.36.0) |
| ViewerScopeResolver | core/Base/Permission/Support/ViewerScopeResolver.php |
| HasLevelBasedAuthorization | core/Base/Permission/Filament/Traits/HasLevelBasedAuthorization.php |
| BaseAllUsersResource | core/Base/Filament/Resources/BaseAllUsersResource.php |
| SignupAssignmentResolver / SignupAssignment(DTO) | core/Base/Auth/Signup/Services/SignupAssignmentResolver.php, core/Base/Auth/Signup/DTO/SignupAssignment.php |
| Permission 모듈 가이드 | Core / Permission |
| Organization 패널 가이드 | Filament 패널 / Organization 패널 |
후속(미반영): Phase 2.5(감사/실행 로그 계층 가시성 + 민감도 등급), Phase 3(내장 공지/FAQ/문의 게시판 + 계층 문의처리)는 별도 작업입니다.