본문으로 건너뛰기

가맹점+회원 백본

_template v1.35.0(Phase 1) + v1.36.0(Phase 2), Core v1.39.0에서 정착한 가맹점(Tenant/Organization 운영주체) + 회원 계층 백본을 한 페이지로 정리합니다. 네 가지 주제를 다룹니다.

  1. 계층 가시성 격리 — 같은 레벨의 다른 조직 회원을 차단하는 보안 격리
  2. 드릴다운 UX — 가맹점별로 회원을 묶어서 보는 운영 화면
  3. 회원가입 org 자동소속 — 가입한 회원을 적절한 조직에 자동 배치
  4. 회원 상태 운영 — 활성·가입 처리·휴면·일시 정지·탈퇴·삭제 회원을 분리 관리

모두 ADR-058 의 고정 백본 + 가변 트리 모델을 전제로 합니다.

개요​

권한 시스템은 세 층으로 구성됩니다.

Platform > SaaS > Tenant > [Orgs ...] > Member
└─ 고정 백본 ─┘ └ 가변 트리 ┘ └ 단말 ┘
구분레벨테이블설명
고정 백본L0 Platform / L1 SaaS / L2 Tenantsaas_products, tenants 등 별도 테이블3계층 고정 구조
가변 트리L3 Org / L4 Workspace / L5 Grouporganizations 단일 테이블(자기 참조)parent_id 기반 임의 깊이(depth 무제한) 트리
단말L6 Memberusersusers.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 Tenantsaas_product_id / tenant_id 스코프(SaasProductScope · TenantScope)로 sibling 격리가 이미 처리됨 → org 추가 제약 없음
L3 Org / L4 Workspace / L5 Grouporganizations 가변 트리이므로 자기 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
전체 회원/membersactive
가입 처리/members/signuppending_verification, pending_approval, rejected
휴면 회원/members/dormantdormant
일시 정지 회원/members/suspendedsuspended
탈퇴 회원/members/withdrawnwithdrawn
삭제 회원/members/deletedsoft_deleted

테이블에는 상태 badge와 상태 변경 시각이 표시되며, 가입 처리 화면에서만 세 가지 가입 상태 필터를 제공합니다. 회원 추가는 활성·가입 처리 화면에서만 기존 MemberResource::canCreate() 권한과 함께 노출됩니다. CSV도 현재 계층 scope와 상태 bucket을 그대로 사용합니다.

삭제 회원 화면은 이전 이름·이메일을 표시하거나 검색하지 않고 CSV에서도 비식별화합니다. 복구·파기 같은 record action도 제공하지 않습니다. Core가 users.state 컬럼보다 먼저 배포된 프로젝트에서는 기존 목록을 활성 회원으로 간주하고 다른 상태 메뉴는 빈 결과로 처리하므로, 미적용 스키마로 인한 500을 방지합니다. 이 변경 자체에는 DB schema 변경이 없습니다.

TenantResource RelationManager (SaaS 패널)​

SaaS 패널의 TenantResource 는 가맹점(Tenant) 엔티티에서 두 갈래로 드릴다운합니다.

RelationManagerrelationship내용
OrganizationsRelationManagerorganizations소속 조직 트리 현황 — 코드/이름/계층(org_level)/깊이(depth)/회원 수/활성. depth 오름차순 정렬
UsersRelationManagerusers소속 회원, 조직(organization.name)별 그룹핑(접기 가능). level 오름차순 정렬

OrganizationResource RelationManager (Tenant 패널)​

Tenant 패널의 OrganizationResource 는 조직(Org) 노드에서 직속 회원을 보여줍니다.

RelationManagerrelationship내용
MembersRelationManagerusers해당 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 는 다음 우선순위로 결정됩니다.

순위출처설명
1invitation.organization_id초대장에 org 가 지정된 경우
2tenant.settings.signup.default_org_codeTenant 설정의 기본 org 코드(코드로 조회)
3tenant 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_POLICYdisabled가입 정책 폴백
CORE_SIGNUP_DEFAULT_LEVEL6가입 회원 기본 레벨
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 2workspace/_template/CHANGELOG.md (1.35.0 / 1.36.0)
ViewerScopeResolvercore/Base/Permission/Support/ViewerScopeResolver.php
HasLevelBasedAuthorizationcore/Base/Permission/Filament/Traits/HasLevelBasedAuthorization.php
BaseAllUsersResourcecore/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/문의 게시판 + 계층 문의처리)는 별도 작업입니다.