Unified identity
Unified identity
A customer's people are ordinary subjects in the platform-root environment
(is_default — "tenant 1"), and the customer itself is an organization in that
environment.
This page is a design record, and its story finished. It was written while the account plane still existed and was being re-pointed at the subject stack — so it argues for a change, names what it deliberately kept, and lists what had not moved yet. All of it has moved: the account row, its member table, its role enum and its session are gone, and a customer is an organization whose people hold memberships of it. What remains worth reading is WHY, because the same argument is the reason not to grow a second credential store again. Where the text below says a thing "stays" or "does not go away", read it as what was true at the time; the current shape is customers, projects & the platform plane in the framework.
The problem this solves
The platform authenticated people two different ways.
| Tenant plane | Account plane (as it was) | |
|---|---|---|
| Identity | Identity\Models\User (subject) |
Platform\Models\AccountMember |
| Session bridge | PlatformAuth |
AccountAuth — a full parallel implementation (later: no session of its own) |
| Sign-in | identifier-first, home-realm discovery | flat email + password |
| SSO | Federation\Models\Connection |
none |
| Passkeys, MFA, magic links | yes | none |
Password policy (AuthPolicies) |
enforced | not enforced |
| Administrative password reset | yes | partial |
Everything in the right-hand column that says "none" is a feature the left-hand column
already has. Adding SSO to the management plane under the old model meant building
account-scoped connections, federated-identity linkage for AccountMember, and
home-realm discovery over account domains — and then doing the same again for passkeys,
MFA and the password policy. The identity stack would exist twice.
Self-hosted deployments already demonstrated the simpler shape: with no
environments.base_domains configured there is no account door at all. One host,
subject authentication only. The dual path existed solely to serve multi-account SaaS.
The model
Operator plane platform operators (unchanged — sits above account)
│
Platform-root environment ("tenant 1", is_default)
│
├── Organization "Acme" ← an ACCOUNT
│ └── Members ← former AccountMembers, now ordinary subjects
│
└── Organization "Rival" ← another ACCOUNT
- Account — unchanged in purpose: owns projects, environment allowance, plan, billing. Now paired 1:1 with an organization in tenant 1, which is where its people live.
- Account member — a subject in tenant 1 with a membership in the account's organization. Authenticates through the ordinary subject door.
- Account role — retained as a typed capability enum on the membership
(
AccountRole: owner / admin / developer / viewer / billing). It gates five capabilities across seven call sites; keeping it typed is less churn than dissolving it into RBAC, and keepscanManageEnvironments()and friends exhaustive. - Environment access (
all_environments, per-environment grants) — resource access scoped to the account's organization.
What this buys
- Account-layer SSO is an ordinary
Connectionon the account's organization. No new framework code. - Passkeys, MFA, magic links, the password policy and administrative password assignment all apply to account members automatically.
- One sign-in door. Identifier-first, then password or an SSO redirect, for everyone.
- Self-hosted and SaaS converge. Self-hosted becomes "tenant 1 is the only tenant" rather than a separate shape with a plane switched off.
- The IdP authenticates its own operators with its own product, so its own sign-in path is exercised continuously.
The risk, stated plainly
EnvironmentAdminAuth documents the boundary this removes:
the tenant admin is an account-layer identity, NOT a subject inside the environment
Making account members subjects widens the blast radius of a tenant-1 compromise: the control plane's people now authenticate through an environment's auth configuration.
Two things make that acceptable rather than merely tolerable:
- The bridge already exists.
EnvironmentAdminAuthalready carries account identity into environment administration through a platform-signed handoff. The separation was never absolute — it was a separation of credential store, not of authority. - Policy cuts the right way. Tenant 1 can hold a stricter authentication policy than any customer tenant — MFA required, SSO required — and the policy engine's tighten-only semantics mean no organization override can weaken it.
What must not regress: the handoff's watertight anti-bleed anchor. A session names exactly one environment id and it is re-checked against the request's host-resolved environment on every resolve, so a session minted for one environment administers nowhere else — not even another environment the same person is entitled to administer, because each one is authorized by its own one-time handoff. That property is independent of where the identity comes from, and it survived the collapse of the admin session into the subject session precisely because it is not an identity: it is a selection, of the same kind as the console's organization picker and an operator's target environment.
Decisions taken
- One organization per account, rather than modelling every account member directly
in tenant 1 without an organization. Keeps
Accountmeaningful, gives a natural home for account SSO connections and domain verification, and maps 1:1 so there is no ambiguity about which account a member belongs to. AccountRolestays a typed enum on the membership rather than becoming RBAC roles. Five capabilities, seven call sites — the churn of dissolving it is not repaid. (It did not survive: with one row there is one role vocabulary,MembershipRole.)- Clean cut, no data migration. There are no external consumers, so the platform-root environment and its accounts are rebuilt rather than migrated. This is the one decision that stops being available the moment someone else runs this software.
Consequences
-
AccountAuthstops being a credential store and, in a second pass, stops being a session at all. The password is verified against the member's subject; the SSO mandate isPlatformAuth::localSignInAllowedFor(), the same method the tenant door calls; an administratively-issued temporary password expires on both doors alike.The account session was kept distinct at first, on the reasoning that the account host must not mint a credential for a plane it does not serve. That reasoning was about the HOST, and the host bulkheads (
plane:console/plane:issuer) are what enforce it — so the distinct session bought nothing and cost every seam between the two stores: an operator with no membership could not sign in at all, a gate that asked the wrong store looped silently, and three separate places had to be asked "who is this?". There is one session.AccountAuthwas the management plane's view of it, and membership a lookup off it — and once the view was all that remained, it was deleted too:CurrentUseralready answered who is acting, and the middleware already refused an organization the subject is not a member of. -
The same collapse reaches
EnvironmentAdminAuth. It held the administering subject's id under a key of its own, which was the same duplication; an admin session is now the ordinary subject session plus the environment anchor above. A consequence worth having: revoking a person's sessions — which a password reset now does — ends their admin console too, which a session assembled out of a raw id never could. -
The plane split stops being about which credential store and becomes purely about which host serves which surface.
PlaneResolveranswers each of those questions once, for both the route gate and the post-authentication landings, so the two can never disagree about where a login is allowed to land. There are four such questions, not one: the management plane (plane:console, the root alone), the console (plane:console, every host — the root is a tenant and its subjects sign in there), the issuer surface (plane:issuer, never the root) and the environment-admin door (plane:environment, never the root). The console and the issuer surface were a single plane namedsubjectfor a while, which is why the root had no/loginat all. -
AccountProvisionercreates the account's organization in tenant 1 alongside the account, and the install flow bootstraps tenant 1 and the platform organization before the first account exists. -
The workspace sign-in becomes the subject sign-in, so it inherits identifier-first discovery, SSO, passkeys and magic links with no additional work.
What is still on the member, not the subject
Two things deliberately did NOT move in the cutover, because moving them would have weakened something while the rest was being unified:
- Account-plane MFA and passkeys (
AccountMemberMfa,AccountPasskeys) still hang off the account member. Routing the workspace login at subject MFA instead would have orphaned every factor a member had already enrolled — a member with TOTP would stop being challenged for it. The account factor is therefore still enforced, and it is enforced on the NEW ways in too: SSO and magic-link landings hold a member with a confirmed factor at the challenge rather than treating a strong first factor as permission to skip the second one. Folding these onto the subject is a later batch, and it is a migration, not a switch. - The member's own password column. It is still written, but it is read in exactly one situation: a member created before the deployment had a platform root, where there was nowhere for the subject to live. Everything else asks the subject.