Managed Principals¶
A managed principal is a human with at least one active manager: an account created and controlled by people who have a verified email. The managed principal may have an email of its own or none. SystemForge provides the primitive; applications supply the meaning. A learning application maps a child to a managed principal and a teacher or parent to a manager, but the same shape fits a caregiver and a dependent, a coach and an athlete, or an organization provisioning an account a person claims later.
Email stays required by default. Managed principals exist only where a deployment and an OAuth client both opt in, and the design of record is ADR-005.
The Primitive¶
A managed principal is an ordinary Principal(type=human) with a Human
row and an active manager relationship. Management comes from the
relationship, not from the email
(ADR-009):
| Field | Value |
|---|---|
identifier |
human:<principal-id> without an email, a lookup key and never an address; the email when there is one |
capabilities |
can_access_ui only: no profile management, no delegation |
sf_humans.email |
Optional; unverified when given at creation |
SystemForge enforces these invariants, transactionally:
- A managed principal has at least one active manager unless it has a
verified email. Removing the last manager of a principal without
one is refused with
ErrLastManager; deactivate or delete the principal instead. - A manager is an active human with an email who is not itself managed.
- Managed principals are created only when the deployment and the requesting client both allow them.
- A managed principal may have an email, from creation
(
CreateInput.Email, unverified untilVerifyEmail) or attached later (AttachVerifiedEmail). It stays managed; the verified email is what allows its last manager to be removed. - A principal with no manager cannot be given one by a manager
(
AddManagerand claims returnErrNotManaged): entering management takes the principal's own consent.
Enabling¶
Both switches default to off. In the SystemAuth configuration:
managed_principals:
enabled: true
login_token_ttl: 720h # default lifetime of reusable login tokens
login_token_max_ttl: 9600h # ceiling on any login token
single_use_token_ttl: 20m # default lifetime of single-use tokens
clients:
- id: learning-app
managed_principals: true
login_token_ttl: 7200h # optional: this client's own default
Dynamically registered clients carry the same settings on OAuthApp
(managed_principals_enabled, login_token_ttl). The single check every
path uses is (*systemauth.Server).ManagedPrincipalsAllowed(ctx, clientID).
Roles¶
Two manager roles are built in:
| Role | Authority |
|---|---|
guardian |
Full control: consent, deletion, billing, credentials, and adding or removing other managers |
educator |
Operational control: credentials, profile reads, and issuing a claim so a guardian can join. No deletion, billing, or consent |
They are relations on principal in the base authorization schema, with
these platform permissions:
| Permission | Granted to |
|---|---|
manage_account |
guardian |
manage_credentials |
guardian, educator |
view_profile |
guardian, educator |
manage_managers |
guardian |
An educator cannot delete a managed principal, even one that never
received a guardian; that is a guardian or platform-administrator action.
Applications may register further roles with managedprincipal.WithRoles.
They grant no platform permissions; give them meaning in the application's
authorization facets (see Authorization).
Creating a Managed Principal¶
svc := managedprincipal.NewService(entClient, systemAuthServer,
managedprincipal.WithAuditEmitter(auditEmitter),
managedprincipal.WithAuthzSyncer(syncer, authz.SyncModeStrict),
)
learnerID, err := svc.Create(ctx, managedprincipal.CreateInput{
ClientID: "learning-app",
DisplayName: "Robin",
Managers: []managedprincipal.ManagerInput{
{PrincipalID: teacherID, Role: managedprincipal.RoleEducator},
},
ActorID: teacherID,
})
The principal, its human row, and its manager relationships are written in
one transaction. Nobody becomes a manager without agreeing: Create names
only the acting manager, and other managers join by accepting an
invitation or redeeming a claim. RemoveManager deactivates a
relationship. Every action is audited through contract/audit.
Trusted administrator tooling, such as a migration, can build the service
with managedprincipal.WithDirectManagerAdds(). Create may then name
other managers, and AddManager attaches one directly; it is idempotent
and reactivates a removed relationship. Without the option both return
ErrConsentRequired.
Sign-in with Login Tokens¶
A login token is a manager-issued sign-in secret for a managed principal. Applications render it as a QR code, a printed card, or a link; SystemForge does not care which.
tok, err := svc.IssueLoginToken(ctx, managedprincipal.IssueLoginTokenInput{
ManagedID: learnerID,
ClientID: "learning-app",
IssuedBy: teacherID,
Name: "Robin's card",
})
// tok.PlainToken is shown once; only its hash is stored.
Tokens are SFLT followed by 52 uppercase letters and digits, so a QR code
can encode them in alphanumeric mode, and typed entry tolerates lowercase,
spaces, and hyphens. The issuer must be an active manager.
The lifetime resolves at issue time:
- An explicit
ExpiresAton the request. - Else, for a reusable token, the client's
login_token_ttl; else the deployment'slogin_token_ttl(30 days by default). - Else, for a single-use token,
single_use_token_ttl(20 minutes by default). - Never beyond
login_token_max_ttl(400 days by default); longer requests are clamped and the clamp is logged.
The 30-day default suits a general SaaS platform; a learning application typically sets a term or a school year on its client.
Exchanging a Token¶
The application posts the token to SystemAuth:
POST /login-token
Content-Type: application/x-www-form-urlencoded
token=SFLT...&client_id=learning-app&return_to=/play
On success SystemAuth establishes its usual login session cookie and
answers 303 See Other to return_to (checked against the redirect
allowlist) or the default target. The authorization-code flow then proceeds
as for any signed-in user.
Unknown, malformed, expired, revoked, and used tokens, a client that does
not allow managed principals, and an inactive principal all receive the
same 401. The endpoint is rate limited per source address (60 a minute
by default, enough for a class signing in from one network) and per client
(600 a minute); configure shared storage with
systemauth.WithLoginTokenRateLimit when several instances serve it. A
single-use token succeeds at most once, even when presented concurrently.
Claims¶
ID tokens and userinfo for a managed principal carry "managed": true,
whatever scopes were granted, and omit email and email_verified when
the principal has no email. Relying parties read it into
relyingparty.VerifiedIdentity.Managed; the relying-party layer creates a
managed principal locally on its first sign-in and never links one by
email, so an application's PrincipalStore.CreatePrincipal must accept an
identity with or without an email when Managed is set.
Ending and Entering Management¶
Release ends management of a principal with a verified email: its
managers are removed and its default human capabilities restored, as one
audited event. Without a verified email it returns ErrEmailNotVerified;
removing the last manager of a principal with one has the same effect.
A self-managed human enters management only by consent. A prospective manager invites its verified address, and the person accepts while signed in:
inv, err := svc.InviteManager(ctx, managedprincipal.InviteManagerInput{
ClientID: "match-app", ManagerID: familyMemberID,
Email: "adult@example.com", Role: managedprincipal.RoleGuardian,
})
// deliver inv.Token; then, as the signed-in invitee:
managerID, err := svc.AcceptManagerInvite(ctx, token, signedInPrincipalID)
An invitation is single use, expires after seven days, and matches only a principal whose verified email is the invited address.
Authorization and the HTTP API¶
Every service operation consults the authorizer, so a Go caller and the HTTP handler are held to the same rules:
| Operation | Needs |
|---|---|
Create |
The actor is the only named manager, or a platform administrator; other managers need WithDirectManagerAdds |
AddManager |
WithDirectManagerAdds, and manage_managers (guardians) |
RemoveManager, Release |
manage_managers (guardians) |
Delete, AttachVerifiedEmail |
manage_account (guardians) |
IssueClaim, IssueLoginToken, tokens, sessions |
manage_credentials (guardians, educators); a role registered with WithRoles cannot issue a login token |
RedeemClaim |
A valid manager claimant, and an issuer that still holds manage_credentials |
Delete deactivates the principal and erases what identifies or
authenticates the person (email, names, credentials, sessions, consents),
keeping the row so identifiers elsewhere keep resolving; data the
application keeps is its own to remove.
managedprincipal.NewHandler(svc) serves the same operations as JSON; see
the route list in the
architecture page.
Relying Parties: the API on SystemAuth¶
When a host builds SystemAuth with systemauthsvc and enables managed
principals, SystemAuth serves the API to applications composed beside it,
in the same process or another, at /managed-principals
(systemauthsvc.ManagedPrincipalsPath). The principals stay in SystemAuth's
database; an application keeps only their IDs.
Each call carries the signed-in manager's SystemAuth access token, granted
the systemforge:managed_principals scope (managedprincipal.Scope). The
manager is the actor, and the application is the client the token was
issued to: that client must allow managed principals, and a body naming
another client is refused. Give the client the scope and the opt-in:
managed_principals:
enabled: true
clients:
- id: learning-app
managed_principals: true
scopes: [openid, profile, email, systemforge:managed_principals]
The API differs from the in-process handler in a few ways:
- Creation names only the caller as manager, by
role. Further managers join with their consent, through an invitation or a claim;POST /{id}/managersis refused. - One answer for strangers. An unknown principal, one that is not managed, and one the caller does not manage all answer 403, so the API cannot be asked who is a managed principal.
- Codes are rate limited per caller: 10 a minute by default across claim
redemption and invitation answers (
managedprincipal.DefaultCodeLimit). Pass shared storage insystemauthsvc.Options.ManagedPrincipalRateswhen several instances serve SystemAuth. - No email attachment. A relying party cannot prove an address belongs to
the principal, so
AttachVerifiedEmailstays an in-process operation.
systemauthsvc.Options.AuditEmitter records every write, and
Options.ManagedPrincipalOptions passes roles, an authorizer or an authz
syncer to the service. A host in the same process can call
Service.ManagedPrincipals() directly.
Applications call the API with managedprincipal/mpclient, which depends on
the standard library and uuid only:
mp := mpclient.New("https://auth.example.com/managed-principals", httpClient)
id, err := mp.Create(ctx, managerToken, mpclient.CreateInput{
DisplayName: "Robin",
Role: mpclient.RoleEducator,
})
card, err := mp.IssueLoginToken(ctx, managerToken, id, mpclient.IssueLoginTokenInput{Name: "Robin's card"})
// card.Token is shown once; render it as a QR code or a printed card.
Errors match mpclient.ErrUnauthorized, ErrForbidden, ErrNotFound,
ErrInvalidCode, ErrInvalidRequest, ErrConflict, and ErrRateLimited
with errors.Is, and unwrap to *mpclient.APIError for the status, message
and RetryAfter. The managed principal then signs in at
POST /login-token as described in Exchanging a Token.
Manager Visibility and Revocation¶
Managers holding manage_credentials can see and end every way a managed
principal is signed in:
| Method | Covers |
|---|---|
ListLoginTokens, RevokeLoginToken |
Live login tokens, never their values |
ListSessions, RevokeSession |
Browser login sessions, OAuth token families, principal token families |
RevokeAll |
All of the above, in one transaction |
Revoking a token family revokes all its tokens, so a refresh token cannot
renew it. managedprincipal.NewHandler serves these as JSON for the
manager identified by requestprincipal:
router.With(requestprincipal.Middleware(opts, authenticators...)).
Mount("/managed-principals", managedprincipal.NewHandler(svc))
GET /managed-principals/{id}/login-tokens
POST /managed-principals/{id}/login-tokens
DELETE /managed-principals/{id}/login-tokens/{credentialID}
GET /managed-principals/{id}/sessions
DELETE /managed-principals/{id}/sessions/{kind}/{sessionID}
POST /managed-principals/{id}/revoke-all
Permissions are checked through an authz.Authorizer. The default is the
simple provider backed by the service's ManagerRoles; pass
managedprincipal.WithAuthorizer to use SpiceDB or to add a
platform-administrator check.
Claiming a Managed Principal¶
A teacher often creates the account before any parent is involved. A
manager with manage_credentials issues a claim, a single-use code
handed to the parent on paper or as a QR code:
claim, err := svc.IssueClaim(ctx, teacherID, learnerID)
// claim.Code is shown once; claim.ExpiresAt is seven days out by default.
learnerID, err = svc.RedeemClaim(ctx, code, parentID)
// The parent is now a guardian of the learner.
The claimant must be an active human with an email who is not managed.
Claims are stored hashed in their own table, apart from credentials, so a
claim code can never sign anyone in. WithClaimTTL changes the lifetime.
Attaching an Email¶
When a managed principal is ready for its own account, a guardian attaches an email:
The email becomes the identifier and default human capabilities are restored, so the principal can set a password and manage its own profile. Manager relationships remain until removed, and now the last one may be. The caller must have verified that the principal controls the address.
Authorization¶
The base SpiceDB schema defines:
definition principal {
relation guardian: principal
relation educator: principal
permission manage_account = guardian
permission manage_credentials = guardian + educator
permission view_profile = guardian + educator
permission manage_managers = guardian
}
With WithAuthzSyncer, every committed relationship change is written to
the authorization backend. The simple provider stores nothing; configure it
with simple.WithManagerRoleGetter(svc.ManagerRoles) and it applies the
same table, so both providers agree.
Applications add domain permissions in their own definitions, for example a learner's progress record, which the learner's managers and the organization's administrators may view:
definition learning_progress {
relation learner: principal
relation org: organization
permission view = learner->view_profile + org->manage
}
Other Surfaces¶
| Surface | Behavior |
|---|---|
| Password and social login | A missing email never matches; managed principals have no password until an email is attached |
| Invitations and signup | Target an email; they never create managed principals |
| SCIM | Managed principals are not SCIM users: they are not listed, changed, or grouped through SCIM |
| Marketplace | stripe.WithPurchaserChecker(svc) refuses checkout with marketplace.ErrManagedPurchaser; billing is a guardian's action |
Application Responsibilities¶
SystemForge supplies a manager with the authority to consent and delete. It does not record consent, ages, or regulatory evidence; the application records what its jurisdiction requires.