Skip to main content

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.

  1. Hierarchical visibility isolation β€” security isolation that blocks members of other organizations at the same level
  2. Drill-down UX β€” operational screens that group members by tenant
  3. 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 β”˜
CategoryLevelTableDescription
Fixed backboneL0 Platform / L1 SaaS / L2 TenantSeparate tables such as saas_products, tenants3-layer fixed structure
Flex treeL3 Org / L4 Workspace / L5 GroupSingle organizations table (self-referencing)Arbitrary-depth tree based on parent_id (unlimited depth)
LeafL6 Memberusersusers.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 LevelIsolation Method
L0 PlatformGlobal β€” no additional org constraint
L1 SaaS / L2 TenantSibling isolation is already handled by the saas_product_id / tenant_id scope (SaasProductScope / TenantScope) β†’ no additional org constraint
L3 Org / L4 Workspace / L5 GroupBecause 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.

MethodRole
viewerOrgIds(mixed $user): ?arrayVisible 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): arrayPure subtree with no level gate (self + all descendants). Reused by RelationManager / resolver
orgAncestorIds(int $orgId): arrayAncestor 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\Organization directly, it queries the organizations table directly (the table name is config('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.

ToolVisible ScopeMechanism
RelationManager (RM)The record's direct relationshipFilament relationship FK constraint (AND condition)
AllUsersResourceArbitrary-depth subtree aggregationPhase 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()).

FilterExposure Condition
LevelAlways (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.

RelationManagerrelationshipContent
OrganizationsRelationManagerorganizationsStatus of the affiliated organization tree β€” code/name/level (org_level)/depth (depth)/member count/active. Sorted by depth ascending
UsersRelationManagerusersAffiliated 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.

RelationManagerrelationshipContent
MembersRelationManagerusersDirect 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.

RankSourceDescription
1invitation.organization_idWhen an org is specified on the invitation
2tenant.settings.signup.default_org_codeThe default org code in the Tenant settings (looked up by code)
3tenant HQ root OrgIf 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 VariableDefaultPurpose
CORE_SIGNUP_DEFAULT_POLICYdisabledSignup policy fallback
CORE_SIGNUP_DEFAULT_LEVEL6Default level for the registering member
CORE_SIGNUP_DEFAULT_REDIRECT/appRedirect 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 organizations plus nullOnDelete (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 = null argument to support org-specified invitations.

Verification: SignupOrgAssignmentTest 3 passed (HQ fallback / default_org_code / org-null tenant).

ItemLocation
ADR-058 Permission system: fixed backbone + flex treeworkspace/_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 module guideCore / Permission
Organization panel guideFilament 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.