Motivation
A team is a tenant: atenant_id slug that scopes
every Eloquent query (R30/R31). The team switcher lets a user
move between the teams they already belong to, and reads each team’s display
name from the tenants registry row. There are now two deliberately
different administration surfaces:
- Administration → Teams is tenant-local and available to
adminandsuper-admin. It creates a team for the acting administrator and renames teams they may administer. - System administration → Tenant control is global and visible only with
platform.admin(the protectedsystem-adminrole). It lists every registry tenant (including suspended/archived), expands one tenant’s users and access, updates lifecycle state, and provisions a tenant + initial project + new/existing user in one operation.
What a “team” is here
There is no hostTeam model and no host teams table. A team is the
tenant_id string stamped on every tenant-aware row; its editable display
name lives on an optional vendor row — the tenants table shipped by
padosoft/laravel-ai-act-compliance (Padosoft\AiActCompliance\MultiTenancy\Models\Tenant).
That is the very table UserTeamsResolver reads for switcher
labels, so team management writes to exactly the source the switcher displays.
The default slug is retained only as a reserved legacy storage fallback. It
is not a company and never appears in the switcher or tenant-local
administration, even if stale project_memberships rows reference it. It is
never creatable or renamable.
The separately reserved system-registration slug is a technical invitation
namespace (tenants.is_system=true), not a company. It cannot be selected,
created or renamed from tenant administration and is excluded from the global
operational tenant inventory.
Design
The piecesData model
The team’s editable name is thename column of the vendor tenants table:
Creating a team also writes a
projects row (tenant_id=slug, an initial
project_key) and a project_memberships row for the acting user — the same
ordering company:create uses, so the new team is immediately usable and appears
in the creator’s switcher.
Account identity remains global rather than tenant-scoped. users.email is
stored lower-cased and mirrored into the database-unique email_normalized
column, including soft-deleted accounts. This makes Owner@Acme.it and
owner@acme.it the same identity under PostgreSQL as well as SQLite/MySQL and
closes the concurrent duplicate-creation race.
System-admin tenant control
Open System administration → Tenant control while signed in with the protectedsystem-admin role. The UI keys visibility from
features.system_admin; the API remains authoritative with
can:platform.admin. A tenant super-admin without that permission receives
403 and cannot enumerate the registry.
Registry and detail
GET /api/system-admin/tenants is paginated (page, per_page, maximum 100)
and supports search plus status=active|suspended|archived. Each row includes
project and distinct-user counts. Selecting a tenant calls
GET /api/system-admin/tenants/{slug} and returns a paginated user list with:
- global Spatie roles and all effective permissions;
- active, inactive or soft-deleted account state;
- whether the user has all-project access under the current isolation mode;
- every membership in the selected tenant, with project key, membership role and scope allowlist.
X-Tenant-Id to /api/system-admin/*; every join into projects/memberships is
instead constrained explicitly by the target tenant slug.
One-step provisioning
The Provision tenant dialog performs a debounced preflight againstPOST /api/system-admin/tenants/availability with company, slug, email and
requested role as soon as the fields are valid:
- a taken slug disables submission;
- a new email reveals name + generated temporary password;
- an existing active email switches automatically to associate existing, hides the password field and promises not to change the current password;
- inactive and soft-deleted identities are blocked with an instruction to activate/restore them first.
POST /api/system-admin/tenants then commits the tenant row, initial project,
identity (when new), selected global role and target-tenant membership in a
single transaction. Supported initial roles are tenant super-admin, admin,
editor and viewer; system-admin is always rejected.
After a new-account success the UI keeps the temporary password visible and can
copy the complete handoff package. Passwords are never returned by the API or
written to logs.
An existing identity is never silently promoted. Its global effective tenant
role must already be at least the requested level
(super-admin > admin > editor > viewer), otherwise the API returns
422 role_global_mismatch and writes no tenant, project or membership. This is
necessary because Spatie roles apply to the identity across all memberships.
Lifecycle
PATCH /api/system-admin/tenants/{slug} changes the display name and/or lifecycle
status. suspended returns HTTP 423 and archived returns HTTP 410 on
tenant-scoped requests even when a stale membership still exists. Both states
disappear from the team switcher. Because the global
control plane is not tenant-scoped, a system administrator can always return
the tenant to active.
Every status transition first calls
POST /api/system-admin/tenants/{slug}/lifecycle-preview. The returned token is
stored only as a hash, expires after five minutes, is single-use, and is bound to
the actor, tenant and exact from → to transition. PATCH consumes it under
lockForUpdate; replay or state drift returns 422. Rename does not require a
lifecycle token. Successful provisioning and updates commit their
admin_command_audit completion atomically with the mutation.
Decision rationale (ADR-style)
- Separate platform scope from tenant privilege.
system-adminowns onlyplatform.admin;super-adminowns the maximum tenant permission set, and both roles need membership before they may use tenant routes. A system administrator also holds companionsuper-adminso existing tenant route allowlists remain stable once a membership exists. ADR 0023 records the migration and rejected alternatives. - Build over the vendor registry, not a new host table. The switcher already
depends on
tenantsfor names; a second host-owned name table would fork the source of truth. When the table is absent the feature degrades (below) rather than duplicating it. Thetenantstable is a registry, modelled likeUser— it is intentionally not tenant-aware and is excluded fromTenantIdMandatoryTest(R30/R31), because itsslugis the tenant id every other table references. - Rename authorizes the target team by membership. The request’s active
tenant does not prove access to another target. The service independently
requires a membership in the target team, matching the rule
AuthorizeTenantHeaderenforces on every tenant route. A team you may not administer returns 404 (IDOR-safe: existence stays hidden). System administrators manage non-member tenants only through the global control plane. - No MCP write tool — a deliberate R44 exception. Like the
project registry, team management is an admin
governance affordance, not an agent capability: a
tenant_idis already usable as a stored legacy slug without a registry row, and a global write tool conflicts with the propose-only MCP posture. It ships HTTP + CLI only; the omission is documented in the controller and here rather than left implicit. defaultis reserved and non-operational. It may remain in legacy stored rows, but it never authorizes a dashboard or suppresses company onboarding. Renaming or re-creating it is rejected.
Worked example
Anadmin who is a member of acme opens Administration → Teams:
GET /api/admin/teamsreturns only active teams represented by the identity’s memberships.defaultappears only when such a membership exists. System administrators also see only their operational memberships here; the global tenant roster lives under/api/system-admin/tenants.- Create → name
Globex Corp. The slug auto-fills toglobex-corp(editable, immutable after).POST /api/admin/teamswrites thetenantsrow + an initialglobex-corpproject + a membership for the admin, atomically. - The page refetches
/api/auth/me; the topbar switcher now lists Globex Corp, switchable immediately. - Rename
Globex Corp→Globex Corporation:PATCH /api/admin/teams/globex-corpupdatestenants.name; the switcher label updates without a reload. - A rename of a team the admin does not belong to → the API answers 404, and the row exposes no Rename action in the first place.
Gotchas
- The registry table is optional — the feature degrades, it never 500s. When
Schema::hasTable('tenants')is false (the AI-Act package was never migrated), create/rename raiseTeamRegistryUnavailableException, which the controller maps to a clean 503 witherror: team_registry_unavailable(R14/R43). The list still renders with humanised slug names. - The slug is immutable. It is the
tenant_idevery document, chunk, membership and chat log joins to — renaming changes only the displayname. To “rename the id”, create a new team and migrate content. - Every identity sees only membership teams on tenant surfaces. This includes system administrators. Create therefore attaches the acting user as a member; global non-member administration belongs exclusively to the system control plane.
- Inactive users cannot authenticate. Both session login and bearer-token
issuance require
users.is_active=true; disabling an account is now an effective access-control action rather than a display-only flag. - Soft-deleted emails remain reserved. Restore the account instead of creating a second identity. This matches the database uniqueness contract and prevents hidden duplicate owners.