account

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 19 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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

func DefaultSlug(name string, id uuid.UUID) string

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

func DomainAllowed(lowercasedEmail string, domains []string) bool

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

func IsPermanentMailError(err error) bool

IsPermanentMailError reports whether err asks not to be retried. It reads through wrapping.

func MailRetryAfterOf

func MailRetryAfterOf(err error) (time.Duration, bool)

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

func PermanentMailError(err error) error

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

func RetryMailAfter(err error, d time.Duration) error

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.

func (*Account) Location

func (a *Account) Location() *time.Location

Location is where the account is, for formatting a time.

UTC when the zone is empty or unknown, rather than an error: a bad zone name is a data problem and refusing to render a page over one helps nobody.

func (*Account) Person

func (a *Account) Person() bool

Person reports whether an account belongs to somebody who can sign in.

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.

func (*Identity) Verified

func (i *Identity) Verified() bool

Verified reports whether the address has been confirmed.

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 Kind

type Kind string

Kind is what an account is.

const (
	// KindPerson signs in with a password or a provider.
	KindPerson Kind = "Person"
	// KindService is what an integration's key acts as. It has no credential, so
	// there is nothing to phish and nothing to reset.
	KindService Kind = "Service"
)

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

func (o *MemoryOutbox) Abandon(_ context.Context, ids []uuid.UUID, by uuid.UUID, _ time.Time) error

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) MarkSent

func (o *MemoryOutbox) MarkSent(_ context.Context, id uuid.UUID, _ time.Time) error

MarkSent 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

func (o *MemoryOutbox) ReleaseClaims(_ context.Context, by uuid.UUID, _ time.Time) (int, error)

ReleaseClaims implements Outbox.

The attempts stay, which is the difference from Abandon: the sends this pass made were made.

func (*MemoryOutbox) Retry

func (o *MemoryOutbox) Retry(_ context.Context, id uuid.UUID, at time.Time, reason string, _ time.Time) error

Retry implements Outbox.

func (*MemoryOutbox) RotateToken

func (o *MemoryOutbox) RotateToken(_ context.Context, verificationID uuid.UUID, hash []byte, expiresAt time.Time) (bool, error)

RotateToken implements Outbox.

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 NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore builds an empty store.

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) FindByID

func (s *MemoryStore) FindByID(_ context.Context, tenantID, id uuid.UUID) (*Account, error)

FindByID implements Store.

func (*MemoryStore) FindIdentityByEmail

func (s *MemoryStore) FindIdentityByEmail(_ context.Context, lowercased string) (*Identity, error)

FindIdentityByEmail implements Store.

func (*MemoryStore) FindIdentityByID

func (s *MemoryStore) FindIdentityByID(_ context.Context, id uuid.UUID) (*Identity, error)

FindIdentityByID implements Store.

func (*MemoryStore) InTx

func (s *MemoryStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error

InTx 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) Put

func (s *MemoryStore) Put(a *Account)

Put adds an account.

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

func (s *MemoryStore) TenantDomains(ctx context.Context, tenantID uuid.UUID) ([]string, error)

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

func (NoNotifier) SendEmailVerification(context.Context, *Identity, string) error

SendEmailVerification implements Notifier.

func (NoNotifier) SendInvitation

func (NoNotifier) SendInvitation(context.Context, *Identity, *Account, string) error

SendInvitation implements Notifier.

func (NoNotifier) SendPasswordReset

func (NoNotifier) SendPasswordReset(context.Context, *Identity, string) error

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 New

func New(cfg Config) (*Service, error)

New builds a service.

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

func (s *Service) AcceptInvitation(ctx context.Context, in AcceptInput) (session.Pair, error)

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

func (s *Service) ChangePassword(ctx context.Context, in ChangePasswordInput) (session.Pair, error)

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

func (s *Service) ConfirmPasswordReset(ctx context.Context, token, newPassword, ip string) error

ConfirmPasswordReset sets a new password from a reset link.

func (*Service) CreateTenant

func (s *Service) CreateTenant(ctx context.Context, in CreateTenantInput) (NewTenant, error)

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

func (s *Service) EndImpersonation(ctx context.Context, tok *session.Token) error

EndImpersonation revokes an impersonating session and records that it ended.

func (*Service) HasPassword

func (s *Service) HasPassword(ctx context.Context, identityID uuid.UUID) (bool, error)

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

func (s *Service) Impersonate(ctx context.Context, in ImpersonateInput) (session.Pair, error)

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

func (s *Service) Invitations(ctx context.Context, tenantID uuid.UUID) ([]Invitation, error)

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) Logout

func (s *Service) Logout(ctx context.Context, rootTokenID uuid.UUID) error

Logout ends one session.

func (*Service) MyInvitations

func (s *Service) MyInvitations(ctx context.Context, identityID uuid.UUID) ([]Invitation, error)

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

func (s *Service) MyTenants(ctx context.Context, identityID uuid.UUID) ([]Membership, error)

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

func (s *Service) Provision(ctx context.Context, in ProvisionInput) (*Account, error)

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

func (s *Service) Refresh(ctx context.Context, presented string) (session.Pair, error)

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

func (s *Service) ReleaseMailClaims(ctx context.Context) (int, error)

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

func (s *Service) SendEmailVerification(ctx context.Context, tenantID, accountID uuid.UUID) error

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

func (s *Service) SetPassword(ctx context.Context, identityID uuid.UUID, newPassword string) error

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

func (s *Service) Switch(ctx context.Context, in SwitchInput) (session.Pair, error)

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.

func (*Service) Tenants

func (s *Service) Tenants(ctx context.Context, tenantID, accountID uuid.UUID) ([]Membership, error)

Tenants are the tenants a person belongs to.

It takes an account rather than an identity because that is what a caller has — their claims name one account — and the answer is about the person behind it.

func (*Service) VerifyEmail

func (s *Service) VerifyEmail(ctx context.Context, token string) error

VerifyEmail confirms an address from a link.

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.

func (*Verification) Usable

func (v *Verification) Usable(now time.Time) bool

Usable reports whether the link may still be redeemed.

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"
)

Jump to

Keyboard shortcuts

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