Documentation
¶
Overview ¶
Package account implements the sign-in flows.
Everything here exists to get a handful of details right that are easy to get wrong and expensive to get wrong:
- The lockout is checked before the password is verified, so a locked request neither burns an argon2 hash nor extends its own window.
- A login for an address with no account still spends the time an argon2 hash costs, so response time is not a membership oracle.
- A wrong password and a disabled account are told apart only after the password is verified, so "this account is disabled" cannot be used to enumerate accounts.
- A password reset answers the same way whether or not the address exists.
- Setting a new password ends every session, because a change made because somebody else knows your password is not a change if they stay signed in.
Index ¶
- Constants
- Variables
- func DefaultSlug(name string, id uuid.UUID) string
- func DomainAllowed(lowercasedEmail string, domains []string) bool
- func IsPermanentMailError(err error) bool
- func MailRetryAfterOf(err error) (time.Duration, bool)
- func PermanentMailError(err error) error
- func RetryMailAfter(err error, d time.Duration) error
- type AcceptInput
- type Account
- type ChangePasswordInput
- type Config
- type CreateTenantInput
- type Creator
- type Credential
- type DeleteAccountInput
- type Delivery
- type DeliveryState
- type Identity
- type ImpersonateInput
- type Invitation
- type Kind
- type LoginInput
- type MailOptions
- type MailReport
- type Membership
- type MemoryOutbox
- func (o *MemoryOutbox) Abandon(_ context.Context, ids []uuid.UUID, by uuid.UUID, _ time.Time) error
- func (o *MemoryOutbox) Claim(_ context.Context, by uuid.UUID, now time.Time, ttl time.Duration, limit int) ([]Delivery, error)
- func (o *MemoryOutbox) Deliveries() []Delivery
- func (o *MemoryOutbox) Enqueue(_ context.Context, d *Delivery) error
- func (o *MemoryOutbox) MarkFailed(_ context.Context, id uuid.UUID, reason string, _ time.Time) error
- func (o *MemoryOutbox) MarkSent(_ context.Context, id uuid.UUID, _ time.Time) error
- func (o *MemoryOutbox) MarkSkipped(_ context.Context, id uuid.UUID, reason string, _ time.Time) error
- func (o *MemoryOutbox) ReleaseClaims(_ context.Context, by uuid.UUID, _ time.Time) (int, error)
- func (o *MemoryOutbox) Retry(_ context.Context, id uuid.UUID, at time.Time, reason string, _ time.Time) error
- func (o *MemoryOutbox) RotateToken(_ context.Context, verificationID uuid.UUID, hash []byte, expiresAt time.Time) (bool, error)
- type MemoryStore
- func (s *MemoryStore) AccountForIdentity(_ context.Context, tenantID, identityID uuid.UUID) (*Account, error)
- func (s *MemoryStore) AccountsForIdentity(_ context.Context, identityID uuid.UUID) ([]*Account, error)
- func (s *MemoryStore) ConsumeVerification(_ context.Context, id uuid.UUID, at time.Time) (bool, error)
- func (s *MemoryStore) CountVerifications() int
- func (s *MemoryStore) CreateVerification(_ context.Context, v *Verification) error
- func (s *MemoryStore) Credential(_ context.Context, identityID uuid.UUID) (*Credential, error)
- func (s *MemoryStore) DeactivateIdentity(id uuid.UUID)
- func (s *MemoryStore) FindByID(_ context.Context, tenantID, id uuid.UUID) (*Account, error)
- func (s *MemoryStore) FindIdentityByEmail(_ context.Context, lowercased string) (*Identity, error)
- func (s *MemoryStore) FindIdentityByID(_ context.Context, id uuid.UUID) (*Identity, error)
- func (s *MemoryStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error
- func (s *MemoryStore) Insert(ctx context.Context, a *Account) error
- func (s *MemoryStore) InsertIdentity(_ context.Context, i *Identity) error
- func (s *MemoryStore) InsertTenant(_ context.Context, t *Tenant) error
- func (s *MemoryStore) InvitationsForIdentity(_ context.Context, identityID uuid.UUID) ([]Invitation, error)
- func (s *MemoryStore) MarkIdentityVerified(_ context.Context, identityID uuid.UUID, at time.Time) error
- func (s *MemoryStore) PendingInvitations(_ context.Context, tenantID uuid.UUID) ([]Invitation, error)
- func (s *MemoryStore) Put(a *Account)
- func (s *MemoryStore) PutIdentity(i *Identity)
- func (s *MemoryStore) PutPerson(i *Identity, a *Account)
- func (s *MemoryStore) RevokeVerification(_ context.Context, id uuid.UUID, at time.Time) (bool, error)
- func (s *MemoryStore) SaveCredential(_ context.Context, c *Credential) error
- func (s *MemoryStore) SoftDeleteAccount(_ context.Context, in DeleteAccountInput) error
- func (s *MemoryStore) TenantDomains(ctx context.Context, tenantID uuid.UUID) ([]string, error)
- func (s *MemoryStore) TenantsForIdentity(_ context.Context, identityID uuid.UUID) ([]Membership, error)
- func (s *MemoryStore) VerificationByHash(_ context.Context, hash []byte) (*Verification, error)
- func (s *MemoryStore) VerificationByID(_ context.Context, id uuid.UUID) (*Verification, error)
- type NewTenant
- type NoNotifier
- type Notifier
- type Outbox
- type ProvisionInput
- type RegisterInput
- type Registered
- type RevokeInput
- type Role
- type Service
- func (s *Service) AcceptAsMe(ctx context.Context, identityID uuid.UUID, in AcceptInput) (session.Pair, error)
- func (s *Service) AcceptInvitation(ctx context.Context, in AcceptInput) (session.Pair, error)
- func (s *Service) ChangePassword(ctx context.Context, in ChangePasswordInput) (session.Pair, error)
- func (s *Service) ConfirmPasswordReset(ctx context.Context, token, newPassword, ip string) error
- func (s *Service) CreateTenant(ctx context.Context, in CreateTenantInput) (NewTenant, error)
- func (s *Service) DispatchMail(ctx context.Context) (report MailReport, err error)
- func (s *Service) EndImpersonation(ctx context.Context, tok *session.Token) error
- func (s *Service) HasPassword(ctx context.Context, identityID uuid.UUID) (bool, error)
- func (s *Service) Impersonate(ctx context.Context, in ImpersonateInput) (session.Pair, error)
- func (s *Service) Invitations(ctx context.Context, tenantID uuid.UUID) ([]Invitation, error)
- func (s *Service) Login(ctx context.Context, in LoginInput) (SignInResult, error)
- func (s *Service) Logout(ctx context.Context, rootTokenID uuid.UUID) error
- func (s *Service) MyInvitations(ctx context.Context, identityID uuid.UUID) ([]Invitation, error)
- func (s *Service) MyTenants(ctx context.Context, identityID uuid.UUID) ([]Membership, error)
- func (s *Service) Provision(ctx context.Context, in ProvisionInput) (*Account, error)
- func (s *Service) Refresh(ctx context.Context, presented string) (session.Pair, error)
- func (s *Service) Register(ctx context.Context, in RegisterInput) (SignInResult, error)
- func (s *Service) ReleaseMailClaims(ctx context.Context) (int, error)
- func (s *Service) RequestPasswordReset(ctx context.Context, tenantID uuid.UUID, emailAddress, ip string) error
- func (s *Service) RevokeInvitation(ctx context.Context, in RevokeInput) error
- func (s *Service) SendEmailVerification(ctx context.Context, tenantID, accountID uuid.UUID) error
- func (s *Service) SetPassword(ctx context.Context, identityID uuid.UUID, newPassword string) error
- func (s *Service) StopClaimingMail()
- func (s *Service) Switch(ctx context.Context, in SwitchInput) (session.Pair, error)
- func (s *Service) Tenants(ctx context.Context, tenantID, accountID uuid.UUID) ([]Membership, error)
- func (s *Service) VerifyEmail(ctx context.Context, token string) error
- type SignInResult
- type Store
- type SwitchInput
- type Tenant
- type TenantDraft
- type TenantOptions
- type Verification
- type VerificationKind
Constants ¶
const ( // DefaultMailClaimTTL is five minutes, chosen for the slowest channel this // queue has, which is mail. DefaultMailClaimTTL = 5 * time.Minute // MinMailClaimTTL is the shortest lease [Service] will accept. Under it, // every message a slow provider is still sending is claimed a second time // under ordinary load, and at-least-once stops being a crash property. MinMailClaimTTL = time.Minute // DefaultMailSendTimeout bounds one call into a notifier. DefaultMailSendTimeout = 30 * time.Second // DefaultMailMaxAttempts, DefaultMailBackoffBase and DefaultMailBackoffCap // span about eight hours: 1m, 2m, 4m, 8m, 16m, 32m, then hourly. An outage // is measured in hours by everyone who has had one. DefaultMailMaxAttempts = 14 DefaultMailBackoffBase = time.Minute DefaultMailBackoffCap = time.Hour )
Defaults for the mail queue. Every one of them is notify's number, and the pairing is deliberate: an operator tuning one dispatcher should not have to learn a second set of arithmetic for the other.
const ( // DefaultMinDuration pads a login. Verifying a real hash and verifying the // dummy take about the same time, but "about" is measurable over enough // samples; a floor makes the remaining difference noise. DefaultMinDuration = 750 * time.Millisecond // DefaultResetTTL is short: a reset link is a live credential. DefaultResetTTL = time.Hour // DefaultVerificationTTL is longer, because confirming an address is not // urgent and a link that expires while somebody is at lunch is a support // ticket. DefaultVerificationTTL = 24 * time.Hour // DefaultInvitationTTL is longer again: an invitation sent on a Friday should // still work on Monday, and the cost of it expiring is somebody having to ask // a colleague to send another. DefaultInvitationTTL = 7 * 24 * time.Hour )
Defaults.
const PermissionProvision = "account.provision"
PermissionProvision is what a caller needs to create an account.
The name is this package's because the endpoint is, but it is an ordinary permission key: grant it to a role, or put it in an API key's scopes, and the same check answers either.
Variables ¶
var ErrInvalidCredentials = rigerr.Unauthorized("the email address or password is not correct")
ErrInvalidCredentials is the answer to every failed sign-in.
One error for a wrong password and for an address nobody has ever registered, because the difference is exactly what an attacker is trying to learn.
Functions ¶
func DefaultSlug ¶
DefaultSlug is the derivation an application gets without asking.
Lowercased, non-alphanumerics collapsed to a hyphen, and the tenant's own identifier appended. The suffix is not decoration: the column is unique, two customers called Acme are ordinary, and a slug that collided would fail the insert of the second one.
func DomainAllowed ¶
DomainAllowed reports whether an address may be used in a tenant.
An empty list allows anything, because most tenants are not one company and a list that has to be filled in before anybody can be added would be a worse default than no list at all. A listed domain matches its subdomains too: somebody who allows example.com means the company, and mail.example.com is the company.
func IsPermanentMailError ¶
IsPermanentMailError reports whether err asks not to be retried. It reads through wrapping.
func MailRetryAfterOf ¶
MailRetryAfterOf is the interval err asks to be retried after, and whether it asked at all.
A permanent error answers false even when RetryMailAfter was also applied: "do not retry" and "retry then" cannot both be honoured, and refusing is the stronger of the two. Reading them the other way round would let a notifier that wrapped both keep alive a delivery it had already called hopeless.
func PermanentMailError ¶
PermanentMailError wraps an error a retry cannot fix, so the delivery stops on this attempt rather than spending the rest of its budget.
The case it is for is a provider refusing the recipient rather than the request: an address that does not exist, a mailbox that is full for good, a message the provider will never accept. It is also what makes an eight-hour schedule affordable — without a way to say "this address is wrong", every dead mailbox would occupy a row for a working day.
Use it only when the answer is about the recipient. A 500, a timeout and a 429 are all about the provider, and wrapping one of those turns a bad ten minutes into a password nobody can reset.
PermanentMailError(nil) is nil. That is not a courtesy: a pass decides a send succeeded by testing the error against nil, so a helper that manufactured a non-nil wrapper from a nil error would record every delivered mail as a permanent failure. This is notify.Permanent's twin.
func RetryMailAfter ¶
RetryMailAfter wraps an error with the earliest a retry is worth making, which is most often a 429 carrying a Retry-After header.
The interval replaces the computed backoff for this attempt only, so a provider asking for ten minutes once does not reset where the doubling had got to. It is honoured as a floor rather than exactly, because a batch of rows carrying one provider's boundary cannot all return at that boundary without rebuilding the herd it was asked to prevent. It does not extend the attempt budget.
RetryMailAfter(nil, d) is nil, and a non-positive d is a plain wrap. This is notify.RetryAfter's twin.
Types ¶
type AcceptInput ¶
type AcceptInput struct {
// Token is the single-use secret from the invitation mail. It is how somebody
// who is not signed in redeems one.
Token string
// InvitationID names the invitation instead, for [Service.AcceptAsMe], where
// the caller is signed in as the person it was addressed to. Exactly one of
// the two is used, decided by which method is called.
InvitationID uuid.UUID
// Password sets a first password, and is required only for somebody who has
// none — an invitation to their first tenant. Somebody who already signs
// in here is joining a second one, and their existing password is the one
// that works; a field that quietly replaced it would let an invitation to any
// tenant change the credential for all of them.
Password string
Client session.Client
IPAddress string
UserAgent string
}
AcceptInput redeems an invitation.
type Account ¶
type Account struct {
ID uuid.UUID
TenantID uuid.UUID
// IdentityID is the person this account belongs to, and nil exactly when
// this is a service account — which is nobody, has no credential, and cannot
// sign in.
IdentityID *uuid.UUID
// EmailAddress is a copy of the identity's, kept here so that listing the
// people in a tenant is one query. For a service account it is a label.
EmailAddress string
DisplayName string
// Kind is whether this is a person or a service account an integration acts
// as. A service account has no credential and cannot sign in, which Login
// enforces rather than leaving to whoever wrote the row.
Kind Kind
// Role is the coarse level in this tenant: Owner, Admin or Basic. Finer
// grants come from roles and permissions, and both reach a caller through
// their claims. It is per account rather than per identity, so somebody can
// be an Owner in one tenant and Basic in another.
Role Role
// TimeZone is an IANA name, for example Europe/Stockholm. Empty means UTC.
//
// It lives on the account because it is a property of the person rather than
// of a request: a report somebody schedules for 9am means 9am where they are,
// and a browser's offset is not available when nobody is looking at one.
TimeZone string
// IsActive is whether the account may be used. It is narrower than the
// identity's: this removes somebody from one tenant.
IsActive bool
// CreatedBy and CreatedByKey are who asked for this account, for the audit
// columns. Both are optional: rig itself creates accounts during a seed with
// nobody to name.
CreatedBy *uuid.UUID
CreatedByKey *uuid.UUID
}
Account is one person inside one tenant.
It carries only what authentication needs. Everything else about somebody — their preferences, their avatar, whatever the application is actually for — lives in the generated model, which is the same row read through a different lens.
type ChangePasswordInput ¶
type ChangePasswordInput struct {
TenantID uuid.UUID
AccountID uuid.UUID
CurrentPassword string
NewPassword string
Client session.Client
IPAddress string
UserAgent string
}
ChangePasswordInput is a deliberate password change.
type Config ¶
type Config struct {
Store Store
Sessions *session.Manager
// Identities issues the tenant-less credential a tenant picker runs on.
// Required: a sign-in always produces one, including for somebody who lands
// straight in a tenant, because switching later is the same flow.
Identities *session.IdentityManager
Hasher *password.Hasher
Policy password.Policy
Log authlog.Log
Notifier Notifier
// Outbox turns mail queueing on, and its presence is the whole switch.
//
// Nil is the inline path this package shipped with, byte for byte: a link is
// minted in the request that asked for it and handed straight to the
// Notifier. That is the default, and it has to be — queueing by default would
// mean every existing deployment upgrades, keeps passing its Notifier, does
// not add the cron job, and silently stops sending mail. That is a worse
// outage than the one the queue fixes.
//
// Set it and a link is written to rig_identity_verification_delivery instead,
// in the transaction that asked for it, and sent by [Service.DispatchMail] —
// which something has to run. See [Outbox].
//
// The trade to know about before turning it on: a reset mail now arrives up
// to one dispatch interval late, where inline it was sent inside the request.
// What is bought is that a provider having a bad hour no longer fails the
// request, spends the caller's rate-limit budget, and kills a token that was
// already minted.
Outbox Outbox
// Mail is what the queue runs on. Every field is optional, and it is read
// only when Outbox is set.
Mail MailOptions
// Tenants is what an application decides about making tenants: who may, what
// a name may be, and what else a new one needs. Every field is optional; the
// zero value lets anybody signed in make one called anything.
Tenants TenantOptions
// OnRegistered runs inside the transaction that creates a self-registered
// identity — after the person and their credential exist, before the
// identity session is issued. Returning an error rolls the whole sign-up
// back, so a retry is a clean retry rather than a conflict with a half-made
// account.
//
// It receives the service because the ordinary body is a call back into it —
// [Service.Provision] with Invite set, bringing the newcomer into a starter
// tenant — and the closure is handed to [New] before the service exists.
// Reach the transaction itself with dbx.Tx(ctx), the same way a generated
// repository does.
//
// Nil, the default, registers the person and nothing else.
OnRegistered func(ctx context.Context, accounts *Service, in Registered) error
// Limiter and Limits are what stop somebody guessing. A service built
// without a limiter refuses to exist: a login endpoint with no lockout is
// not a login endpoint, it is a password oracle with a queue.
Limiter *throttle.Limiter
Limits throttle.Defaults
MinDuration time.Duration
ResetTTL time.Duration
VerificationTTL time.Duration
// InvitationTTL bounds an invitation. It is the longest of the three by
// default: somebody invited on a Friday should still be able to join on
// Monday, and an expired invitation means asking a colleague to send another.
InvitationTTL time.Duration
// RequireVerifiedEmail refuses a sign-in until the address is confirmed.
RequireVerifiedEmail bool
Now func() time.Time
Sleep func(ctx context.Context, d time.Duration)
}
Config builds a service.
type CreateTenantInput ¶
type CreateTenantInput struct {
// IdentityID is the person who will own it, established by their identity
// session.
IdentityID uuid.UUID
Name string
// AllowedEmailDomains restricts later provisioning into it.
AllowedEmailDomains []string
IPAddress string
UserAgent string
}
CreateTenantInput makes a tenant for somebody who already exists.
type Creator ¶
type Creator struct {
IdentityID uuid.UUID
// EmailAddress is theirs, lowercased — which is what a domain rule wants.
EmailAddress string
DisplayName string
}
Creator is who is asking to make a tenant.
type Credential ¶
type Credential struct {
ID uuid.UUID
IdentityID uuid.UUID
PasswordHash string
Algorithm string
Params password.Params
CreatedAt time.Time
UpdatedAt *time.Time
}
Credential is a person's password.
One per identity rather than one per account, so somebody in three tenants has one password to remember and one password to change.
type DeleteAccountInput ¶
type DeleteAccountInput struct {
TenantID uuid.UUID
AccountID uuid.UUID
At time.Time
// ByAccountID and ByAPIKeyID are who did it, for the audit columns — who,
// and through what.
ByAccountID *uuid.UUID
ByAPIKeyID *uuid.UUID
}
DeleteAccountInput removes somebody from a tenant.
type Delivery ¶
type Delivery struct {
ID uuid.UUID
// VerificationID is the link this is the delivery of. Its token is rotated
// before every attempt, so exactly one is live at a time.
VerificationID uuid.UUID
// Kind is which [Notifier] method to call, copied from the verification so a
// claim needs no join.
Kind VerificationKind
State DeliveryState
DeliverAt time.Time
// Attempts is how many times this has been claimed, including this one.
Attempts int
}
Delivery is one queued link, on its way.
type DeliveryState ¶
type DeliveryState string
DeliveryState is where one queued link is in its life.
const ( // DeliveryPending is owed and not yet claimed, or claimed by a process that // has not marked it. DeliveryPending DeliveryState = "Pending" // DeliverySent means the notifier accepted it, which is not the same as it // arriving — and rig does not pretend to know the difference. DeliverySent DeliveryState = "Sent" // DeliveryFailed is past MaxAttempts, or refused permanently, and stops // being claimed. DeliveryFailed DeliveryState = "Failed" // DeliverySkipped is a link that was consumed or withdrawn between being // queued and being sent. // // It is the state this queue buys that the inline path could not have: today // a withdrawn invitation cannot un-send a mail that is already in flight, // and here the rotate step finds the row already settled and sends nothing. DeliverySkipped DeliveryState = "Skipped" )
type Identity ¶
type Identity struct {
ID uuid.UUID
// EmailAddress is stored as the person typed it and matched lowercased.
// Showing somebody a differently-cased version of their own address is a
// small rudeness with no upside. It is unique across every tenant.
EmailAddress string
// DisplayName is what to call the person before any tenant has an opinion.
// An account may override it, and mail uses this one.
DisplayName string
// IsActive is whether the person may sign in anywhere at all. An account's
// own IsActive is narrower: it removes somebody from one tenant.
IsActive bool
EmailVerifiedAt *time.Time
// CreatedBy and CreatedByKey are who asked for this identity, for the audit
// columns. Both are optional: a seed creates identities with nobody to name.
CreatedBy *uuid.UUID
CreatedByKey *uuid.UUID
}
Identity is a person who can sign in, independent of where they work.
One address, one password, one set of linked providers, no tenant. Somebody who belongs to two tenants has one identity and two accounts, and the identity is what a password, a reset link and a provider link all belong to.
type ImpersonateInput ¶
type ImpersonateInput struct {
TenantID uuid.UUID
// AdministratorID is who is really doing this. It travels with the session
// through every rotation and lands in every audit entry the session writes.
AdministratorID uuid.UUID
AccountID uuid.UUID
IPAddress string
UserAgent string
}
ImpersonateInput starts a session as somebody else.
type Invitation ¶
type Invitation struct {
ID uuid.UUID
IdentityID uuid.UUID
AccountID uuid.UUID
TenantID uuid.UUID
// TenantName is which tenant it is into. An invitation listed to the
// person receiving it is otherwise a row of identifiers: they have not been
// there yet, so the name is the only part they recognise.
TenantName string
EmailAddress string
DisplayName string
Role Role
CreatedAt time.Time
ExpiresAt time.Time
}
Invitation is a live invitation, with enough about the person to show it.
type LoginInput ¶
type LoginInput struct {
// TenantID says which tenant the session is for, and may be left empty.
//
// Set it when the application already knows — a subdomain, a header, a path
// segment — and the sign-in is refused unless the person belongs to that
// tenant. Leave it empty and the person is signed in to one of their own
// tenants, which is what a single sign-in page wants: nobody knows which
// tenants an address belongs to until the password is checked, so asking
// first is asking a question the visitor cannot answer.
//
// Either way the address is not looked up in a tenant: an address is one
// person across every tenant, and the tenant only decides which of that
// person's accounts the session belongs to.
TenantID uuid.UUID
EmailAddress string
Password string
Remember bool
Client session.Client
IPAddress string
UserAgent string
}
LoginInput is a sign-in attempt.
type MailOptions ¶
type MailOptions struct {
// ClaimTTL is how long this process's claim is honoured before another
// dispatcher may take the row. Zero means [DefaultMailClaimTTL]; under
// [MinMailClaimTTL] is refused.
ClaimTTL time.Duration
// SendTimeout bounds one call into a [Notifier]. Zero means
// [DefaultMailSendTimeout]; ClaimTTL or longer is refused.
//
// It is the deadline on the context the notifier is handed, and it is
// cooperative: a notifier that ignores it hangs its dispatcher anyway.
SendTimeout time.Duration
// MaxAttempts, BackoffBase and BackoffCap are the retry arithmetic. Zero
// means the defaults, which span about eight hours.
MaxAttempts int
BackoffBase time.Duration
BackoffCap time.Duration
// Jitter is where the spread on a retry comes from: given n it returns
// something in [0, n). It exists to be replaced in a test and for nothing
// else. Nil means math/rand/v2's.
Jitter func(int64) int64
}
MailOptions is what the queue runs on, and every field is optional.
The four numbers are the same four notify's engine takes, with the same defaults and the same refusals, because they are the same decision made twice in two modules that cannot import each other — rig/auth depends on rig/runtime and not on rig/notify. Where notify states the reason at length, this file names the file it is agreeing with.
type MailReport ¶
type MailReport struct {
// Claimed is how many rows this pass took, and Sent how many a notifier
// accepted.
Claimed int
Sent int
// Failed is how many reached MaxAttempts and stopped.
Failed int
// Rejected is how many a notifier refused permanently, with
// [PermanentMailError], and which stopped on this attempt with the rest of
// their schedule unspent.
Rejected int
// Retrying is how many failed this time and will be tried again.
Retrying int
// Deferred is how many were rescheduled at a notifier's own request rather
// than on the computed backoff.
Deferred int
// Skipped is how many had nothing left to send: a link consumed, withdrawn,
// or belonging to somebody who has since been deactivated.
//
// A steady non-zero one is not a problem. It is invitations being accepted
// or withdrawn faster than the dispatcher runs, which is the queue working.
Skipped int
// Released is how many claims a clean shutdown gave back rather than leaving
// to expire, and Abandoned how many the pass could not reach inside its
// lease.
Released int
Abandoned int
}
MailReport is what one dispatch pass did.
Every count, including the zeros, for the reason notify's report gives: a pass that sent nothing is the ordinary case and still worth seeing, because the absence of a line cannot be told from the job not running.
func (MailReport) String ¶
func (r MailReport) String() string
String is the one line a pass is worth in a log: every count, zeros included.
type Membership ¶
type Membership struct {
TenantID uuid.UUID
TenantName string
TenantSlug string
AccountID uuid.UUID
Role Role
IsActive bool
}
Membership is one tenant a person belongs to, and who they are in it.
The pair is the point: the tenant is where, and the account is who — the same person is an Owner in one tenant and Basic in another, and a session is issued for one account rather than for the person.
Named for the relationship rather than for either end, because Tenant is the row and this is not: two people in one tenant are two memberships.
type MemoryOutbox ¶
type MemoryOutbox struct {
// FailedReason is what the notifier last said about each delivery, exposed
// so a test can assert on the sentence rather than only on the state.
FailedReason map[uuid.UUID]string
// contains filtered or unexported fields
}
MemoryOutbox is an Outbox over a map, for tests and for development.
It is the same kind of double MemoryStore is, with the same rule: a double that disagrees with the real store about the property under test is worse than no double. So the claim charges an attempt before anything is sent, the lease is honoured by wall-clock comparison rather than ignored, and RotateToken refuses a link that is already settled — those three are what the queue's correctness rests on, and a version that skipped them would prove nothing.
What it does not have is concurrency across processes. One mutex is not SKIP LOCKED, and the tests that care about two dispatchers racing are the Docker ones.
func NewMemoryOutbox ¶
func NewMemoryOutbox(store *MemoryStore) *MemoryOutbox
NewMemoryOutbox builds one over a store, which it needs because rotating a token writes to the verification the delivery owns.
func (*MemoryOutbox) Abandon ¶
Abandon implements Outbox.
The attempt goes back with the claim. The claim charged every row in the batch before anything was sent, so a row released without a send would have paid for one it never got — and MaxAttempts of those would fail a delivery no notifier had ever been asked about.
func (*MemoryOutbox) Claim ¶
func (o *MemoryOutbox) Claim(_ context.Context, by uuid.UUID, now time.Time, ttl time.Duration, limit int) ([]Delivery, error)
Claim implements Outbox.
func (*MemoryOutbox) Deliveries ¶
func (o *MemoryOutbox) Deliveries() []Delivery
Deliveries is every row, for a test that wants to assert on the queue itself.
func (*MemoryOutbox) Enqueue ¶
func (o *MemoryOutbox) Enqueue(_ context.Context, d *Delivery) error
Enqueue implements Outbox.
func (*MemoryOutbox) MarkFailed ¶
func (o *MemoryOutbox) MarkFailed(_ context.Context, id uuid.UUID, reason string, _ time.Time) error
MarkFailed implements Outbox.
func (*MemoryOutbox) MarkSkipped ¶
func (o *MemoryOutbox) MarkSkipped(_ context.Context, id uuid.UUID, reason string, _ time.Time) error
MarkSkipped implements Outbox.
func (*MemoryOutbox) ReleaseClaims ¶
ReleaseClaims implements Outbox.
The attempts stay, which is the difference from Abandon: the sends this pass made were made.
type MemoryStore ¶
type MemoryStore struct {
// Domains are the allowed email domains per tenant, for the tests that care
// about provisioning. Absent means no restriction, which is what an ordinary
// tenant has.
Domains map[uuid.UUID][]string
// TenantNames name the tenants a tenant list reports.
TenantNames map[uuid.UUID]string
// contains filtered or unexported fields
}
MemoryStore is an in-process Store, for tests.
func (*MemoryStore) AccountForIdentity ¶
func (s *MemoryStore) AccountForIdentity(_ context.Context, tenantID, identityID uuid.UUID) (*Account, error)
AccountForIdentity implements Store.
func (*MemoryStore) AccountsForIdentity ¶
func (s *MemoryStore) AccountsForIdentity(_ context.Context, identityID uuid.UUID) ([]*Account, error)
AccountsForIdentity implements Store.
Oldest first, which is not decoration: signing in without naming a tenant puts somebody in the one they joined first, so the order *is* the contract.
func (*MemoryStore) ConsumeVerification ¶
func (s *MemoryStore) ConsumeVerification(_ context.Context, id uuid.UUID, at time.Time) (bool, error)
ConsumeVerification implements Store.
func (*MemoryStore) CountVerifications ¶
func (s *MemoryStore) CountVerifications() int
CountVerifications is how many link rows exist, for the test that says a retry writes none.
func (*MemoryStore) CreateVerification ¶
func (s *MemoryStore) CreateVerification(_ context.Context, v *Verification) error
CreateVerification implements Store.
func (*MemoryStore) Credential ¶
func (s *MemoryStore) Credential(_ context.Context, identityID uuid.UUID) (*Credential, error)
Credential implements Store.
func (*MemoryStore) DeactivateIdentity ¶
func (s *MemoryStore) DeactivateIdentity(id uuid.UUID)
DeactivateIdentity switches somebody off, for the test that says a deactivated person is not mailed a working link.
func (*MemoryStore) FindIdentityByEmail ¶
FindIdentityByEmail implements Store.
func (*MemoryStore) FindIdentityByID ¶
FindIdentityByID implements Store.
func (*MemoryStore) Insert ¶
func (s *MemoryStore) Insert(ctx context.Context, a *Account) error
Insert implements Store.
func (*MemoryStore) InsertIdentity ¶
func (s *MemoryStore) InsertIdentity(_ context.Context, i *Identity) error
InsertIdentity implements Store.
func (*MemoryStore) InsertTenant ¶
func (s *MemoryStore) InsertTenant(_ context.Context, t *Tenant) error
InsertTenant implements Store.
func (*MemoryStore) InvitationsForIdentity ¶
func (s *MemoryStore) InvitationsForIdentity(_ context.Context, identityID uuid.UUID) ([]Invitation, error)
InvitationsForIdentity implements Store.
func (*MemoryStore) MarkIdentityVerified ¶
func (s *MemoryStore) MarkIdentityVerified(_ context.Context, identityID uuid.UUID, at time.Time) error
MarkIdentityVerified implements Store.
func (*MemoryStore) PendingInvitations ¶
func (s *MemoryStore) PendingInvitations(_ context.Context, tenantID uuid.UUID) ([]Invitation, error)
PendingInvitations implements Store.
func (*MemoryStore) PutIdentity ¶
func (s *MemoryStore) PutIdentity(i *Identity)
PutIdentity adds a person.
func (*MemoryStore) PutPerson ¶
func (s *MemoryStore) PutPerson(i *Identity, a *Account)
PutPerson adds a person and their account in one call, which is what almost every test wants: an identity with nobody in it, or an account behind nobody, is a state the flows are entitled to refuse.
func (*MemoryStore) RevokeVerification ¶
func (s *MemoryStore) RevokeVerification(_ context.Context, id uuid.UUID, at time.Time) (bool, error)
RevokeVerification implements Store.
func (*MemoryStore) SaveCredential ¶
func (s *MemoryStore) SaveCredential(_ context.Context, c *Credential) error
SaveCredential implements Store.
func (*MemoryStore) SoftDeleteAccount ¶
func (s *MemoryStore) SoftDeleteAccount(_ context.Context, in DeleteAccountInput) error
SoftDeleteAccount implements Store.
func (*MemoryStore) TenantDomains ¶
TenantDomains implements Store.
func (*MemoryStore) TenantsForIdentity ¶
func (s *MemoryStore) TenantsForIdentity(_ context.Context, identityID uuid.UUID) ([]Membership, error)
TenantsForIdentity implements Store.
TenantNames is how a test gives a tenant a name; an absent one is reported as its identifier, which is enough for the flows that only care about the set.
func (*MemoryStore) VerificationByHash ¶
func (s *MemoryStore) VerificationByHash(_ context.Context, hash []byte) (*Verification, error)
VerificationByHash implements Store.
func (*MemoryStore) VerificationByID ¶
func (s *MemoryStore) VerificationByID(_ context.Context, id uuid.UUID) (*Verification, error)
VerificationByID implements Store.
type NewTenant ¶
type NewTenant struct {
TenantID uuid.UUID
TenantName string
TenantSlug string
// AccountID is the first account, which holds OwnerRole.
AccountID uuid.UUID
IdentityID uuid.UUID
}
NewTenant is what was created.
type NoNotifier ¶
type NoNotifier struct{}
NoNotifier drops every link, silently and successfully.
It is the default so that a manager can be built in one line during development. In production it means nobody can ever reset a password, and nothing anywhere says so — this type is substituted for a nil Notifier without a warning, which is a thing to know rather than a thing to rely on. Compare notify.NoSender, which refuses instead, having been written after somebody met this failure.
New does refuse one combination: a Config.Outbox with this as the notifier is a queue whose rows are written and then dropped, which is a table that grows forever behind mail that never goes.
func (NoNotifier) SendEmailVerification ¶
SendEmailVerification implements Notifier.
func (NoNotifier) SendInvitation ¶
SendInvitation implements Notifier.
func (NoNotifier) SendPasswordReset ¶
SendPasswordReset implements Notifier.
type Notifier ¶
type Notifier interface {
SendPasswordReset(ctx context.Context, i *Identity, token string) error
SendEmailVerification(ctx context.Context, i *Identity, token string) error
SendInvitation(ctx context.Context, i *Identity, a *Account, token string) error
}
Notifier delivers the links this package mints.
Sending mail is the application's business: it knows the templates, the sender, the locale, and whether it uses a queue. What rig knows is when a link exists and what it says. It takes an identity rather than an account because that is what a link is about: the address being confirmed and the password being reset belong to the person, not to one of the tenants they work in.
An invitation is the exception and takes the account too, because it is about one tenant: the mail has to say which tenant somebody is being invited to, and "you have been invited" with no answer to "invited where" is a mail nobody can act on.
**Where this is called from depends on Config.Outbox.** Nil and it is the request that asked for the link, so a slow provider is a slow page. Set and it is Service.DispatchMail, so a slow provider costs a lease and nothing else. The signature is the same either way, and turning the queue on changes no mail code.
**The context carries a deadline when the queue is on** — MailOptions.SendTimeout, thirty seconds by default — and honouring it is this method's job. rig cannot enforce it: what happens inside these calls is application code. Pass ctx to the request, and do not hand a provider SDK an http.Client with no timeout of its own, which is Go's default.
**Do not deduplicate these at the provider.** It is otherwise good advice, and it is wrong here: every attempt carries a *different* token, because the queue rotates the secret before each send and the previous one stops working. Suppressing the second mail as a duplicate delivers a link that does not work. This is the one seam in rig where an idempotency key is the wrong answer — notify.Delivery.ID says the opposite, and means it.
type Outbox ¶
type Outbox interface {
// Enqueue writes one delivery.
//
// It has to join the caller's transaction when there is one. The verification
// row and this row are written together, and a verification without its
// delivery is an orphan link nobody will ever mail — invisible, except as an
// invitation in a listing that was never sent.
Enqueue(ctx context.Context, d *Delivery) error
// Claim takes up to limit due rows and stamps this process's lease on them,
// charging each an attempt.
//
// The lease is what makes a crashed dispatcher recoverable: a row is still
// Pending with a stale claim, and the next pass past it takes it. It must
// skip rows another claimant is taking rather than queueing behind them.
Claim(ctx context.Context, by uuid.UUID, now time.Time, ttl time.Duration, limit int) ([]Delivery, error)
// RotateToken writes a fresh hash and expiry onto the verification a delivery
// owns, and reports whether it changed anything.
//
// False means the link was consumed or withdrawn between being queued and
// being sent, and the delivery is Skipped rather than sent or failed. It must
// not touch a row that is already settled — that check and this write are one
// statement, or two requests racing can both believe they hold the live
// token.
RotateToken(ctx context.Context, verificationID uuid.UUID, hash []byte, expiresAt time.Time) (bool, error)
// MarkSent records that a notifier accepted a delivery.
MarkSent(ctx context.Context, id uuid.UUID, at time.Time) error
// MarkFailed records that a delivery is done being tried, with what the
// notifier said last.
MarkFailed(ctx context.Context, id uuid.UUID, reason string, at time.Time) error
// MarkSkipped records that there was nothing left to send.
MarkSkipped(ctx context.Context, id uuid.UUID, reason string, at time.Time) error
// Retry puts a delivery back with a later deliver_at, keeping what the
// notifier said so a pattern is visible before the cap is reached.
Retry(ctx context.Context, id uuid.UUID, at time.Time, reason string, now time.Time) error
// Abandon gives back the claim and the attempt for a row a pass never
// reached. It must only affect rows this process still holds.
Abandon(ctx context.Context, ids []uuid.UUID, by uuid.UUID, now time.Time) error
// ReleaseClaims gives back every lease this process holds, keeping the
// attempts — a shutdown gives up sends it made, not sends it never made.
ReleaseClaims(ctx context.Context, by uuid.UUID, now time.Time) (int, error)
}
Outbox is the queue's persistence, and it is the same kind of short adapter over generated repositories that Store is.
Setting one on Config is what turns queueing on. Nil is the inline path this package shipped with, unchanged — see Config.Outbox for why that is the default and has to be.
type ProvisionInput ¶
type ProvisionInput struct {
TenantID uuid.UUID
EmailAddress string
DisplayName string
// Kind defaults to a person. Pass KindService for the account an
// integration's key acts as — it gets no identity at all, so there is nothing
// to sign in with and nothing to phish.
Kind Kind
// Role defaults to Basic, and applies to this tenant only. Whether the
// caller may hand out a higher one is the application's rule: this package
// will set what it is told.
Role Role
// TimeZone is an IANA name, optional.
TimeZone string
// ByAccountID and ByAPIKeyID are who asked, for the audit columns. The HTTP
// layer fills them from the claims; a caller reaching this directly should
// fill them too, or the row will say a change came from nobody.
ByAccountID *uuid.UUID
ByAPIKeyID *uuid.UUID
// Invite sends a verification link, so the person can set a password and
// arrive. Without a Notifier it does nothing, which is why it is a request
// rather than an assumption.
Invite bool
}
ProvisionInput describes somebody to create an account for.
type RegisterInput ¶
type RegisterInput struct {
EmailAddress string
DisplayName string
Password string
IPAddress string
UserAgent string
}
RegisterInput creates a person, and nothing else.
type Registered ¶
type Registered struct {
IdentityID uuid.UUID
// EmailAddress as typed, trimmed. Lowercase it yourself for comparisons —
// the address is one person's name for themselves, and the cased form is
// what they wrote.
EmailAddress string
// DisplayName as resolved: what was sent, or the address's local part when
// nothing was.
DisplayName string
IPAddress string
UserAgent string
}
Registered is somebody who just signed themselves up, as Config.OnRegistered sees them.
type RevokeInput ¶
type RevokeInput struct {
// TenantID is the caller's, and it bounds what can be withdrawn: an
// invitation belongs to one tenant, and naming one from another tenant has to
// answer the same way as naming one that does not exist.
TenantID uuid.UUID
InvitationID uuid.UUID
// ByAccountID and ByAPIKeyID are who did it, for the audit columns.
ByAccountID *uuid.UUID
ByAPIKeyID *uuid.UUID
}
RevokeInput withdraws an invitation.
type Role ¶
type Role string
Role is an account's coarse level.
Three, because almost every product ends up with these three and inventing a permission taxonomy on day one is how a project ends up with fourteen. The role and permission tables are there for the day three is not enough.
const ( // RoleOwner may do anything, including the things that end the account: // billing, deleting the tenant, removing the last other owner. RoleOwner Role = "Owner" // RoleAdmin administers the tenant — inviting, configuring, managing keys — // without the decisions that are the owner's to make. RoleAdmin Role = "Admin" // RoleBasic gets on with the work. RoleBasic Role = "Basic" )
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the sign-in flows.
func (*Service) AcceptAsMe ¶
func (s *Service) AcceptAsMe(ctx context.Context, identityID uuid.UUID, in AcceptInput) (session.Pair, error)
AcceptAsMe redeems an invitation for somebody already signed in.
The picker's door, where the token-based one is the mailbox's. It takes the invitation's identifier and the identity behind an identity session, and that is a stronger claim than the token rather than a weaker one: a token proves somebody reached the address it was sent to, and a session proves who they are, established by a password. Requiring the emailed link from a caller already signed in as the person invited would add nothing — which is why a listing can safely hand out identifiers and never tokens.
With Config.Outbox set this accepts an invitation whose mail has not gone out yet, because a queued link has no token and this door does not need one. That is the right answer: the person is signed in as themselves and is looking at the invitation in their own listing, and refusing until a cron job had run would be refusing on a detail they cannot see.
func (*Service) AcceptInvitation ¶
AcceptInvitation redeems an invitation and returns a session for the account it was for.
One round trip, which is the whole reason it exists. Without it an invitation is a verification link followed by the forgotten-password flow, and asking somebody to reset a password they have never had is a sign-up that loses people at the last step.
It also confirms the address, because the link is the proof: it went to that address and came back.
func (*Service) ChangePassword ¶
ChangePassword replaces a password for somebody who knows the old one, and returns a fresh session.
The old sessions are all revoked, including the one making the request, and a new pair is issued in their place. Anything else forces a choice between signing the person out of the tab they are looking at and leaving a thief signed in on another continent.
func (*Service) ConfirmPasswordReset ¶
ConfirmPasswordReset sets a new password from a reset link.
func (*Service) CreateTenant ¶
CreateTenant makes a tenant and puts one account in it as its Owner.
One transaction for the tenant, the account and whatever OnCreated adds, so a failure leaves nothing behind: a tenant with no owner is unreachable, and an owner with no role can sign in and do nothing.
It does not create the person. That is Service.Register or an invitation, and keeping them apart is what makes "sign in, then choose where to be" a flow rather than one form that does everything.
func (*Service) DispatchMail ¶
func (s *Service) DispatchMail(ctx context.Context) (report MailReport, err error)
DispatchMail is one pass: claim what is due, send it, mark it.
A pass that fails part way through is a pass. The rows it did not reach are still Pending, the rows it claimed and did not mark come back when their lease expires, and the next pass takes both.
It answers an empty report and no error for a service with no Config.Outbox, so a project on the inline path can register the task and change nothing else.
The report is a named result because [Service.releaseMail] runs from a defer and adds to it: an unnamed one would be copied before the defer ran, and the count of leases a failed pass handed back would be zero in every log line that could carry it.
func (*Service) EndImpersonation ¶
EndImpersonation revokes an impersonating session and records that it ended.
func (*Service) HasPassword ¶
HasPassword reports whether a person has a password at all.
Somebody who only ever signed in through a provider has none, and neither does somebody who has been invited and not yet arrived. It is here so that a caller setting a first password can tell the difference without reading the hash: an application that fetched the credential to check would be an application holding a hash it has no use for.
func (*Service) Impersonate ¶
Impersonate issues a session for another account, marked as what it is.
The permission check belongs to the caller: this package does not know what an administrator is. What it guarantees is that the session cannot pretend to be an ordinary one, and that both ends are recorded.
func (*Service) Invitations ¶
Invitations are the live invitations into a tenant.
Live means minted and still redeemable: not accepted, not withdrawn, not expired. It is what an interface lists so that somebody can change their mind, and it comes from the database rather than from whatever the notifier remembers — a link that was accepted an hour ago is not pending, and only the row knows that.
func (*Service) Login ¶
func (s *Service) Login(ctx context.Context, in LoginInput) (SignInResult, error)
Login signs somebody in.
func (*Service) MyInvitations ¶
MyInvitations are the live invitations addressed to one person, in every tenant.
Keyed on the identity rather than on a tenant and an account, because the caller has neither: this is what somebody sees before they belong anywhere. The administrator's view of the same table is Service.Invitations, which is scoped to one tenant.
func (*Service) MyTenants ¶
MyTenants are the tenants one person belongs to.
The same list Service.Tenants answers, reached from an identity instead of from a tenant and an account — which is what a caller holding only an identity session has.
func (*Service) Provision ¶
Provision brings somebody into a tenant.
It finds the person by their address or creates them, then gives them an account in the tenant. An address that already belongs to somebody is reused rather than refused: a person who works at two of your customers is one person, and making them a second identity would mean a second password.
No credential is created, which is the whole point of it being here rather than a POST on the table: somebody who can sign in needs a password set or a provider linked, and both of those are flows with their own rules. What this does is the part that is the same everywhere — check the address, honour the tenant's domain list, refuse a second account in the same tenant, and record who asked.
func (*Service) Refresh ¶
Refresh exchanges a refresh token for a new pair.
The rate limit is on the session family rather than the account: a client looping on refresh is one client misbehaving, and throttling the account would take down the person's other devices along with it.
func (*Service) Register ¶
func (s *Service) Register(ctx context.Context, in RegisterInput) (SignInResult, error)
Register creates an identity with a password and no tenant at all.
The counterpart to Service.Provision, and the difference is who is asking. Provision is an administrator or an integration adding somebody to a tenant they already run: it needs a permission, checks the tenant's allowed domains, and sets no password. This is a stranger signing themselves up, so there is no caller to check and nowhere to put them yet.
What comes back is an identity session — the tenant-less credential — because the next thing that happens is a tenant picker: they look at the invitations waiting for them and either accept one or make a tenant of their own. No tenant session is issued, because there is no tenant.
Whether a stranger may do this at all is the application's decision, the same way creating a tenant is. rig mounts the endpoint only when github.com/simonjanss/rig/auth.Config.AllowRegistration says so.
func (*Service) ReleaseMailClaims ¶
ReleaseMailClaims gives back every lease this process still holds.
The attempts are not given back, and that is the difference from Abandon: the sends this pass made were made, and a shutdown that un-charged them would let a process that crashed repeatedly retry forever.
func (*Service) RequestPasswordReset ¶
func (s *Service) RequestPasswordReset(ctx context.Context, tenantID uuid.UUID, emailAddress, ip string) error
RequestPasswordReset mints a reset link and hands it to the notifier.
It answers the same way whether or not the address exists, and it records the attempt either way — which is what makes the rate limit work: an attacker hammering addresses to see which ones respond differently gets the same answer every time and runs out of budget doing it.
The tenant is only for the log entry. A password belongs to the person, so somebody who has forgotten theirs is not asking about one tenant, and answering "no such address here" from the wrong subdomain would be a puzzle with no solution.
func (*Service) RevokeInvitation ¶
func (s *Service) RevokeInvitation(ctx context.Context, in RevokeInput) error
RevokeInvitation withdraws an invitation and removes the account it was for.
Both halves, because either alone leaves something wrong. Killing only the link leaves an account nobody can ever use, listed among the people in the tenant and blocking a second invitation with a conflict. Removing only the account leaves a live link that would recreate nothing and fail confusingly.
It can only withdraw an invitation that is still pending, which is what keeps it from being a way to delete a colleague: once somebody has accepted, their account is theirs and removing them is a different decision with a different name.
func (*Service) SendEmailVerification ¶
SendEmailVerification mints a confirmation link for the person behind an account.
The account is what a caller has — it is what their claims name — and the address being confirmed is the identity's, so confirming it once counts in every tenant they belong to.
func (*Service) SetPassword ¶
SetPassword replaces a person's password without asking for the old one.
It is for provisioning and for an administrator resetting somebody out of a hole — not for a self-service change, which is Service.ChangePassword. Sessions are revoked either way, in every tenant.
func (*Service) StopClaimingMail ¶
func (s *Service) StopClaimingMail()
StopClaimingMail stops this service taking new deliveries, for a shutdown that wants the pass in flight to finish without a new one starting.
It is read by Service.DispatchMail and nothing else, because claiming is the only thing it stops: a pass already past that point runs to its end, which is the point of calling this rather than closing the pool.
func (*Service) Switch ¶
Switch issues a session for another tenant the same person belongs to.
No password: they have already proved who they are, and the new session is for a different account of the same identity. What it is not is a way to reach a tenant somebody does not belong to — that is the check below, and it is the only thing standing between a switcher and every customer's data.
The old session is left alive. Somebody with two tenants open in two tabs has not asked to be signed out of either.
type SignInResult ¶
type SignInResult struct {
IdentityID uuid.UUID
// TenantID is the tenant the session landed in, and Nil when there was
// none to land in.
TenantID uuid.UUID
// Session is the tenant session, or nil when there is no tenant to be
// in. When a tenant was named it is that one; otherwise the oldest.
Session *session.Pair
// Identity is always issued, including alongside a session: signing in and
// then switching tenants is one flow, and the picker needs a credential
// that outlives whichever tenant was landed on.
Identity session.Issued
// Tenants are every one this person belongs to, so a client can draw the
// picker without a second call.
Tenants []Membership
}
Login verifies a password and starts a session. SignInResult is what a sign-in produced.
Two credentials, because there are two states a signed-in person can be in. Session is the ordinary one, scoped to a tenant, and is what every generated endpoint accepts. Identity is the other: it proves who somebody is and carries no tenant, which is what somebody who belongs to no tenant yet has — and what the tenant picker runs on.
Session is nil exactly when Tenants is empty. That is not a failure any more: an invitation waiting to be accepted is a perfectly good reason to have an account and no tenant, and answering 403 to it made the flow impossible.
type Store ¶
type Store interface {
// FindIdentityByEmail returns the person with a lowercased address, or nil
// when there is none. It looks across every tenant, because an address is one
// person. A missing identity is not an error: it is the ordinary answer to
// somebody mistyping their address.
FindIdentityByEmail(ctx context.Context, lowercased string) (*Identity, error)
// FindIdentityByID returns a person, or nil.
FindIdentityByID(ctx context.Context, id uuid.UUID) (*Identity, error)
// InsertIdentity writes a new person.
InsertIdentity(ctx context.Context, i *Identity) error
// MarkIdentityVerified records that an address was confirmed. The address
// belongs to the person, so confirming it counts in every tenant at once.
MarkIdentityVerified(ctx context.Context, identityID uuid.UUID, at time.Time) error
// FindByID returns an account, or nil.
FindByID(ctx context.Context, tenantID, id uuid.UUID) (*Account, error)
// AccountForIdentity returns a person's account in one tenant, or nil when
// they do not belong to it.
AccountForIdentity(ctx context.Context, tenantID, identityID uuid.UUID) (*Account, error)
// AccountsForIdentity returns every account a person has, in every tenant.
//
// It is what makes a password change mean what it says: the credential is
// global, so ending "every session" has to reach the tenants the request was
// not made from.
AccountsForIdentity(ctx context.Context, identityID uuid.UUID) ([]*Account, error)
// TenantsForIdentity is the same set with the tenant's name attached, for
// showing somebody where they can go.
TenantsForIdentity(ctx context.Context, identityID uuid.UUID) ([]Membership, error)
// Insert writes a new account.
//
// It is separate from the rest because creating one is not a flow this
// package owns end to end: who may join a tenant is a product decision,
// and Provision is the part that is the same everywhere — the address is
// checked, the tenant's domains are honoured, nothing is written twice, and
// no credential comes into existence by accident.
Insert(ctx context.Context, a *Account) error
// InsertTenant writes a tenant row.
InsertTenant(ctx context.Context, t *Tenant) error
// TenantDomains are the email domains this tenant's accounts may use, or
// empty for no restriction.
TenantDomains(ctx context.Context, tenantID uuid.UUID) ([]string, error)
// Credential returns a person's password, or nil when they have none —
// which is the case for somebody who only ever signed in through a
// provider.
Credential(ctx context.Context, identityID uuid.UUID) (*Credential, error)
// SaveCredential creates or replaces a person's password.
SaveCredential(ctx context.Context, c *Credential) error
CreateVerification(ctx context.Context, v *Verification) error
// PendingInvitations are the live invitations into one tenant: minted, not
// redeemed, not withdrawn, not expired. It is what an interface lists so
// somebody can change their mind.
PendingInvitations(ctx context.Context, tenantID uuid.UUID) ([]Invitation, error)
// InvitationsForIdentity are the live invitations addressed to one person,
// across every tenant. The other side of PendingInvitations: that one is an
// administrator looking at their tenant, this is somebody looking at
// where they have been asked to go — which is the only thing they can see
// before they belong anywhere.
InvitationsForIdentity(ctx context.Context, identityID uuid.UUID) ([]Invitation, error)
// RevokeVerification cancels a link. It must be a no-op on one that is
// already consumed or revoked, and report whether it changed anything, so
// two requests racing cannot both claim to have withdrawn it.
RevokeVerification(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)
// SoftDeleteAccount removes somebody from a tenant, recording who did it.
SoftDeleteAccount(ctx context.Context, in DeleteAccountInput) error
// VerificationByHash finds a link by the hash of its token, or nil.
VerificationByHash(ctx context.Context, hash []byte) (*Verification, error)
// VerificationByID finds one by identifier, or nil.
//
// The by-identifier lookup exists for accepting an invitation from the
// picker, where the caller is already signed in as the person it was
// addressed to. The token proves somebody reached the mailbox; a session
// proves who they are, which is the stronger claim of the two — so the
// identifier is enough, and it is the only thing a listing hands out.
VerificationByID(ctx context.Context, id uuid.UUID) (*Verification, error)
// ConsumeVerification marks a link used. It must be a no-op on a link that
// is already consumed, so that two requests racing to redeem one cannot
// both win.
ConsumeVerification(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)
InTx(ctx context.Context, fn func(ctx context.Context) error) error
}
Store is the persistence the flows need.
An application implements it over the generated repositories, which is a short adapter: every method here is one query against a table `rig setup-project` created.
type SwitchInput ¶
type SwitchInput struct {
// TenantID and AccountID are where the caller is now, from their claims.
TenantID uuid.UUID
AccountID uuid.UUID
// ToTenantID is where they want to be.
ToTenantID uuid.UUID
Client session.Client
IPAddress string
UserAgent string
}
SwitchInput moves a session to another tenant.
type Tenant ¶
type Tenant struct {
ID uuid.UUID
Name string
// Slug is the URL-safe name, unique across every tenant.
Slug string
// AllowedEmailDomains restricts who may be provisioned into it. Empty means
// no restriction.
AllowedEmailDomains []string
IsActive bool
}
Tenant is the isolation boundary every generated query scopes by.
Only what creating one needs. A tenant is otherwise not this package's to describe: it is an ordinary table, and an application that wants to read or change one does it through its own code — `auth.expose: [tenant]` in rig.yaml puts it in the generated set for an administration screen.
type TenantDraft ¶
type TenantDraft struct {
// ID is already decided, so a hook can use it — in a slug, or in a row of its
// own keyed on it.
ID uuid.UUID
// Name is as typed, trimmed. A hook may rewrite it.
Name string
// Slug is empty unless a hook fills it in; otherwise it is derived after
// Validate returns.
Slug string
// AllowedEmailDomains restricts who may be provisioned into this tenant
// later. Empty means no restriction.
AllowedEmailDomains []string
// By is who is creating it, for a rule that depends on them.
By Creator
}
TenantDraft is the tenant about to exist, for a hook to check or adjust.
type TenantOptions ¶
type TenantOptions struct {
// Allow decides whether this person may create a tenant at all.
//
// Nil means anybody signed in may. It runs before anything is written and
// before Validate, so a refusal costs one function call.
//
// Allow: func(_ context.Context, by account.Creator) error {
// if !strings.HasSuffix(by.EmailAddress, "@rig.app") {
// return rigerr.Forbidden("only rig.app may create tenants")
// }
// return nil
// }
Allow func(ctx context.Context, by Creator) error
// Validate checks and may rewrite what is about to be created.
//
// It is handed a pointer, so a hook that wants to normalize rather than refuse
// can: title-casing a name, trimming a suffix, filling in a slug. What it
// leaves behind is what gets written.
//
// Validate: func(_ context.Context, t *account.TenantDraft) error {
// if t.Name != strings.Title(t.Name) {
// return rigerr.Invalid("a tenant's name must be capitalised")
// }
// return nil
// }
Validate func(ctx context.Context, t *TenantDraft) error
// Slug derives the URL-safe name. Nil uses [DefaultSlug].
Slug func(name string, id uuid.UUID) string
// OnCreated runs inside the transaction that made the tenant, after the row
// and the first account exist and before the commit.
//
// This is where an application's own tables get their rows: the roles a tenant
// starts with, a default project, a settings record. Returning an error rolls
// the whole thing back, which is the point of it being here rather than after —
// a tenant whose roles failed to seed is a tenant whose Owner can do nothing.
//
// Reach the transaction with [github.com/simonjanss/rig/runtime/dbx.Tx],
// which is the same way a generated repository reaches it:
//
// OnCreated: func(ctx context.Context, made account.NewTenant) error {
// tx, ok := dbx.Tx(ctx)
// if !ok {
// return errors.New("expected a transaction")
// }
// return seedRoles(ctx, tx, made.TenantID, keys)
// }
OnCreated func(ctx context.Context, made NewTenant) error
// OwnerRole is the level the first account gets. Empty means [RoleOwner].
OwnerRole Role
}
TenantOptions are what an application decides about making tenants.
rig owns the mechanism — the tenant row, the first account, the slug, the transaction all of it happens in — because that part is the same everywhere and getting it half-right leaves a tenant nobody can reach. What it does not own is policy: who may make one, what a name is allowed to be, and what else a new tenant needs. Those are hooks, and every one of them is optional.
type Verification ¶
type Verification struct {
ID uuid.UUID
IdentityID uuid.UUID
// InvitedToTenantID is the tenant an invitation is into, and nil for a link
// about the person rather than one tenant — confirming their address or
// resetting their password, both of which are global.
InvitedToTenantID *uuid.UUID
Kind VerificationKind
TokenHash []byte
CreatedAt time.Time
ExpiresAt time.Time
ConsumedAt *time.Time
// RevokedAt is when the link was cancelled, which is not the same as used.
// An invitation somebody withdrew and one somebody accepted are different
// things to find in an audit trail, so the table keeps both — the same
// distinction rig_account_token makes between a rotation and a revocation.
RevokedAt *time.Time
}
Verification is a single-use link.
Only the hash is stored, for the same reason a password is not stored: a reset link is a credential for the few minutes it lives, and a database dump that contains live ones is a database dump that hands over every account.
type VerificationKind ¶
type VerificationKind string
VerificationKind is what a single-use link is for.
const ( // KindEmailVerification confirms an address belongs to whoever gave it. KindEmailVerification VerificationKind = "EmailVerification" // KindPasswordReset lets somebody set a password without knowing the old one. KindPasswordReset VerificationKind = "PasswordReset" // KindInvitation brings a person into a tenant, whether or not they already // have an identity. KindInvitation VerificationKind = "Invitation" )