Managed Principals and Manager Relationships¶
This page describes managed principals as implemented in
identity/managedprincipal, identity/credential and SystemAuth
(identity/systemauth): what makes a principal managed, the invariants the
service enforces, the built-in manager roles, the login_token credential
and its exchange, the claims a managed session carries, what managers can
see and revoke, how an email is attached, how management ends (release),
how a self-managed human consents to be managed (manager invitations), and
how a managed principal's slug is chosen. The user-facing guide is
Managed Principals.
Decisions: ADR-005 (managed principals and manager relationships), ADR-009 (management is a relationship, not the absence of an email; amends ADR-005), ADR-006 (how a managed principal's slug is chosen), ADR-010 (access changes recorded when management starts or ends).
Definition as implemented¶
A managed principal is a Principal(type=human) with at least one active
row in sf_manager_relationships as the managed side (ADR-009). The email
plays no part: a managed principal may have none, an unverified one, or a
verified one. That is the test everywhere in the code:
| Check | Where |
|---|---|
Service.IsManaged(principalID) |
An active manager relationship exists (hasActiveManager) |
Manager authority checks (authorizeCredentials, authorizeAccount, checkIssuer) |
Require the target to be an active human with an active manager relationship before consulting the authorizer; otherwise ErrNotManaged |
Login-token exchange (verifyLoginToken) |
Requires the principal to be active and HumanPrincipal.Managed; an email does not matter |
managed claim |
Emitted when the session's principal has an active manager relationship (HumanPrincipal.Managed, loaded with the principal) |
A managed principal is created with principal.ManagedHumanCapabilities()
(CanAccessUI only: CanManageProfile and CanDelegate are false) and
sf_principals.identifier = "human:<principal-id>", a lookup key rather
than an address. signup.validateEmail requires a plain address, so a
managed identifier can never be looked up as an email.
Enabling¶
Managed principals exist only when both gates are open:
| Gate | Setting |
|---|---|
| Deployment | systemauth config managed_principals.enabled (ManagedPrincipalsConfig.Enabled), default off |
| Client | sf_oauth_apps.managed_principals_enabled or config clients[].managed_principals, default off |
systemauth.Server.ManagedPrincipalsAllowed(ctx, clientID) evaluates both
and is the managedprincipal.Gate the service consults; Server.LoginTokenPolicy(ctx, clientID) supplies lifetimes (below).
Tables¶
sf_manager_relationships (identity/ent/schema/manager_relationship.go)¶
| Column | Notes |
|---|---|
managed_principal_id |
The principal being managed; immutable |
manager_principal_id |
The manager; immutable |
role |
guardian, educator, or a role registered with managedprincipal.WithRoles; immutable |
active |
Removal deactivates rather than deletes; re-adding reactivates |
created_by |
The actor that established the relationship |
Unique on (managed_principal_id, manager_principal_id, role).
sf_manager_claims (identity/ent/schema/manager_claim.go)¶
A single-use code with which a human with an email becomes guardian of a
managed principal. Kept apart from sf_credentials so a claim code can
never sign anyone in.
| Column | Notes |
|---|---|
managed_principal_id |
The principal the claim grants guardianship of |
code_prefix |
SFMC plus 8 characters, unique, used to look the claim up |
code_hash |
SHA-256 of the full code (SFMC + 160 random bits, base32) |
issued_by, expires_at |
Issuer and expiry (DefaultClaimTTL 7 days, WithClaimTTL) |
redeemed_at, redeemed_by |
Set once; a redeemed or expired claim is refused |
sf_manager_invites (identity/ent/schema/manager_invite.go)¶
A single-use invitation with which a human with a verified email offers to
manage a self-managed human. Kept apart from organization invites
(sf_invites), which grant membership rather than authority over a person.
| Column | Notes |
|---|---|
manager_principal_id |
The inviter, who becomes a manager on acceptance |
email |
Verified address of the invited human; only a principal whose verified email matches (case-insensitive) can accept |
role |
The manager role the inviter will hold |
token_prefix, token_hash |
SFMI plus 8 characters, unique, used to look it up; SHA-256 of the full token (SFMI + 160 random bits, base32) |
expires_at |
DefaultInviteTTL 7 days, WithInviteTTL |
accepted_at, accepted_by, revoked_at, declined_at, declined_by |
Set once; an accepted, revoked, declined or expired invitation is refused |
sf_credentials type login_token (identity/ent/schema/credential.go)¶
The manager-issued sign-in secret is a credential like any other:
identifier holds the displayable prefix (SFLT plus 8 characters),
secret_hash the SHA-256 of the token (SFLT + 32 random bytes, base32),
metadata records issued_by and single_use, and expires_at,
active, revoked, revoked_at, revoked_reason, last_used_at and
last_used_ip give it the standard lifecycle.
Invariants enforced by managedprincipal.Service¶
| # | Invariant | Enforcement |
|---|---|---|
| 1 | At least one active manager unless the principal has a verified email | Create refuses len(Managers) == 0 (ErrNoManagers); RemoveManager refuses to deactivate the last active relationship of a principal whose email is missing or unverified (ErrLastManager). lockHuman takes a row lock on the principal for the transaction so concurrent removals serialize |
| 2 | A manager is an active human with an email | validateManager requires type = human, active, email IS NOT NULL; a managed principal, having no email, cannot manage. A principal cannot manage itself |
| 3 | Both the deployment and the client allow managed principals | Create and IssueLoginToken call Gate.ManagedPrincipalsAllowed(clientID) and return ErrNotAllowed otherwise |
| 4 | Email is independent of management, but decides whether management may end | A managed principal may have an email from Create or AttachVerifiedEmail and keeps its managers and managed capabilities. Once the email is verified (VerifyEmail, or attached verified), RemoveManager may remove the last manager; with no active manager the principal is no longer managed and login tokens stop signing it in |
| 5 | A principal with no manager cannot be given one by a manager | AddManager and RedeemClaim return ErrNotManaged for a principal with no active manager: entering management takes the principal's own consent (AcceptManagerInvite, below) |
| 6 | A managed principal is never a manager | validateManager also requires the candidate to have no active manager of its own, whether or not it has an email; accepting an invitation is refused with ErrManagesOthers for a principal that manages others |
Roles are checked against the registered set (ErrInvalidRole); the
built-ins are managedprincipal.RoleGuardian and RoleEducator.
Manager roles and permissions¶
The platform permissions a manager holds over a principal are defined in
authz (authz/manager.go) and in the base SpiceDB schema
(definition principal):
| Permission | guardian |
educator |
Covers |
|---|---|---|---|
manage_account |
yes | no | Consent, deletion, billing; attaching an email |
manage_credentials |
yes | yes | Issuing claims; listing and revoking login tokens and sessions |
view_profile |
yes | yes | Reading the profile |
manage_managers |
yes | no | Adding and removing other managers |
authz.ManagerRolePermissions and ManagerRoleAllows mirror the schema;
the parity test keeps them equal. The role-based provider evaluates a check
on authz.NewPrincipalResource(managedID) through
rbac.WithManagerRoleGetter, for which Service.ManagerRoles(managerID,
managedID) is the getter; the service's default authorizer is exactly
rbac.New(rbac.WithManagerRoleGetter(svc.ManagerRoles)), replaceable with
WithAuthorizer. The relationship-based provider reads the guardian and
educator relations that Syncer.AddPrincipalManager writes.
Which service operations consult the authorizer:
| Operation | Permission checked |
|---|---|
IssueClaim, ListLoginTokens, RevokeLoginToken, ListSessions, RevokeSession, RevokeAll |
manage_credentials |
AttachVerifiedEmail, Delete |
manage_account |
AddManager, RemoveManager, Release |
manage_managers |
ListManagersFor |
view_profile |
IssueLoginToken |
None through the authorizer: checkIssuer requires the issuer to be an active manager of the principal, in any role |
Create |
No principal exists to check a permission against, so the actor must be one of the managers being named (the manager creating the principal) or a platform administrator (authz.PlatformAuthorizer.IsPlatformAdmin); otherwise ErrForbidden. A missing actor is refused |
RedeemClaim |
The claim carries its issuer's authority: redemption is refused (ErrInvalidClaim) unless the issuer still holds manage_credentials on the principal, so removing a manager withdraws the claims they issued. The claimant must be a valid manager |
InviteManager |
The inviter must be a valid manager and the client must allow managed principals; consent is given by the invitee in AcceptManagerInvite, which checks the principal's own verified email, not a permission |
The service accepts WithRoles for application-defined manager roles, but
authz.ManagerRolePermissions grants nothing to them in the role-based
provider; an application maps them in its own vocabulary.
Creating and maintaining managers¶
| Method | Behavior |
|---|---|
Create(in CreateInput) |
Requires DisplayName, at least one ManagerInput{PrincipalID, Role}, a client that allows managed principals. In one transaction validates each manager, creates sf_principals (identifier human:<id>, managed capabilities), sf_humans, and the relationships. CreateInput.Email is optional: when set it is stored unverified, becomes the identifier, and ErrEmailInUse refuses a taken address. Emits managed_principal.created, then syncs RegisterPrincipal and AddPrincipalManager for each manager |
AddManager(managedID, m, actorID) |
Needs manage_managers, checked under the principal's row lock in the writing transaction. Idempotent: no-op when active, reactivates when inactive; emits managed_principal.manager_added; syncs |
RemoveManager(managedID, m, actorID) |
Needs manage_managers, checked under the principal's row lock in the writing transaction. Deactivates; refuses the last manager of a principal without a verified email (ErrLastManager); emits managed_principal.manager_removed; syncs |
ListManagers(managedID) |
Active relationships, oldest first; unchecked, for callers that already hold authority. ListManagersFor(actorID, managedID) checks view_profile |
Delete(actorID, managedID) |
Needs manage_account. Soft delete with erasure in one transaction: the principal is deactivated, its identifier becomes deleted:<id> and its display name "Deleted account"; email, verification, names and avatar are cleared (freeing the address); credentials, external identities, login sessions, OAuth consents and claims are deleted; OAuth and principal token families are revoked; memberships and manager relationships are deactivated; pending manager invitations to its address are withdrawn; an access change deactivated is recorded and announced (ADR-010); the authorization backend loses its relations and the principal. managed_principal.deleted. The row stays so identifiers elsewhere keep resolving; application data and any personal organization are the application's to remove |
Release(actorID, managedID) |
Ends management of a principal with a verified email: deactivates every remaining relationship, restores DefaultCapabilitiesForType(human) and records an access change (management_ended), in one transaction. Needs manage_managers, checked under the principal's row lock; ErrEmailNotVerified without a verified email; managed_principal.released; syncs each removal. RemoveManager of the last manager of a verified principal does the same |
InviteManager(in) |
A manager-to-be offers to manage the human with a verified Email. Needs a client that allows managed principals and a valid manager; returns the token once; managed_principal.manager_invited |
AcceptManagerInvite(token, principalID) |
The signed-in principal's consent. Spends the invitation conditionally, creates the relationship, sets ManagedHumanCapabilities, records an access change (management_granted), managed_principal.manager_invite_accepted, syncs. ErrInvalidInvite for any mismatch, ErrAlreadyManaged, ErrManagesOthers |
DeclineManagerInvite(token, principalID) |
The invitee's refusal, with the same identity match as acceptance (an active principal whose verified email is the invited address; ErrInvalidInvite for any mismatch, unknown, expired, revoked, accepted or already declined token). Single use: it marks the invitation declined (declined_at, declined_by) conditionally, so a concurrent acceptance or decline wins exactly once, and a declined invitation can be neither accepted nor revoked. Touches no relationship, capability or access change. managed_principal.manager_invite_declined, whose details carry the inviter and the invitation, is the inviter's notification: the service sends none itself, so the application delivers it |
RevokeManagerInvite(actorID, inviteID) |
The inviter withdraws a pending invitation; ErrNotFound otherwise, including after the invitee accepted or declined |
VerifyEmail(managedID, verifiedAt) |
Records that the principal controls the email it was created with; called by the verification flow, not by a manager. managed_principal.email_verified |
IssueClaim(actorID, managedID) |
Returns the plain code once; managed_principal.claim_issued |
RedeemClaim(code, claimantID) |
Normalizes the code (case, spaces, hyphens), compares the hash in constant time, spends the claim with a conditional update so two redemptions cannot both succeed, validates the claimant as a manager, and makes them guardian; managed_principal.claim_redeemed; syncs |
Audit events go through contract/audit.Emitter when
WithAuditEmitter is set; authorization sync follows authz.SyncMode
(WithAuthzSyncer): a sync failure after the commit is returned in
strict mode and logged in eventual mode.
Sign-in: the login_token credential¶
| Policy | Default | Source |
|---|---|---|
| Reusable token lifetime | 30 days (credential.DefaultLoginTokenTTL) |
managed_principals.login_token_ttl; a client's login_token_ttl (sf_oauth_apps.login_token_ttl) overrides it |
| Ceiling | 400 days (DefaultLoginTokenMaxTTL) |
managed_principals.login_token_max_ttl; longer requests are clamped and logged |
| Single-use lifetime | 20 minutes (DefaultSingleUseLoginTokenTTL) |
managed_principals.single_use_token_ttl |
Service.IssueLoginToken(in) checks the client gate, requires the issuer to
be an active manager with an email and the subject to be an active managed
principal, resolves the expiry with credential.LoginTokenPolicy.ExpiresAt,
and calls credential.Service.IssueLoginToken, which stores the hashed
credential and returns the plain token once
(credential.GeneratedLoginToken). The event
managed_principal.login_token_issued records the prefix, expiry and
whether the lifetime was clamped. Tokens use uppercase letters and digits
so a QR code can encode them in alphanumeric mode; how an application
renders them is its own choice.
credential.Service.VerifyLoginToken(plain, ip) looks the token up by
prefix, compares the hash in constant time, requires it active, unrevoked
and unexpired, records last_used_at and last_used_ip, and revokes a
single-use token in the same conditional update (revoked_reason = "used").
Every failure is credential.ErrInvalidLoginToken.
Exchange endpoint¶
SystemAuth mounts POST /login-token (systemauth.LoginTokenPath) when
managed principals are enabled and a credential.Service is available
(WithCredentialService, or the Ent storage's database). It takes form
fields token, client_id and optional return_to, and:
- rate-limits per source address (
DefaultLoginTokenAddressLimit, 60 per minute) and per registered client (DefaultLoginTokenClientLimit, 600 per minute; unregistered client IDs share one bucket), withWithLoginTokenRateLimitandWithLoginTokenClientIPfor shared storage and proxy-aware addressing; - resolves
return_toagainst the redirect allowlist; - requires the client to allow managed principals, verifies the token, and requires the principal to be active and managed (an email does not matter);
- establishes a browser login session (
sf_login_sessionswithprovider = login_token) and redirects.
Every rejection is the same 401 invalid login token; the response never
says which condition failed.
Claims¶
GetUserClaims adds "managed": true for a session whose principal has an
active manager, whatever scopes were granted; email and email_verified
appear only when the principal has an email and the email scope is
granted. The claim appears in the ID token and at
/oauth/userinfo (systemauth.ManagedClaim). The relying-party layer reads
it into relyingparty.VerifiedIdentity.Managed from either source
(IdentityFromClaims), and ResolvePrincipal creates a managed principal
locally, with the email when the claims carry one but never linking by
email; the BFF reports
managed on the current user.
Sessions and manager visibility¶
Service.ListSessions(actorID, managedID) returns every way the principal
is signed in now, as managedprincipal.Session{ID, Kind, Provider,
ClientID, CreatedAt, ExpiresAt, LastUsedAt, ClientIP}:
| Kind | Source | Revocation |
|---|---|---|
login |
sf_login_sessions (browser sessions, provider such as login_token) |
Row deleted |
oauth |
sf_oauth_tokens, one entry per token family |
Family marked revoked (revoked_reason = "revoked by manager"), so its refresh token cannot renew it |
principal_token |
sf_principal_tokens, one entry per family |
Family marked revoked |
ListLoginTokens returns live login tokens without their values;
RevokeLoginToken revokes one; RevokeSession(kind, id) ends one session;
RevokeAll revokes every login token and ends every session in one
transaction. Each emits an audit event (login_token_revoked,
session_revoked, all_revoked). managedprincipal.NewHandler(svc)
exposes these and the managing operations as a chi router to mount behind
requestprincipal.Middleware, which identifies the calling principal. The
caller is always the actor; every route runs the service operation and so
its authorizer check:
GET / ListManagedBy (?after=&limit=, at most 200)
POST / Create (JSON: client_id, display_name, email?, managers[] or role)
GET /{principalID}/managers ListManagersFor
POST /{principalID}/managers AddManager (JSON: principal_id, role)
DELETE /{principalID}/managers/{managerID}/{role} RemoveManager
POST /{principalID}/release Release
DELETE /{principalID} Delete
POST /{principalID}/claims IssueClaim
POST /claims/redeem RedeemClaim (JSON: code)
POST /manager-invites InviteManager (JSON: client_id, email, role)
POST /manager-invites/accept AcceptManagerInvite (JSON: token)
POST /manager-invites/decline DeclineManagerInvite (JSON: token); 204, 400 for any mismatch
DELETE /manager-invites/{inviteID} RevokeManagerInvite
GET /{principalID}/login-tokens
POST /{principalID}/login-tokens IssueLoginToken (JSON: client_id, name?, single_use?, expires_at?)
DELETE /{principalID}/login-tokens/{credentialID}
GET /{principalID}/sessions
DELETE /{principalID}/sessions/{kind}/{sessionID}
POST /{principalID}/revoke-all
Responses: 401 without a principal, 403 for a missing permission (or a
client that does not allow managed principals), 404 for an unknown or
unmanaged principal, 400 for malformed input or any unusable code, 409 for
conflicts (ErrLastManager, ErrEmailInUse, ErrEmailNotVerified,
ErrAlreadyManaged). Bodies are bounded and refuse unknown fields.
client_id in a create, invite or login-token body is the caller's
assertion; the deployment and that client's opt-in still gate it
(ManagedPrincipalsAllowed).
In relying-party mode (WithRequestClient), which systemauthsvc uses to
serve the API on SystemAuth, the client is the authenticated access token's
and a different body client_id is a 400; create names only the caller as
manager; POST /{principalID}/managers is refused (managers join through
invitations and claims); and an unknown or unmanaged principal answers 403,
like a principal the caller does not manage. WithCodeRateLimit adds a
per-caller 429 on the routes that present a code.
Attaching an email¶
Service.AttachVerifiedEmail(actorID, managedID, email, verifiedAt)
requires manage_account, and in one transaction sets sf_humans.email and
email_verified_at on a managed principal that has no email, and makes the
email the sf_principals.identifier. Management is unchanged (ADR-009):
the relationships and the managed capabilities stay, and the verified email
is what allows the last manager to be removed. A taken address is
ErrEmailInUse; a principal that already has an email is ErrHasEmail.
Proving control of the address is the caller's job (ADR-003 verification is
not wired into this step). A principal created with an unverified email is
verified with Service.VerifyEmail instead.
Other surfaces today¶
| Surface | Behavior |
|---|---|
| Invites | Accepting or listing invites requires a caller with an email; a managed principal gets 403 |
| Signup and social login | Look principals up by email; a managed identifier never matches |
| Marketplace | marketplace.PurchaserChecker (Service.IsManaged) refuses checkout for managed principals |
| Deletion | Service.Delete (above), restricted by manage_account to guardians, which ADR-005 requires |
Releasing a principal¶
Management ends only for a principal that can be reached and has proven it
controls its address (ADR-009). Release and the removal of the last
manager of such a principal are the same event: the relationships are
deactivated and the human capabilities that management withheld
(CanManageProfile, CanDelegate) are restored in the transaction, and
managed_principal.released is emitted. The platform records the release;
whether the person must confirm first is the application's decision. Once
released the principal is self-managed: managers lose their authority, the
managed claim is no longer emitted, login tokens stop signing it in, and
re-entering management takes a new invitation.
Manager invitations¶
A manager relationship with an existing principal gives up control of its
own profile, so it is created only by that principal accepting an
invitation while signed in (AcceptManagerInvite). The caller passes its
own authenticated principal ID, and the invitation matches only when the
principal's verified email is the invited address, so a leaked token alone
does not enter anyone into management. The invitee may instead decline
(DeclineManagerInvite), with the same identity match: the invitation is
then spent, the inviter cannot withdraw it and nothing about the invitee
changes. Creation by a manager (Create,
RedeemClaim) remains the path for principals the manager creates or
whose guardian claims them. How the token is delivered (email, link, QR)
is the application's choice.
Slugs¶
A managed principal's personal organization (ADR-006) is never slugged from
an email. signup.SlugForManaged produces the manager-entered value or a
generated adjective-noun-NN; SystemAuth uses it, with the managers'
display name and no provider avatar, when a managed principal signs in with
an email of its own (personalOrgParamsFor). A handle therefore never
reveals a child's address.
What a managed principal may do for itself¶
That is the application's decision, expressed in its authorization
vocabulary (ADR-009 decision 7): the platform supplies the relationship,
the roles and the managed claim. A managed principal may hold its own
credentials (a password, a social login linked to its email, login
tokens); manager authority (consent, deletion, credential and session
revocation) is unchanged by the principal signing in itself.