Tenant + Member Backbone
This page consolidates the tenant (Tenant/Organization operating entity) + member hierarchy backbone that was established in _template v1.35.0 (Phase 1) + v1.36.0 (Phase 2). It covers three topics.
- Hierarchical visibility isolation β security isolation that blocks members of other organizations at the same level
- Drill-down UX β operational screens that group members by tenant
- Automatic org assignment on signup β automatically placing a registered member into the appropriate organization
All of these assume the fixed-backbone + flex-tree model from ADR-058.
Overviewβ
The permission system is composed of three layers.
Platform > SaaS > Tenant > [Orgs ...] > Member
ββ Fixed backbone ββ β Flex tree β β Leaf β
| Category | Level | Table | Description |
|---|---|---|---|
| Fixed backbone | L0 Platform / L1 SaaS / L2 Tenant | Separate tables such as saas_products, tenants | 3-layer fixed structure |
| Flex tree | L3 Org / L4 Workspace / L5 Group | Single organizations table (self-referencing) | Arbitrary-depth tree based on parent_id (unlimited depth) |
| Leaf | L6 Member | users | users.organization_id points to a leaf node of the tree |
Here, a tenant refers to the Tenant operating entity and the Organization tree beneath it, while a member refers to the L6 user attached to a leaf of that tree. L3βL5 are all nodes of the same organizations table; they are merely given a recommended mapping (3/4/5) via org_level, which is a concept independent of the actual tree depth.
The key to this structure is the arbitrary-depth org tree. A simple SaaS can stop at Tenant > Member, while a large enterprise can expand deeply as Tenant > Orgβ > Orgβ > ... > Member. The backbone work guarantees that visibility / drill-down / signup assignment always operate correctly on top of this arbitrary-depth tree.
Hierarchical Visibility Isolation (Security)β
Generalized Isolation Principleβ
The principle established in v1.35.0 Phase 1 can be summarized in a single line.
Every level sees only the descendants of its own subtree and blocks sibling data at the same level.
Before the fix, there was a permission leak where an L3 Organization Admin or L5 Group Leader could view members of another Org within the same Tenant. For example, when Org A and Org B sit side by side within the same tenant, the Org A admin could see Org B's member list. Phase 1 blocks this sibling leak.
Per-Level Applicationβ
Scope handling differs depending on the level of the viewer (the logged-in user).
| Viewer Level | Isolation Method |
|---|---|
| L0 Platform | Global β no additional org constraint |
| L1 SaaS / L2 Tenant | Sibling isolation is already handled by the saas_product_id / tenant_id scope (SaasProductScope / TenantScope) β no additional org constraint |
| L3 Org / L4 Workspace / L5 Group | Because organizations is a flex tree, only members of the viewer's own Org subtree (self + descendants) are isolated by organization_id β blocks other Org/Workspace/Group (siblings) under the same parent |
ViewerScopeResolverβ
The core implementation is Core's ViewerScopeResolver (App\Core\Base\Permission\Support\ViewerScopeResolver). It resolves the list of organization_id values the viewer is allowed to see.
use App\Core\Base\Permission\Support\ViewerScopeResolver;
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
// null = no org scope applied (L0/L1/L2 or no organization_id)
// array = list of own Org + descendant Org ids (target of whereIn)
// [] = fail-safe (org resolution failed β show nothing)
The return rules are decided by viewerOrgIds() based on the viewer's level and organization_id. If level is <= 2 or there is no organization_id, it returns null so no additional constraint is applied; otherwise it computes the subtree via orgSubtreeIds().
There are three main methods.
| Method | Role |
|---|---|
viewerOrgIds(mixed $user): ?array | Visible org list relative to the viewer (including the level gate). The return type is ?array (an array of org id integers, or null) |
orgSubtreeIds(int $orgId): array | Pure subtree with no level gate (self + all descendants). Reused by RelationManager / resolver |
orgAncestorIds(int $orgId): array | Ancestor chain (self + all ancestors). Used for notice exposure / inquiry escalation |
parent_id BFS Subtreeβ
Subtree resolution is always performed via trustworthy parent_id (FK) BFS. The path column (Materialized Path) depends on a boot hook and may be unset depending on the environment/timing, so it is not relied upon. The org tree is shallow, so one query per depth level is sufficient.
// orgSubtreeIds: parent_id based BFS (cycle / runaway guard)
$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)); // exclude already-seen nodes (cycle defense)
if (empty($children)) {
break;
}
$ids = array_merge($ids, $children);
$frontier = $children;
}
- fail-safe: If the target Org does not exist, an empty array is returned, so on invalid input the data is not exposed but blocked.
- cycle / runaway guard: Already-seen nodes are excluded, and a guard counter (
guard < 50) prevents infinite loops. - Core independence: Instead of referencing
App\Models\Organizationdirectly, it queries theorganizationstable directly (the table name isconfig('core.tables.organizations')).
Filament Application Pointsβ
Isolation is reflected in queries through two paths.
1. HasLevelBasedAuthorization Trait β getEloquentQuery() of a level-based Resource automatically calls applyAdditionalQueryConstraints().
public static function applyAdditionalQueryConstraints(Builder $query, $user): Builder
{
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
if (is_null($orgIds)) {
return $query; // L0/L1/L2 β saas/tenant scope is sufficient
}
return $query->whereIn('organization_id', $orgIds);
}
2. BaseAllUsersResource::buildQuery() β directly isolates per level on the all-users listing screen.
$level = (int) ($user->level ?? 6);
if ($level === 0) {
return $query; // Platform: view all SaaS
}
if ($level === 1 && $user->saas_product_id) {
return $query->where('saas_product_id', $user->saas_product_id); // SaaS: own SaaS
}
// Level 2: TenantScope already blocks siblings (other Tenants) by tenant_id
// Level 3+: further restrict to own Org subtree β block other Org/Workspace/Group (siblings)
if ($level >= 3) {
$orgIds = ViewerScopeResolver::viewerOrgIds($user);
if (! is_null($orgIds)) {
$query->whereIn('organization_id', $orgIds);
}
}
saas_product_id is also restricted explicitly so that an L1 SaaS Admin cannot see cross-SaaS users on Platform Panel-type screens. Verification: HierarchicalVisibilityScopeTest (5 passed) proves the sibling isolation.
Drill-down UXβ
v1.36.0 Phase 2 added operational screens that group members by tenant. Two tools divide the roles.
| Tool | Visible Scope | Mechanism |
|---|---|---|
| RelationManager (RM) | The record's direct relationship | Filament relationship FK constraint (AND condition) |
| AllUsersResource | Arbitrary-depth subtree aggregation | Phase 1 subtree scope + grouping/filter |
Why the Role Separation Is Neededβ
A Filament RelationManager's relationship FK constraint can only be an AND condition. Expanding the subtree with OR would break the TenantScope / SoftDelete isolation, so subtree aggregation is handled not by the RM but by AllUsersResource (subtree scope + grouping/filter). The RM focuses on precise entity drill-down, showing only "what is directly attached to this node."
BaseAllUsersResource β Grouping + Conditional Filtersβ
The all-users screen provides grouping headers at the Tenant β Organization level (collapsible) and conditional SelectFilters dedicated to upper-level panels.
// Per-tenant drill-down: Tenant β Organization grouping headers
->groups([
Group::make('tenant.name')->label('Tenant')->collapsible(),
Group::make('organization.name')->label('Organization')->collapsible(),
])
Filters are exposed conditionally based on the panel's minimum level (getMinimumLevel()).
| Filter | Exposure Condition |
|---|---|
| Level | Always (only options at or above the minimum level) |
Tenant (tenant_id) | Only upper-level panels whose getMinimumLevel() is <= 2 |
Organization (organization_id) | Only panels whose getMinimumLevel() is <= 3 |
Membership accuracy of the arbitrary-depth org tree is guaranteed by the subtree scope in buildQuery(), so grouping/filtering operates safely on top of it.
TenantResource RelationManager (SaaS Panel)β
The SaaS panel's TenantResource drills down from the tenant (Tenant) entity along two branches.
| RelationManager | relationship | Content |
|---|---|---|
OrganizationsRelationManager | organizations | Status of the affiliated organization tree β code/name/level (org_level)/depth (depth)/member count/active. Sorted by depth ascending |
UsersRelationManager | users | Affiliated members, grouped by organization (organization.name) (collapsible). Sorted by level ascending |
OrganizationResource RelationManager (Tenant Panel)β
The Tenant panel's OrganizationResource shows the direct members of an organization (Org) node.
| RelationManager | relationship | Content |
|---|---|---|
MembersRelationManager | users | Direct members of the given Org node (organization_id == record). Name/email/Level/parent (parent.name) |
The subtree aggregation view that includes descendant Orgs is handled by the AllUsersResource described above (role separation).
Automatic Org Assignment on Signupβ
v1.36.0 Phase 2 automatically assigns the appropriate organization affiliation and hierarchical parent at signup time. The core is resolveOrganization() of SignupAssignmentResolver (App\Core\Base\Auth\Signup\Services\SignupAssignmentResolver).
Org Resolution Priorityβ
A registering member's organization_id is determined by the following priority.
| Rank | Source | Description |
|---|---|---|
| 1 | invitation.organization_id | When an org is specified on the invitation |
| 2 | tenant.settings.signup.default_org_code | The default org code in the Tenant settings (looked up by code) |
| 3 | tenant HQ root Org | If none of the above exist, fall back to that Tenant's root Org (parent_id IS NULL) |
If all three steps fail and no org can be resolved, organization_id is left as null; in that case tenant isolation alone is sufficient.
parent_id Determinationβ
parent_id is set to the L3 Organization Admin of the resolved Org (or null if none exists). This connects the registering member properly into the hierarchy chain.
// resolveOrganization core β parent_id = L3 Organization Admin of the given Org
$parent = DB::table($userTable)
->where('organization_id', $orgId)
->where('level', UserLevel::ORGANIZATION_ADMIN->value) // L3
->orderBy('id')
->value('id');
The resolution result is carried in the SignupAssignment DTO (with the organizationId / parentId fields added) and passed along, and Fortify's CreateNewUser populates the new User's organization_id and parent_id.
// CreateNewUser: automatic org assignment for the registering member
$user->setAttribute('organization_id', $assignment->organizationId);
$user->setAttribute('parent_id', $assignment->parentId);
For Core independence, the resolver queries the organizations table directly instead of App\Models\Organization, and operates depth-agnostically (arbitrary-depth tree).
config β auth.member_signupβ
The previously hardcoded default values were externalized into the auth.member_signup block of config/core.php. The default policy is disabled, so self-service signup stays closed until the operator explicitly enables it per site (backward-compatible safety).
// 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', '/'],
],
],
A site's (SaaS/Tenant) settings.signup.* takes precedence over the defaults above. The policy precedence is tenant.settings.signup.policy β saas.settings.signup.policy β config('core.auth.member_signup.default_policy').
| env Variable | Default | Purpose |
|---|---|---|
CORE_SIGNUP_DEFAULT_POLICY | disabled | Signup policy fallback |
CORE_SIGNUP_DEFAULT_LEVEL | 6 | Default level for the registering member |
CORE_SIGNUP_DEFAULT_REDIRECT | /app | Redirect after signup |
Idempotent Migrationβ
The migration that adds the organization_id column to the core_signup_invitations table (2026_05_31_000001_add_organization_id_to_core_signup_invitations) is written idempotently, so re-applying it during backport is safe.
if (! Schema::hasTable('core_signup_invitations')) {
return; // graceful skip if the table does not exist
}
if (Schema::hasColumn('core_signup_invitations', 'organization_id')) {
return; // idempotent: already exists (safe to re-apply on backport)
}
Schema::table('core_signup_invitations', function (Blueprint $table) {
$table->foreignId('organization_id')
->nullable()
->after('tenant_id')
->constrained('organizations')
->nullOnDelete();
});
- The column is nullable, with an FK to
organizationsplusnullOnDelete(when an organization is deleted, only the invitation's org reference is set to null). down()defers dropping the column for backport safety (so the registering member's org target mapping data is not lost on rollback).- On the invitation-issuing side,
SignupInvitationService::issue()also accepts a?int $organizationId = nullargument to support org-specified invitations.
Verification: SignupOrgAssignmentTest 3 passed (HQ fallback / default_org_code / org-null tenant).
Related Design / Referencesβ
| Item | Location |
|---|---|
| ADR-058 Permission system: fixed backbone + flex tree | 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 module guide | Core / Permission |
| Organization panel guide | Filament Panels / Organization Panel |
Follow-ups (not yet implemented): Phase 2.5 (audit/run-log hierarchical visibility + sensitivity grades) and Phase 3 (built-in notice/FAQ/inquiry board + hierarchical inquiry handling) are separate efforts.