가맹점+회원 백본
_template v1.35.0(Phase 1) + v1.36.0(Phase 2), Core v1.39.0에서 정착한 가맹점(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() 의 서브트리 스코프가 보장하므로, 그룹핑/필터는 그 위에서 안전하게 동작합니다.
회원 상태별 관리 메뉴 (Core v1.39.0)
회원 관리 Resource는 하나만 등록하고, 같은 목록 Page를 여섯 개의 고정 route로 나누어 운영합니다. 기존 계층 가시성 query를 먼저 만든 뒤 상태 조건을 AND로 교차하므로, URL query나 테이블 필터가 SaaS/Tenant/Org 경계를 넓힐 수 없습니다.
| 메뉴 | Route | 포함하는 UserState |
|---|---|---|
| 전체 회원 | /members | active |
| 가입 처리 | /members/signup | pending_verification, pending_approval, rejected |
| 휴면 회원 | /members/dormant | dormant |
| 일시 정지 회원 | /members/suspended | suspended |
| 탈퇴 회원 | /members/withdrawn | withdrawn |
| 삭제 회원 | /members/deleted | soft_deleted |
테이블에는 상태 badge와 상태 변경 시각이 표시되며, 가입 처리 화면에서만 세 가지 가입 상태 필터를 제공합니다. 회원 추가는 활성·가입 처리 화면에서만 기존 MemberResource::canCreate() 권한과 함께 노출됩니다. CSV도 현재 계층 scope와 상태 bucket을 그대로 사용합니다.
삭제 회원 화면은 이전 이름·이메일을 표시하거나 검색하지 않고 CSV에서도 비식별화합니다. 복구·파기 같은 record action도 제공하지 않습니다. Core가 users.state 컬럼보다 먼저 배포된 프로젝트에서는 기존 목록을 활성 회원으로 간주하고 다른 상태 메뉴는 빈 결과로 처리하므로, 미적용 스키마로 인한 500을 방지합니다. 이 변경 자체에는 DB schema 변경이 없습니다.
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/문의 게시판 + 계층 문의처리)는 별도 작업입니다.