Identity, Membership and SSO¶
Canonical person identities, account memberships, users, teams and invitations, edition overlays, enterprise sign-on persistence, session authentication assurance and membership offboarding.
Users, teams and edition overlays¶
Preloop (OSS) owns user, invitation and team CRUD in
preloop.api.endpoints.users, invitations and teams. Models, schemas and
CRUD remain in preloop.models. Core registers these routers once, including
the unauthenticated invitation inspection/acceptance routes. Blocking CRUD
handlers use FastAPI's synchronous thread-pool dispatch. User and team
management capability flags are present without commercial plugins, including
with DISABLE_PROPRIETARY_PLUGINS=true.
Core applies no access control between users of the same account: there are
no roles, no role assignment and no owner protection, and every active member
can do everything in the account. Without the RBAC plugin
preloop.utils.permissions.members_have_full_access() is true, so
user_holds_permission grants every permission, ensure_permission_in_oss
and the kill-switch toggle admit every member, and /auth/users/me reports a
null permission list. Explicitly disabling commercial plugins uses the same
boolean flag parser for loading and authorization. Missing optional RBAC packages
mean core; broken installed RBAC imports refuse startup, and failed commercial
profile permission lookups return an empty list rather than unrestricted access.
Endpoints keep their @require_permission decorators;
they are no-ops in core and enforced by the RBAC plugin. Tenant isolation is
not an access control between users and stays: controllers validate account
ownership before reading or mutating users, teams or invitations. Invitations
that request roles answer 402 upgrade_required in core, and roles stored on
an invitation by a commercial edition are not granted on acceptance. Bulk hard
deletion still skips the primary user of an account with other members,
because an account needs a primary user.
The role catalog route (GET /api/v1/roles) is registered only by the commercial
RBAC plugin. Core user/team responses retain the roles/inherited_roles fields
for API compatibility but return empty lists without its access-control service;
legacy invitation metadata likewise does not advertise commercial role grants.
Team membership labels remain descriptive and grant no account privileges.
preloop.services.multiuser resolves optional services through PluginManager:
multiuser_seat_gate, multiuser_billing_service, multiuser_membership and
multiuser_access_control. The last implements the AccessControl protocol
(validate_invitation_roles, apply_invitation_roles,
ensure_can_remove_member). Moved controllers import no proprietary packages.
The RBAC and account hierarchy plugins in Preloop Cloud and Preloop Enterprise
supply seat policies, subscription housekeeping, canonical membership linking
and all access control: roles and role assignment (/users/{id}/roles,
/teams/{id}/roles), owner protection, RBAC/ABAC and permission enforcement
on the core endpoints. They register only their additional routes, never a
second copy of a core route. Invitation context includes the invitee email so
the overlay can recognize an existing seat across its account tree.
Preloop Cloud and Preloop Enterprise also retain resource rules and sharing, team budgets, subaccount hierarchy, pooled billing, SSO/SAML/SCIM, fleet findings, multi-approver/escalation workflows, weekly governance review and advanced reporting. The audit hash chain, retention, legal holds and signed exports are in core. The console shows no role controls in core; role actions open the upgrade affordance. Per-user devices/runners/agents, basic approvals and basic usage views remain in core.
Canonical person identities¶
Person owns the chosen username, address and verification, shared name/avatar,
password, global suspension status and identity security generation. Existing
User.id values remain account membership IDs. Their account, role, team, API
key and historical actor bindings remain unchanged. Membership is_active
revokes one account's access; Person.identity_status = suspended suspends the
human globally. There is no transient User proxy and no account ID mutation.
Core crud_person resolves a membership's explicit Person link, returns a typed
IdentityProfile, updates canonical profile fields and resolves credentials.
PersonAuthIdentity owns login subjects in a (source, provider, subject)
namespace: provider must include issuer/tenant where subjects are not global.
The legacy schema did not capture LDAP/AD/SAML issuer; their migrated provider
namespace is legacy-account:<account_id>, pending an explicitly proven relink.
New and migrated OAuth login links use source oauth_<provider> with the
stored provider label and oauth_id (falling back to legacy external_id).
Initial identity creation registers that link atomically and preserves the
originating membership; explicitly linked memberships cannot claim new subjects.
OAuth integration grants remain account scoped and retain their account cascade;
they do not constitute the global login identity.
Passkey IDs, keys and counters stay unchanged. Their person_id owns access;
nullable user_id records originating membership provenance. Current legacy
passwords from already explicitly linked memberships are retained under the
same Person in PersonPasswordCredential; a nonprimary password differing from
the validated primary, a nonprimary passkey, or a differing nonprimary provider
subject quarantines the identity. These credentials previously authorized only
a membership, so granting them global identity access requires fresh ownership
proof. Preservation prevents data loss during operator repair and never recreates
a credential previously removed by consolidation. New explicitly linked
memberships cannot introduce identity credentials. A canonical password reset retires all retained hashes
and clears membership password caches. Security changes increment both identity
and membership token generations to support the existing JWT generation checks.
They also revoke opaque OAuth access/refresh tokens and CLI sessions across
linked memberships and discard pending OAuth authorization codes. Account API
keys retain their account-scoped semantics.
Deleting a membership, its account, the former primary membership, or the last
membership retains Person and login credentials. Explicit delete_identity
requires zero remaining memberships and destroys the identity-owned credentials.
A retained verified identity continues claiming its verified address.
The identity expand/backfill revisions are staged and additive. Drain credential
writers for both revisions, then deploy canonical authentication and profile
consumers together before resuming writes. A valid primary membership supplies
its chosen username verbatim; a missing/stale pointer falls back deterministically
by most recent login, creation time and ID. No suffix stripping or email merge
occurs. Conflicting linked addresses or provider claims quarantine the identities
with non-secret reasons; operators must establish ownership before repairing
canonical values. Unrelated provisional identities retain separate Person IDs.
An authorized administrator can then call crud_person.repair_conflict with a
fresh canonical profile/password, a non-secret ownership-evidence reference and
explicit acceptance of credential retirement. The operation clears quarantine,
stores the reference in migration_resolution, retires all disputed passwords,
passkeys and provider links, clears verification and revokes every linked session.
All other credential types require fresh enrollment. Ordinary status/profile/reset
operations cannot lift quarantine. The backfill precomputes username ownership
and writes profiles and credentials in batches of at most 1,000 records; rehearse
its write-blocking lock duration on a production-sized copy before rollout.
python -m preloop.models.identity_audit reports rollout counts and conflict
reasons without passwords, provider subjects or addresses. The CLI starts a
read-only database transaction and never initializes extensions. A SELECT only
database role adds operational enforcement. The old User profile columns
are temporary compatibility caches updated only by canonical operations; unique
membership usernames remain legacy aliases. Old password readers work only
before the first canonical password reset, which intentionally clears their
cache. These are rollout compatibility columns, not independent writable identity
stores. Arbitrary legacy writers must be retired at cutover; ORM changes to a
membership's verification flag no longer verify a Person. Credential backfill
cannot safely be reversed into membership cascades; downgrade refuses and rollback
uses a forward repair. Membership expansion policy and enterprise endpoints stay
in EE; core identity ownership and retention need no plugin registration.
Human authentication resolves the canonical Person in core, independently of enterprise login hooks. Account memberships retain their original user IDs, account-scoped roles and JWT generation checks. Password and passkey sign-in, JWT refresh, OAuth code exchange and opaque OAuth access also require an active Person, membership and account. API keys retain their existing account-bound ownership and revocation semantics.
Verification and recovery enumerate canonical identities by normalized email; explicitly linked provisional memberships receive one message with the chosen username. Equal-address unrelated identities remain separate. New links carry purpose, expiry, Person ID, expected email and identity generation. Consumption changes that generation; changed-address links and replay are refused. Old row-bound links resolve only their explicit Person link and remain usable only before an identity security change. Address-only links remain invalid.
Passkeys are owned by Person; their nullable user ID records registration provenance. They remain manageable across eligible accounts and after that membership is removed. Reset, password change and sign-out-everywhere invalidate sessions across every membership without changing tenant permissions. Removing one membership leaves canonical credentials and other memberships intact.
OAuth/SSO identities remain exempt from REQUIRE_EMAIL_VERIFICATION when a
canonical PersonAuthIdentity provider credential exists. The exemption follows
the Person across membership changes, never a mutable membership user_source
flag, and does not itself mark the canonical email as verified. Local identities
without a provider credential still require explicit email verification.
The hosted-model capability adds enabled, non-retired global models to the account model catalog and flow selectors, using each model's configured alias or provider/model identifier. No provider or model name defines built-in status. The model is read-only, and retired system rows remain available only for historical references. The catalog lists built-ins first; their Default badge appears only when the system row is default and the account has no configured default of the same service kind. OSS deployments do not expose built-ins by default. Built-in detail usage is scoped to the requesting account; operator credentials and routing metadata are never included in the public catalog. Preset cloning prefers a usable account model and otherwise binds the built-in. Hosted allowance and Free's one-time credit continue to be enforced by the gateway at execution.
Canonical identity presentation¶
A profile read carries person_id separately from id (the current account
membership) and account_id. Names, email verification and avatars come from the
canonical Person. Self profile and avatar edits update that Person from either
membership; an email change follows the canonical verification/conflict contract
and revokes every session. Username and recovery-email edits require an
authenticated login session; API keys may edit display names but cannot change
those identifiers through either OSS or EE profile routes. An unavailable
canonical self profile returns 401. Tenant membership administration changes account
access, not another person's profile or credentials.
The core identity directory searches canonical fields inside one account for member lists. Its platform queries paginate people and expand explicit memberships, preserving historical event membership IDs and account attribution. A quarantined migration identity displays an unavailable state without exposing a generated membership alias or relaxing authentication. Account seat counts continue to count active membership rows. No presentation migration is required; apply the identity schema/backfill and authentication changes first.
Enterprise authentication persistence boundary¶
preloop.models.crud.sso_login supplies account-scoped identity lookup, serialized
provider-subject enrollment, membership persistence and expiring administrator
proof lookup for enterprise protocols. Verified IdP email only resolves an existing
destination-account member whose canonical address is already verified; matching another account's email cannot establish
identity. Known provider subjects can identify a person independently of email.
The caller owns seat admission and authorization; these core helpers assign no
roles and create no accounts. Connection updates advance their revision even
when a write-only secret retains the same secret-reference id. Protocol handling,
role policy and token issuance remain in the enterprise SSO plugin.
SSO grant provenance¶
crud.sso_group_grants reconciles complete OIDC, SAML and SCIM snapshots without
committing. Each connection/source claim references a physical role or team
assignment. Existing assignments default to manually_granted=true; only rows
created by the mapper are eligible for automatic removal after their final claim
disappears. Manual replacement and explicit team edits remain manual. Connection
deletion revokes its claims before cascading configuration, retaining assignments
claimed by other sources. Account hierarchy access_grant_id stays independent.
The CRUD boundary validates tenant references and rejects owner authority;
protocol adapters and role selection remain in the enterprise plugin.
Session authentication assurance¶
Core carries a bounded authentication context through browser JWTs, account switch
extensions, CLI consent/exchange and opaque MCP OAuth access/refresh records. The
optional authentication_policy plugin service can enforce account methods at
issuance, access and refresh. A membership-local non_sso_auth_generation is separate
from canonical person/session revocation; all session types still honor ordinary
auth_generation. Original authentication account/generation remain distinct from
the selected account to prevent switches from upgrading or reviving assurance.
The schema also holds durable SSO owner notices; delivery and enforcement stay in EE.
SAML persistence remains edition-neutral: AccountSsoConnection stores the
public SP certificate paired with its SecretReference, and replay CRUD atomically
consumes expiring pending SAML requests. Protocol implementation and certificate
creation stay in EE. The shared identity CRUD accepts explicit OIDC/SAML sources
while preserving canonical verified-email linkage, account membership boundaries
and provider-link locking. The optional ee dependency extra supplies the SAML
XML toolkit; core modules do not import it.
Fresh password and passkey authentication can select an explicitly requested, already-held active membership after credential verification. Passkey challenge and completion bind the same optional account UUID. This makes owner recovery reachable after a different account was last active without treating an existing session switch as fresh credential proof. Unknown and unavailable destinations share one refusal.
Email verification uses explicit email_link assurance and onboarding uses proven
password assurance before auto-login. Chat-derived requests carry chat assurance
and pass the same policy gate before internal bearer dispatch. MCP browser redirect
codes retain the original session assurance and recheck policy when redeemed;
their purpose restriction prevents use as general person sessions. Recovery owner
selection requires the global system owner role, and verified domain comparisons
normalize both stored claims and email domains to IDNA.
Optional enterprise single sign-on settings¶
Account settings includes a Single sign-on tab. The Lit console checks the
enterprise_sso feature and the caller's account administration permission
before loading account-scoped provider, domain, policy, mapping or SCIM-token
configuration. Without the feature it offers the existing upgrade action.
Submitted client secrets and signing keys are cleared immediately and never
repopulated from configuration reads; SCIM tokens appear once until dismissed.
Email-first login is enabled only when the feature is advertised. Discovery can offer a local SSO start URL or return the user to existing sign-in methods. Provider callback/metadata URLs and test-sign-in availability come from the server. Enforcement controls require a current configuration test completed by the signed-in administrator within 24 hours, a verified domain and an enabled connection; missing support is explained in the UI. The API remains responsible for the recent administrator test requirement and authentication enforcement.
Test sign-in obtains a one-use start URL through an authenticated POST. SAML metadata imports are previewed before applying them to the unsaved editor; only public SP certificates are displayed. A refused account switch can request fresh password/passkey authentication bound to that account, preserving the existing session until credential proof succeeds. All owners retain emergency access, which the enterprise plugin audits and notifies.
Public SSO starts require the configured console/backend Origin or Referer before minting state. The validated local login link explicitly preserves the browser referrer rather than inheriting Shoelace's noreferrer default; originless shared URLs remain refused.
Membership offboarding and machine credential ownership¶
Console removal and SCIM provisioning share services.membership_lifecycle.
Offboarding locks the account, Person and target membership, marks only that
membership inactive and increments only its token generation. In the same
transaction it revokes personal API/runtime keys, CLI and MCP OAuth grants,
ends their runtime sessions, removes team memberships and registered runners,
and records access.revoked with actual affected-row counts. Repeated removal
is a no-op. Reactivation checks canonical identity and seat availability without
restoring credentials. Credential issuance serializes with the same lifecycle
boundary and refuses an inactive membership. SCIM uses commit=False so its
provisioning record and revocation commit or roll back together.
Account-owned CI principals and durable managed-agent credentials retain their
keys. ApiKey.user_id is nullable creator attribution with ON DELETE SET NULL;
account deletion still cascades its credentials. A durable credential requires
an active account, agent, key and matching persisted credential binding. Its
runtime principal contains the agent ID separately from creator attribution
and has no human membership ID. Gateway, native permissions, control, desktop
and MCP adapters use that machine identity; ordinary human/admin REST
authentication rejects it. App configuration acknowledgment is a dedicated
self-credential endpoint. Runtime policy checks never inherit the creator's
human permissions. Migration 20261117_membership_offboarding refuses downgrade
while credentials with deleted creators remain, preserving those credentials.