본문으로 건너뛰기

가맹점+회원 백본

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

  1. 계층 가시성 격리 — 같은 레벨의 다른 조직 회원을 차단하는 보안 격리
  2. 드릴다운 UX — 가맹점별로 회원을 묶어서 보는 운영 화면
  3. 회원가입 org 자동소속 — 가입한 회원을 적절한 조직에 자동 배치

모두 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() 가 뷰어의 levelorganization_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) 엔티티에서 두 갈래로 드릴다운합니다.

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_idnull 로 두며, 이때는 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_idparent_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.phpauth.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.policysaas.settings.signup.policyconfig('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/문의 게시판 + 계층 문의처리)는 별도 작업입니다.