managedprincipal

package
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 38 Imported by: 0

Documentation

Overview

Package managedprincipal manages managed principals: humans, such as young learners, whose accounts are created and controlled by managers who have a verified email (ADR-005). A principal is managed while it has at least one active manager relationship, whether or not it has an email of its own (ADR-009).

The package enforces the invariants: a managed principal can be created only when the deployment and the requesting client both allow it, a principal without a verified email always has at least one active manager, and every manager is an active human with an email who is not itself managed.

Index

Constants

View Source
const (
	// ClaimCodePrefix starts every claim code. Codes use only uppercase
	// letters and digits, for QR codes and printed letters.
	ClaimCodePrefix = "SFMC"

	// DefaultClaimTTL is how long a claim code stays redeemable: long
	// enough for a letter sent home to reach a parent.
	DefaultClaimTTL = 7 * 24 * time.Hour

	// Audit event types for claims and attaching an email.
	EventClaimIssued   = "managed_principal.claim_issued"
	EventClaimRedeemed = "managed_principal.claim_redeemed"
	EventEmailAttached = "managed_principal.email_attached"
	EventEmailVerified = "managed_principal.email_verified"
)
View Source
const (
	// InviteCodePrefix starts every manager invitation token.
	InviteCodePrefix = "SFMI"

	// DefaultInviteTTL is how long a manager invitation stays acceptable.
	DefaultInviteTTL = 7 * 24 * time.Hour

	// Audit event types for manager invitations.
	EventManagerInvited        = "managed_principal.manager_invited"
	EventManagerInviteAccepted = "managed_principal.manager_invite_accepted"
	EventManagerInviteRevoked  = "managed_principal.manager_invite_revoked"
	EventManagerInviteDeclined = "managed_principal.manager_invite_declined"
)
View Source
const (
	// RoleGuardian has full authority: consent, deletion, billing, sign-in
	// credentials, and adding or removing other managers.
	RoleGuardian = "guardian"
	// RoleEducator has operational authority: sign-in credentials and
	// reading the profile, without deletion, billing, or consent.
	RoleEducator = "educator"
)

Built-in manager roles.

View Source
const (
	EventCreated        = "managed_principal.created"
	EventManagerAdded   = "managed_principal.manager_added"
	EventManagerRemoved = "managed_principal.manager_removed"
	EventTokenIssued    = "managed_principal.login_token_issued"
	// EventReleased records that a principal's management ended.
	EventReleased = "managed_principal.released"
)

Audit event types emitted by the service.

View Source
const (
	EventTokenRevoked   = "managed_principal.login_token_revoked"
	EventSessionRevoked = "managed_principal.session_revoked"
	EventAllRevoked     = "managed_principal.all_revoked"
)

Audit event types for manager visibility actions.

View Source
const EventDeleted = "managed_principal.deleted"

EventDeleted records that a managed principal was deleted.

Variables

