Skip to main content

Tenant + Member Backbone

This page consolidates the tenant (Tenant/Organization operating entity) + member hierarchy backbone established in _template v1.35.0 (Phase 1) + v1.36.0 (Phase 2), and Core v1.39.0. It covers four 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
  4. Member state operations β€” separate management of active, signup-processing, dormant, suspended, withdrawn, and deleted members

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.

Member State Management Menus (Core v1.39.0)​

The member management Resource is registered only once, while the same list Page is exposed through six fixed routes. The hierarchy visibility query is built first and the state condition is intersected with it using AND, so URL parameters and table filters cannot expand the SaaS, Tenant, or Org boundary.

MenuRouteIncluded UserState values
All Members/membersactive
Signup Processing/members/signuppending_verification, pending_approval, rejected
Dormant Members/members/dormantdormant
Suspended Members/members/suspendedsuspended
Withdrawn Members/members/withdrawnwithdrawn
Deleted Members/members/deletedsoft_deleted

The table shows a state badge and the state-change timestamp. The three signup states can be filtered only on the Signup Processing page. Member creation is available only on the Active and Signup Processing pages and remains combined with the existing MemberResource::canCreate() permission. CSV exports reuse the current hierarchy scope and state bucket.

The Deleted Members page does not display or search the previous name and email, and the CSV output redacts those identifiers as well. It provides no record actions such as reactivation or erasure. If Core is deployed before a project has the users.state column, the existing list is treated as active and every other state page returns an empty result, preventing a missing-schema 500. This feature does not introduce a database schema change.

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.