View Source
var (
	// ErrInvalidClaim is returned for every claim code that cannot be
	// redeemed: unknown, malformed, expired, or already redeemed.
	ErrInvalidClaim = errors.New("managedprincipal: invalid claim code")
	// ErrEmailInUse is returned when attaching an email another human
	// already has.
	ErrEmailInUse = errors.New("managedprincipal: email is already in use")
	// ErrHasEmail is returned when attaching an email to a principal that
	// already has one.
	ErrHasEmail = errors.New("managedprincipal: principal already has an email")
	// ErrEmailRequired is returned when attaching an empty email, or
	// verifying a principal that has none.
	ErrEmailRequired = errors.New("managedprincipal: email is required")
)
View Source
var (
	// ErrInvalidInvite is returned for every invitation that cannot be
	// accepted or declined: unknown, malformed, expired, revoked, already
	// accepted or declined, or addressed to someone other than the
	// signed-in principal.
	ErrInvalidInvite = errors.New("managedprincipal: invalid manager invitation")
	// ErrAlreadyManaged is returned when accepting an invitation for a
	// principal that already has a manager. Its managers change the
	// relationships; an invitation is the way into management.
	ErrAlreadyManaged = errors.New("managedprincipal: principal is already managed")
	// ErrManagesOthers is returned when a principal that manages others
	// would enter management: a managed principal cannot manage.
	ErrManagesOthers = errors.New("managedprincipal: principal manages other principals")
	// ErrInviteEmailRequired is returned when inviting without an address.
	ErrInviteEmailRequired = errors.New("managedprincipal: invitee email is required")
)
View Source
var (
	// ErrNotAllowed is returned when the deployment or the requesting
	// client does not allow managed principals.
	ErrNotAllowed = errors.New("managedprincipal: managed principals are not enabled for this client")
	// ErrDisplayNameRequired is returned when creating a managed principal
	// without a display name.
	ErrDisplayNameRequired = errors.New("managedprincipal: display name is required")
	// ErrNoManagers is returned when creating a managed principal without
	// a manager.
	ErrNoManagers = errors.New("managedprincipal: at least one manager is required")
	// ErrInvalidManager is returned when a manager is not an active human
	// with an email, or is itself managed.
	ErrInvalidManager = errors.New("managedprincipal: manager must be an active human with an email")
	// ErrInvalidRole is returned for a manager role that is not registered.
	ErrInvalidRole = errors.New("managedprincipal: unknown manager role")
	// ErrNotManaged is returned when the principal is not a human, or has
	// no active manager, for operations that apply only to managed
	// principals.
	ErrNotManaged = errors.New("managedprincipal: principal is not managed (it has no active manager)")
	// ErrLastManager is returned when removing the last active manager of
	// a principal that has no verified email. Management ends only for a
	// principal that can be reached and verified (ADR-009).
	ErrLastManager = errors.New("managedprincipal: cannot remove the last manager of a principal without a verified email")
	// ErrNotManager is returned when the acting principal is not an
	// active manager of the managed principal.
	ErrNotManager = errors.New("managedprincipal: not an active manager of this principal")
	// ErrForbidden is returned when the acting principal lacks the
	// permission an operation requires.
	ErrForbidden = errors.New("managedprincipal: forbidden")
	// ErrNotFound is returned when a principal or relationship does not exist.
	ErrNotFound = errors.New("managedprincipal: not found")
)
View Source
var ErrEmailNotVerified = errors.New("managedprincipal: release requires a verified email")

ErrEmailNotVerified is returned by Release when the principal has no verified email: management ends only for a principal that can be reached and has proven it controls its address (ADR-009).

Functions

func NewHandler

func NewHandler(svc *Service) http.Handler

NewHandler returns the managed-principal API. Mount it under a prefix, such as /managed-principals, behind requestprincipal.Middleware, which identifies the calling principal. Every route goes through the same service operations, and so the same authorizer checks, as the Go API.

Managing a principal:

POST   /                                         create (actor must be a manager)
GET    /{principalID}/managers                   view_profile
POST   /{principalID}/managers                   add a manager; manage_managers
DELETE /{principalID}/managers/{managerID}/{role} remove a manager; manage_managers
POST   /{principalID}/release                    end management; manage_managers
DELETE /{principalID}                            delete; manage_account
POST   /{principalID}/claims                     issue a claim code; manage_credentials
POST   /claims/redeem                            the caller redeems a claim code

Consent to be managed:

POST   /manager-invites                          the caller offers to manage a verified address
POST   /manager-invites/accept                   the caller accepts, entering management
POST   /manager-invites/decline                  the caller declines
DELETE /manager-invites/{inviteID}               the inviter withdraws it

Credentials and sessions, each requiring manage_credentials on the managed principal:

GET    /{principalID}/login-tokens
DELETE /{principalID}/login-tokens/{credentialID}
GET    /{principalID}/sessions
DELETE /{principalID}/sessions/{kind}/{sessionID}
POST   /{principalID}/revoke-all

Types

type Claim

type Claim struct {
	ID        uuid.UUID
	Code      string
	ExpiresAt time.Time
}

Claim is a newly issued claim code. Code is available only here.

type CreateInput

type CreateInput struct {
	// ClientID is the OAuth client making the request; it must allow
	// managed principals.
	ClientID string
	// DisplayName is the name shown for the managed principal.
	DisplayName string
	// Email optionally gives the principal an address of its own. It is
	// stored unverified: it counts toward ending management only once
	// verified (VerifyEmail). Management does not depend on it (ADR-009).
	Email string
	// Managers must contain at least one manager.
	Managers []ManagerInput
	// ActorID is the principal performing the action. It must be one of the
	// managers, or a platform administrator.
	ActorID uuid.UUID
}

CreateInput describes a managed principal to create.

type CreateRequest

type CreateRequest struct {
	// ClientID is the OAuth client making the request.
	ClientID    string        `json:"client_id"`
	DisplayName string        `json:"display_name"`
	Email       string        `json:"email,omitempty"`
	Managers    []ManagerView `json:"managers"`
}

CreateRequest is the body of POST /.

type Gate

type Gate interface {
	ManagedPrincipalsAllowed(ctx context.Context, clientID string) (bool, error)
	LoginTokenPolicy(ctx context.Context, clientID string) (credential.LoginTokenPolicy, error)
}

Gate reports whether a client may create and sign in managed principals, and the login token lifetimes that apply to it. *systemauth.Server implements it: ManagedPrincipalsAllowed requires both the deployment setting and the client's flag, and LoginTokenPolicy applies the client's own default lifetime over the deployment's.

type InviteManagerInput

type InviteManagerInput struct {
	// ClientID is the OAuth client making the request; it must allow
	// managed principals.
	ClientID string
	// ManagerID is the human offering to manage: an active human with an
	// email that is not itself managed. It is also the actor.
	ManagerID uuid.UUID
	// Email is the verified email of the human invited to be managed. Only
	// a principal whose verified email matches can accept.
	Email string
	// Role is the manager role the inviter will hold.
	Role string
}

InviteManagerInput describes a manager invitation.

type IssueLoginTokenInput

type IssueLoginTokenInput struct {
	// ManagedID is the managed principal the token signs in.
	ManagedID uuid.UUID
	// ClientID is the OAuth client requesting the token; it must allow
	// managed principals and supplies the default lifetime.
	ClientID string
	// IssuedBy is the issuing manager.
	IssuedBy uuid.UUID
	// ExpiresAt optionally sets the expiry; it is clamped to the ceiling.
	ExpiresAt *time.Time
	// SingleUse makes the token valid for one sign-in.
	SingleUse bool
	// Name optionally labels the token, such as the card or device.
	Name string
}

IssueLoginTokenInput describes a login token to issue for a managed principal.

type LoginTokenView

type LoginTokenView struct {
	ID         uuid.UUID  `json:"id"`
	Prefix     string     `json:"prefix"`
	Name       string     `json:"name,omitempty"`
	IssuedBy   uuid.UUID  `json:"issued_by"`
	SingleUse  bool       `json:"single_use"`
	CreatedAt  time.Time  `json:"created_at"`
	ExpiresAt  *time.Time `json:"expires_at,omitempty"`
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
	LastUsedIP string     `json:"last_used_ip,omitempty"`
}

LoginTokenView is a login token as a manager sees it; it never carries the token value.

type ManagerInput

type ManagerInput struct {
	PrincipalID uuid.UUID
	Role        string
}

ManagerInput names a manager and the role it holds.

type ManagerInvite

type ManagerInvite struct {
	ID        uuid.UUID
	Token     string
	Email     string
	Role      string
	ExpiresAt time.Time
}

ManagerInvite is a newly issued manager invitation. Token is available only here; the application delivers it to the invitee.

type ManagerView

type ManagerView struct {
	PrincipalID uuid.UUID `json:"principal_id"`
	Role        string    `json:"role"`
}

ManagerView is a manager relationship as the API returns it.

type Option

type Option func(*Service)

Option configures a Service.

func WithAccessChangeSink

func WithAccessChangeSink(sink accesschange.Sink) Option

WithAccessChangeSink announces session revocations (RevokeSession and RevokeAll) to sink after they commit (ADR-010). The access-change record is written whether or not a sink is set.

func WithAuditEmitter

func WithAuditEmitter(e audit.Emitter) Option

WithAuditEmitter records every action through the given emitter.

func WithAuthorizer

func WithAuthorizer(a authz.Authorizer) Option

WithAuthorizer sets the authorizer that checks managers' permissions on managed principals, such as a SpiceDB provider or an rbac provider with a platform-administrator check. Defaults to an rbac provider backed by ManagerRoles.

func WithAuthzSyncer

func WithAuthzSyncer(syncer authz.RelationshipSyncer, mode authz.SyncMode) Option

WithAuthzSyncer mirrors manager relationships into the authorization backend as guardian and educator relations on the managed principal. In authz.SyncModeStrict a sync failure is returned, after the database change has committed; otherwise it is logged.

func WithClaimTTL

func WithClaimTTL(d time.Duration) Option

WithClaimTTL sets how long claim codes stay redeemable. Defaults to DefaultClaimTTL.

func WithInviteTTL

func WithInviteTTL(d time.Duration) Option

WithInviteTTL sets how long manager invitations stay acceptable. Defaults to DefaultInviteTTL.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger used for failures that cannot be returned, such as an audit emission after the change committed.

func WithRoles

func WithRoles(roles ...string) Option

WithRoles registers manager roles in addition to the built-in guardian and educator roles.

type Relationship

type Relationship struct {
	ManagedPrincipalID uuid.UUID
	ManagerPrincipalID uuid.UUID
	Role               string
	Active             bool
}

Relationship is an active or inactive manager relationship.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service creates managed principals and maintains their managers.

func NewService

func NewService(client *ent.Client, gate Gate, opts ...Option) *Service

NewService returns a Service. The gate decides whether a client may create managed principals; pass the SystemAuth server.

func (*Service) AcceptManagerInvite

func (s *Service) AcceptManagerInvite(ctx context.Context, token string, principalID uuid.UUID) (uuid.UUID, error)

AcceptManagerInvite is the signed-in principal's consent to be managed: it spends the invitation, makes the inviter a manager with the invited role, and gives up the capabilities that managed principals do not have (profile management, delegation). principalID is the caller's own identity, never a value a manager supplies. It returns the manager's ID.

The principal must be an active human whose verified email is the invited address, must have no manager yet (ErrAlreadyManaged), and must not manage others (ErrManagesOthers). Every failure to match is ErrInvalidInvite.

func (*Service) AddManager

func (s *Service) AddManager(ctx context.Context, managedID uuid.UUID, m ManagerInput, actorID uuid.UUID) error

AddManager makes manager a manager of managed with the given role. It is a no-op when the relationship is already active and reactivates it when inactive. The actor needs manage_managers on the managed principal, which guardians hold; the permission is checked under the principal's row lock, in the transaction that writes.

func (*Service) AttachVerifiedEmail

func (s *Service) AttachVerifiedEmail(ctx context.Context, actorID, managedID uuid.UUID, email string, verifiedAt time.Time) error

AttachVerifiedEmail gives a managed principal that has no email an address the caller has verified the principal controls, and makes it the identifier. Management is unchanged (ADR-009): manager relationships and the principal's managed capabilities stay until management ends, and the verified email is what allows it to end. The actor needs manage_account, which guardians hold.

Proving control of the address is the caller's job until SystemAuth's email verification (ADR-003) drives this step. A principal created with an unverified email is verified with VerifyEmail instead.

func (*Service) Create

func (s *Service) Create(ctx context.Context, in CreateInput) (uuid.UUID, error)

Create creates a managed principal with its managers in one transaction. It returns the new principal's ID. The actor must be one of the managers, the manager creating the principal, or a platform administrator (ErrForbidden otherwise).

func (*Service) DeclineManagerInvite

func (s *Service) DeclineManagerInvite(ctx context.Context, token string, principalID uuid.UUID) error

DeclineManagerInvite is the invitee's refusal of an invitation to be managed. It marks the invitation declined, so it can be neither accepted nor declined again, and records an audit event, managed_principal. manager_invite_declined, carrying the inviter and the invitation for whoever notifies them. The inviter sees nothing change: no relationship or capability is touched.

principalID is the caller's own identity, never a value a manager supplies. As for AcceptManagerInvite, it must be an active human whose verified email is the invited address, and every failure to match is ErrInvalidInvite. Unlike acceptance it does not depend on the invitee's management state, so an invitee that is already managed may still clear an invitation away.

func (*Service) Delete

func (s *Service) Delete(ctx context.Context, actorID, managedID uuid.UUID) error

Delete deletes a managed principal. The actor needs manage_account, which guardians hold (ADR-005: deletion is a guardian or platform-administrator action; an educator cannot).

Delete is the platform's soft delete, like organization deletion: the principal row stays so that identifiers elsewhere keep resolving, but everything that identifies or authenticates the person is removed in one transaction. The principal is deactivated; its email, names and avatar are cleared and its identifier becomes "deleted:<id>", which frees the address; its credentials, external identities, login sessions, consents and claims are removed; its OAuth and principal token families are revoked; its memberships and manager relationships are deactivated; and invitations to its address are withdrawn. An access change is recorded so tokens already issued stop being honored at once (ADR-010). Data an application keeps about the person, and any personal organization it owns, are the application's to remove.

func (*Service) InviteManager

func (s *Service) InviteManager(ctx context.Context, in InviteManagerInput) (*ManagerInvite, error)

InviteManager offers to manage the self-managed human with the given verified email. Nothing changes for that human until it accepts the invitation while signed in (AcceptManagerInvite): a manager relationship with an existing principal is never created without its consent (ADR-009).

func (*Service) IsManaged

func (s *Service) IsManaged(ctx context.Context, principalID uuid.UUID) (bool, error)

IsManaged reports whether the principal is a human with at least one active manager relationship. An email, verified or not, does not change the answer (ADR-009).

func (*Service) IssueClaim

func (s *Service) IssueClaim(ctx context.Context, actorID, managedID uuid.UUID) (*Claim, error)

IssueClaim issues a single-use code with which a human with an email becomes guardian of a managed principal, for example a parent receiving a letter from a teacher. The issuer needs manage_credentials, which guardians and educators hold.

func (*Service) IssueLoginToken

IssueLoginToken issues a login token for a managed principal on behalf of one of its active managers. The plain token is returned once.

func (*Service) ListLoginTokens

func (s *Service) ListLoginTokens(ctx context.Context, actorID, managedID uuid.UUID) ([]*credential.Credential, error)

ListLoginTokens returns a managed principal's usable login tokens, newest first. It never returns token values.

func (*Service) ListManagers

func (s *Service) ListManagers(ctx context.Context, managedID uuid.UUID) ([]Relationship, error)

ListManagers returns the active manager relationships of a principal.

func (*Service) ListManagersFor

func (s *Service) ListManagersFor(ctx context.Context, actorID, managedID uuid.UUID) ([]Relationship, error)

ListManagersFor returns the active manager relationships of a managed principal to an actor that holds view_profile on it, which guardians and educators do.

func (*Service) ListSessions

func (s *Service) ListSessions(ctx context.Context, actorID, managedID uuid.UUID) ([]Session, error)

ListSessions returns the ways a managed principal is signed in now: browser login sessions, OAuth token families, and principal token families.

func (*Service) ManagerRoles

func (s *Service) ManagerRoles(ctx context.Context, managerID, managedID uuid.UUID) ([]string, error)

ManagerRoles returns the roles managerID actively holds over managedID. It matches authz.ManagerRoleGetter, so it can back the rbac provider: rbac.WithManagerRoleGetter(svc.ManagerRoles).

func (*Service) RedeemClaim

func (s *Service) RedeemClaim(ctx context.Context, code string, claimantID uuid.UUID) (uuid.UUID, error)

RedeemClaim makes claimantID a guardian of the claim's managed principal and spends the claim. The claimant must be an active human with an email who is not managed. It returns the managed principal's ID.

func (*Service) Release

func (s *Service) Release(ctx context.Context, actorID, managedID uuid.UUID) error

Release ends a principal's management: it removes the principal's remaining managers and restores its default human capabilities in one transaction, after which the principal is self-managed. It requires a verified email (ErrEmailNotVerified otherwise) and the manage_managers permission, which guardians hold and which is checked under the principal's row lock. Release is an explicit, audited event (managed_principal.released); whether the person being released must confirm first is the application's decision.

func (*Service) RemoveManager

func (s *Service) RemoveManager(ctx context.Context, managedID uuid.UUID, m ManagerInput, actorID uuid.UUID) error

RemoveManager deactivates a manager relationship. It refuses with ErrLastManager when this is the last active manager of a principal without a verified email; deactivate or delete the principal instead. Removing the last manager of a principal with a verified email releases it, as Release does. The actor needs manage_managers on the managed principal, which guardians hold; the permission is checked under the principal's row lock, in the transaction that writes, so of two concurrent removals that each withdraw the other's authority exactly one succeeds.

func (*Service) RevokeAll

func (s *Service) RevokeAll(ctx context.Context, actorID, managedID uuid.UUID) error

RevokeAll revokes every login token and ends every session of a managed principal in one transaction.

func (*Service) RevokeLoginToken

func (s *Service) RevokeLoginToken(ctx context.Context, actorID, managedID, credentialID uuid.UUID) error

RevokeLoginToken revokes one of a managed principal's login tokens.

func (*Service) RevokeManagerInvite

func (s *Service) RevokeManagerInvite(ctx context.Context, actorID, inviteID uuid.UUID) error

RevokeManagerInvite withdraws a pending invitation. Only the inviter may.

func (*Service) RevokeSession

func (s *Service) RevokeSession(ctx context.Context, actorID, managedID uuid.UUID, kind SessionKind, sessionID uuid.UUID) error

RevokeSession ends one session listed by ListSessions. Revoking a token family revokes every token in it, so its refresh token cannot renew it.

func (*Service) Roles

func (s *Service) Roles() []string

Roles returns the registered manager roles.

func (*Service) VerifyEmail

func (s *Service) VerifyEmail(ctx context.Context, managedID uuid.UUID, verifiedAt time.Time) error

VerifyEmail records that a managed principal controls the email it was created with, so that its management may end (Release). It is called by the verification flow once the principal has proven control of the address (ADR-003), not by a manager: the platform does not let a manager vouch for someone else's address.

type Session

type Session struct {
	// ID identifies the session within its kind: the login session ID, or
	// the token family ID (the token ID when it has no family).
	ID   uuid.UUID   `json:"id"`
	Kind SessionKind `json:"kind"`
	// Provider is how a login session was established, such as
	// login_token.
	Provider string `json:"provider,omitempty"`
	// ClientID is the OAuth client holding an OAuth session.
	ClientID   string     `json:"client_id,omitempty"`
	CreatedAt  time.Time  `json:"created_at"`
	ExpiresAt  time.Time  `json:"expires_at"`
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
	ClientIP   string     `json:"client_ip,omitempty"`
}

Session is one way a managed principal is currently signed in.

type SessionKind

type SessionKind string

SessionKind distinguishes the ways a managed principal can be signed in.

const (
	// SessionKindLogin is a SystemAuth browser login session.
	SessionKindLogin SessionKind = "login"
	// SessionKindOAuth is an OAuth token family a client holds.
	SessionKindOAuth SessionKind = "oauth"
	// SessionKindToken is a principal token family.
	SessionKindToken SessionKind = "principal_token"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL