Documentation
¶
Overview ¶
Package account implements the sign-in flows.
There are two ways in and rig stores no passwords. A provider vouches for somebody (see github.com/simonjanss/rig/auth/oauth), or rig mails a short code to the address they are signing in with and they type it back — which is this package's own door, and the one anybody with no provider account uses.
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 code is compared, so a locked request neither does the work nor extends its own window.
- A sign-in for an address rig has never seen takes the same time as one for an address it has, so response time is not a membership oracle.
- A wrong code and a disabled account are told apart only after the code is compared, so "this account is disabled" cannot be used to enumerate accounts.
- Asking for a code answers the same way whether or not the address exists.
- A code dies after a few wrong guesses rather than merely being slowed down, because six digits are guessable in a way a token is not.
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 MaskEmail(address string) string
- func PermanentMailError(err error) error
- func RetryMailAfter(err error, d time.Duration) error
- type AcceptInput
- type Account
- type Config
- type CreateTenantInput
- type Creator
- type Delivery
- type DeliveryState
- type EmailCodeOptions
- type Identity
- type ImpersonateInput
- type Invitation
- type InviteInput
- type Joined
- type Kind
- 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) ChargeVerificationAttempt(_ context.Context, id uuid.UUID, max int, at time.Time) (int, bool, 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) 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) Forget(accountID uuid.UUID)
- func (s *MemoryStore) InTransaction() bool
- 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) InvitationByID(_ context.Context, id uuid.UUID) (*Invitation, error)
- func (s *MemoryStore) InvitationByToken(_ context.Context, hash []byte) (*Invitation, error)
- func (s *MemoryStore) InvitationSlot(_ context.Context, tenantID, identityID uuid.UUID) (*Invitation, error)
- func (s *MemoryStore) InvitationsForIdentity(_ context.Context, identityID uuid.UUID) ([]Invitation, error)
- func (s *MemoryStore) LastAccountForIdentity(_ context.Context, identityID uuid.UUID) (*Account, error)
- func (s *MemoryStore) LiveVerification(_ context.Context, identityID uuid.UUID, kind VerificationKind) (*Verification, 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) SetLastAccount(identityID, accountID uuid.UUID)
- 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 PreviewInput
- type ProvisionInput
- type Registered
- type RequestEmailCodeInput
- 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) 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) Impersonate(ctx context.Context, in ImpersonateInput) (session.Pair, error)
- func (s *Service) Invitations(ctx context.Context, tenantID uuid.UUID) ([]Invitation, error)
- func (s *Service) Invite(ctx context.Context, in InviteInput) (*Invitation, 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) PreviewInvitation(ctx context.Context, in PreviewInput) (*Invitation, 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) ReleaseMailClaims(ctx context.Context) (int, error)
- func (s *Service) RequestEmailCode(ctx context.Context, in RequestEmailCodeInput) error
- func (s *Service) RevokeEverySession(ctx context.Context, identityID uuid.UUID) 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) SignInIdentity(ctx context.Context, in SignInIdentityInput) (SignInResult, 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
- func (s *Service) VerifyEmailCode(ctx context.Context, in VerifyEmailCodeInput) (SignInResult, error)
- type SignInIdentityInput
- type SignInResult
- type Store
- type SwitchInput
- type Tenant
- type TenantDraft
- type TenantOptions
- type Verification
- type VerificationKind
- type VerifyEmailCodeInput
Constants ¶
const ( // DefaultEmailCodeLength is six digits, which is what people expect to be // asked for and what fits in a glance. It is short enough to be guessable, // which is why [EmailCodeOptions.MaxAttempts] exists. DefaultEmailCodeLength = 6 // DefaultEmailCodeTTL is ten minutes: long enough to find the mail on // another device, short enough that a live credential is not sitting in a // mailbox all afternoon. DefaultEmailCodeTTL = 10 * time.Minute // DefaultEmailCodeMaxAttempts is three, and it is deliberately well below // the five wrong sign-ins that lock an address. // // If the two matched, somebody who mistyped a code three or four times // would kill the code *and* lock themselves out of the address for fifteen // minutes — so the ordinary clumsy failure would be unrecoverable rather // than "that one is dead, ask for another". Three tries on a code you can // re-read from the mail in front of you is enough, and it leaves room // underneath the lockout for the second code to work. // // With six digits it is also a one in a third of a million chance of // guessing a particular code. DefaultEmailCodeMaxAttempts = 3 // MinEmailCodeLength is the shortest code this package will mint. Below it // no attempt ceiling makes a code safe: four digits with five attempts is // one guess in two thousand, which is not a credential. MinEmailCodeLength = 6 // MaxEmailCodeLength bounds the other end. Past this somebody is typing a // token, and a token belongs in a link. MaxEmailCodeLength = 10 )
Defaults for the mailed-code sign-in.
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 sign-in, and it is the whole of what keeps an // address rig knows and one it does not indistinguishable. // // Comparing a code costs one sha256 whether or not the address exists, which // is far too cheap to hide the reads around it: the lookup that finds // nobody returns sooner than the one that finds a person and their live // code. A floor over both makes the difference noise. It used to be // belt-and-braces over an argon2 hash that did most of this work; it is not // any more, so do not read it as a tax and remove it. DefaultMinDuration = 750 * time.Millisecond // 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 ErrInvalidCode = rigerr.Unauthorized("that code is not correct or has expired")
ErrInvalidCode is the answer to every way a code can be wrong.
One error for a wrong code, an expired code, a code that was already used, a code that has been guessed at too many times, and an address rig has never heard of — because the difference between the last one and the rest is exactly what somebody probing for accounts is trying to learn. Service.VerifyEmailCode pads its own duration for the same reason.
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.
This is the tenant's own list — rig_tenant.allowed_email_domains — and it answers whether somebody may hold an account *here*. Whether they may become an identity at all is a different question at a different time, asked once and not per tenant: see github.com/simonjanss/rig/auth/identity.Gate.
The rule is one rule, so it is written once. This is github.com/simonjanss/rig/auth/identity.DomainAllowed, kept under this name because Store implementations in other packages call it.
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 MaskEmail ¶ added in v0.6.0
MaskEmail hides most of an address while leaving it recognisable.
"bo@school.example" becomes "b***@school.example". The domain is untouched, deliberately: it is what tells somebody which of their addresses a link is for, and it is usually the tenant's own domain, which whatever is showing this has already named. A one-character local part becomes "*" rather than itself.
Exported because an application rendering its own landing page should mask the way rig does rather than invent a second format for the same field. What it is for is Service.PreviewInvitation: the holder of an invitation link is not yet proven to be its addressee, mail gets forwarded, and the unmasked form would turn a leaked link into a confirmed address.
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
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, so there is nothing to sign
// in as.
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 cannot sign in, which every sign-in path 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 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
Log authlog.Log
Notifier Notifier
// EmailCode is the mailed-code sign-in. The zero value is off, and a
// deployment with no provider configured either has no way in at all —
// which [New] does not refuse, because a service that only provisions and
// invites is a legitimate thing to build.
EmailCode EmailCodeOptions
// 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
// AllowIdentity decides whether somebody nobody here has ever heard of may
// become an identity. Nil admits everybody the doors above already let
// through, which is what this package did before it existed.
//
// It is the same gate [github.com/simonjanss/rig/auth/oauth.Config] holds,
// and one value is meant to be given to both: the question is about the
// person rather than about how they are arriving, and a deployment with a
// rule per door is a deployment that acquires an ungated one the next time a
// door is added.
//
// See [github.com/simonjanss/rig/auth/identity.Gate] for the contract,
// which is the part worth reading before writing one: it is asked about
// strangers and nobody else.
AllowIdentity identity.Gate
// OnRegistered runs inside the transaction that creates a person rig has
// never seen — asking for a sign-in code with a new address, where
// [EmailCodeOptions.AllowProvisioning] allows it. Returning an error rolls
// the whole thing back, so a retry is a clean retry rather than a conflict
// with a half-made identity.
//
// It receives the service because the ordinary body is a call back into it —
// [Service.Provision], bringing the newcomer into a starter tenant, or
// [Service.Invite] to leave one waiting in their picker — 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.
//
// One thing to know before using it for anything expensive: it runs when the
// code is *asked for*, not when it is typed back, because the row that holds
// the code references the person. So a starter tenant made here may belong
// to somebody who never finishes signing in.
//
// Nil, the default, creates the person and nothing else.
OnRegistered func(ctx context.Context, accounts *Service, in Registered) error
// OnJoined runs inside the transaction that accepts an invitation — after
// the account exists, before the session is issued. Returning an error rolls
// the whole acceptance back, so a retry is a clean retry rather than a
// member with half a setup.
//
// It exists because of where an account now comes from. [Service.Provision]
// is called by the application, so whatever else a new member needs — a
// role's grants, a preferences row — is the caller's next line. Accepting an
// invitation is called by rig's own handler, and this is that line.
//
// Reach the transaction with dbx.Tx(ctx). It does not receive the service:
// the ordinary body is a write against the application's own tables rather
// than a call back into this package.
OnJoined func(ctx context.Context, in Joined) 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 sign-in endpoint, it is a guessing oracle with a queue.
Limiter *throttle.Limiter
Limits throttle.Defaults
MinDuration 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 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 EmailCodeOptions ¶ added in v0.6.0
type EmailCodeOptions struct {
// Enabled turns the flow on. [New] refuses it with no Notifier: a code
// nobody receives is a door nobody can open.
Enabled bool
// Length is how many digits. Zero means [DefaultEmailCodeLength], and
// anything between [MinEmailCodeLength] and [MaxEmailCodeLength] is
// accepted.
Length int
// TTL is how long a code lasts. Zero means [DefaultEmailCodeTTL].
TTL time.Duration
// MaxAttempts is how many wrong guesses kill a code. Zero means
// [DefaultEmailCodeMaxAttempts].
//
// It is a ceiling on one code rather than a rate limit on an address, and
// the two are not substitutes: a limit counts failures over a rolling
// window, so a fresh code arrives with the old code's failures still
// counted and five fat-fingered attempts lock the address rather than
// killing one code.
MaxAttempts int
// AllowProvisioning sends a code to an address rig has never seen, creating
// the person when the code is asked for.
//
// Off by default, the way
// [github.com/simonjanss/rig/auth/oauth.Config.AllowProvisioning] is and for
// the same reason: it is the difference between a deployment anybody can get
// an identity in and one where somebody has to be invited first. On, it is
// self-registration — [Config.OnRegistered] runs in the transaction that
// creates the person, which is the hook that puts a newcomer somewhere.
//
// The trade to know: with it on, a stranger typing an address creates a row
// and runs that hook before proving anything. A starter tenant made this way
// may belong to somebody who never types the code.
AllowProvisioning bool
}
EmailCodeOptions is the mailed-code sign-in: rig sends a short numeric code to an address and somebody types it back.
The zero value is off, and off is the default. It is the way in for anybody with no account at a configured provider, which in most deployments is most people — but whether a deployment wants it is a decision, and so is whether a code may go to an address nobody has ever seen.
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 set of linked providers, no tenant. Somebody who belongs to two tenants has one identity and two accounts, and the identity is what a sign-in code, an invitation 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
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 is what the inviter called them, falling back to the name the
// person already has when the inviter said nothing.
DisplayName string
// Role is what accepting will grant, which is worth showing: being invited
// as an Admin is something to know before pressing the button.
Role Role
// InvitedByAccountID and InvitedByName are who sent it. The name is empty
// when a key sent it, and when the person who did has since been removed —
// both of which a landing page has to render rather than fail on.
InvitedByAccountID *uuid.UUID
InvitedByName string
CreatedAt time.Time
ExpiresAt time.Time
}
Invitation is a live invitation, with enough about the person to show it.
type InviteInput ¶ added in v0.6.0
type InviteInput struct {
TenantID uuid.UUID
EmailAddress string
// DisplayName is what to call them in this tenant. Empty falls back at
// accept time to the name the person already has, rather than to a guess
// made now: somebody who already signs in here has told you their name, and
// an invitation should not overwrite it with the local part of an address.
DisplayName string
// Role defaults to Basic, and applies to this tenant only. Whether the
// caller may hand out a higher one is the application's rule.
Role Role
// ByAccountID and ByAPIKeyID are who asked. They are not only audit columns
// here: one of them becomes the created_by of the account accepting creates,
// and the account's display name is what a landing page shows somebody who
// has not signed in yet.
ByAccountID *uuid.UUID
ByAPIKeyID *uuid.UUID
}
InviteInput asks somebody to join a tenant.
type Joined ¶ added in v0.6.0
type Joined struct {
TenantID uuid.UUID
AccountID uuid.UUID
IdentityID uuid.UUID
Role Role
EmailAddress string
DisplayName string
// InvitedBy is the account that sent the invitation, and nil when a key
// sent it or when that person has since been removed.
InvitedBy *uuid.UUID
}
Joined is who just became a member, for Config.OnJoined.
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 {
// Now is the clock the expiry filters read, so that a test advancing its own
// clock sees invitations expire the way Postgres would. Unset means
// time.Now.
//
// The real store puts `expires_at > now()` in SQL; this double used to leave
// it out entirely, which made it disagree with Postgres about a property
// under test — the thing accountOrder below exists to avoid.
Now func() time.Time
// 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: a sign-in that names no tenant and has no session history to read falls back to the tenant they joined first, so the order *is* the contract.
func (*MemoryStore) ChargeVerificationAttempt ¶ added in v0.6.0
func (s *MemoryStore) ChargeVerificationAttempt( _ context.Context, id uuid.UUID, max int, at time.Time, ) (int, bool, error)
ChargeVerificationAttempt implements Store.
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.
It enforces the one unique index this table has that a caller can trip over: one live invitation per person per tenant. Live is not consumed and not revoked, and — like the index — says nothing about expiry, which is the whole point of enforcing it here. A caller that clears the slot by a lookup which hides expired rows passes against a double that does not check and fails against Postgres, which is exactly the kind of disagreement this store is written to avoid.
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) Forget ¶ added in v0.6.0
func (s *MemoryStore) Forget(accountID uuid.UUID)
Forget removes an account outright.
The real store soft-deletes, and what every caller here checks is whether the account is still found — which is the same question either way. It stands in for the ways an account can stop existing: somebody removed from a tenant, or a tenant closed.
func (*MemoryStore) InTransaction ¶ added in v0.6.0
func (s *MemoryStore) InTransaction() bool
InTransaction reports whether the caller is running inside MemoryStore.InTx.
It is for the assertion a Notifier makes about itself: the mail a flow sends must go out after the transaction that wrote the row it is about, because the real store would otherwise be holding a connection open across somebody else's server.
func (*MemoryStore) InTx ¶
InTx implements Store.
Re-entrant, like the real one, and it counts: see MemoryStore.InTransaction.
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) InvitationByID ¶ added in v0.6.0
func (s *MemoryStore) InvitationByID(_ context.Context, id uuid.UUID) (*Invitation, error)
InvitationByID implements Store.
func (*MemoryStore) InvitationByToken ¶ added in v0.6.0
func (s *MemoryStore) InvitationByToken(_ context.Context, hash []byte) (*Invitation, error)
InvitationByToken implements Store.
func (*MemoryStore) InvitationSlot ¶ added in v0.6.0
func (s *MemoryStore) InvitationSlot( _ context.Context, tenantID, identityID uuid.UUID, ) (*Invitation, error)
InvitationSlot implements Store.
No expiry filter, standing in for a unique index that has none: an expired invitation still holds the slot, so hiding it here would be hiding the row the real store is about to refuse a second invitation over.
func (*MemoryStore) InvitationsForIdentity ¶
func (s *MemoryStore) InvitationsForIdentity(_ context.Context, identityID uuid.UUID) ([]Invitation, error)
InvitationsForIdentity implements Store.
func (*MemoryStore) LastAccountForIdentity ¶ added in v0.5.0
func (s *MemoryStore) LastAccountForIdentity(_ context.Context, identityID uuid.UUID) (*Account, error)
LastAccountForIdentity implements Store.
It answers from what MemoryStore.SetLastAccount was told and applies the account half of what the real query filters, so a test that disables the account sees the fallback rather than a landing nobody can use. The tenant half it cannot: nothing here holds a tenant's active state, which is why the tenant that went away is a case for the Postgres suite.
func (*MemoryStore) LiveVerification ¶ added in v0.6.0
func (s *MemoryStore) LiveVerification( _ context.Context, identityID uuid.UUID, kind VerificationKind, ) (*Verification, error)
LiveVerification 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) SetLastAccount ¶ added in v0.5.0
func (s *MemoryStore) SetLastAccount(identityID, accountID uuid.UUID)
SetLastAccount says which account an identity most recently held a session for, standing in for the row rig_account_token would carry.
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 secret, 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 confirm an address 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 refuses two combinations rather than dropping them. A Config.Outbox with this as the notifier is a queue whose rows are written and then thrown away, which is a table that grows forever behind mail that never goes. And EmailCodeOptions.Enabled with this as the notifier is a sign-in nobody can ever complete — the only way in, minting codes into a void.
func (NoNotifier) SendEmailCode ¶ added in v0.6.0
SendEmailCode implements Notifier.
func (NoNotifier) SendEmailVerification ¶
SendEmailVerification implements Notifier.
func (NoNotifier) SendInvitation ¶
func (NoNotifier) SendInvitation(context.Context, *Identity, *Invitation, string) error
SendInvitation implements Notifier.
type Notifier ¶
type Notifier interface {
// SendEmailCode delivers a short numeric code somebody types back to sign
// in. It is the one secret here that a person reads out rather than clicks,
// so the mail wants it legible and on its own line.
SendEmailCode(ctx context.Context, i *Identity, code string) error
SendEmailVerification(ctx context.Context, i *Identity, token string) error
SendInvitation(ctx context.Context, i *Identity, inv *Invitation, token string) error
}
Notifier delivers the secrets 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 code being sent belong to the person, not to one of the tenants they work in.
An invitation is the exception and takes the invitation too, because it is about one tenant: the mail has to say which tenant somebody is being invited to, who asked, and as what — "you have been invited" with no answer to "invited where" is a mail nobody can act on. It is the invitation rather than an account because there is no account yet, and that is the point of the flow: accepting is what creates one.
**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 PreviewInput ¶ added in v0.6.0
PreviewInput asks what an invitation's link is for.
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
}
ProvisionInput describes somebody to create an account for.
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: the address's local part, which is all anybody
// knows about somebody who has typed nothing but an address.
DisplayName string
IPAddress string
UserAgent string
}
Registered is somebody who has just come into existence, as Config.OnRegistered sees them.
It arrives from one place: asking for a sign-in code with an address rig has never seen, in a deployment where EmailCodeOptions.AllowProvisioning says that is allowed. There is no registration endpoint — with no password there is nothing for one to take, and an endpoint that minted an identity token for anybody who typed an address would be a door rather than a form.
type RequestEmailCodeInput ¶ added in v0.6.0
type RequestEmailCodeInput struct {
// TenantID is only for the audit entry, and may be left empty: a code is
// about an address rather than about a membership.
TenantID uuid.UUID
EmailAddress string
IPAddress string
UserAgent string
}
RequestEmailCodeInput asks for a code.
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. 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, and why Service.PreviewInvitation hands out the identifier too.
That is also the answer to the fourth case, somebody signed in *and* holding the link: preview it, then come here with the identifier. The stronger of two claims is the one to prefer when both are present, and this one refuses an invitation that is not the caller's own — which the token door cannot, because there the token is the whole of the claim.
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 the link that was mailed and returns a session for the account it just created.
The mailbox's door, for somebody who followed the link and is not signed in — which is most people most of the time, on a device this installation has never seen. Service.PreviewInvitation is what lets a page tell them what they are looking at before they get here.
It also confirms the address, because the link is the proof: it went to that address and came back.
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) 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) Invite ¶ added in v0.6.0
func (s *Service) Invite(ctx context.Context, in InviteInput) (*Invitation, error)
Invite asks somebody to join a tenant, and creates nothing in it.
The difference from Service.Provision is the whole reason both exist. Provision adds a member; this adds nobody. What it writes is an identity, if this installation has never seen the address, and a row that says which tenant, what role, what to call them and who asked. **Accepting it is what makes somebody a member** — so an invitation that is never answered leaves the tenant's people list exactly as it was, and somebody who was invited and has not replied is not counted, not listed, and has nothing scoped to them.
Inviting somebody who already has an invitation here supersedes it. The newest link is the one that should work, which is the same decision the mail queue already makes when it rotates a token before each attempt — and it means re-sending an invitation is something an administrator can just do.
It returns the invitation so that whoever sent it can render the row they have just created, which is what saves a listing call.
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) PreviewInvitation ¶ added in v0.6.0
func (s *Service) PreviewInvitation(ctx context.Context, in PreviewInput) (*Invitation, error)
PreviewInvitation says what a link is for, without spending it.
The one thing rig answers to somebody who has proved nothing but that they hold a link, and it exists because a mail's whole value is the sentence it lets the landing page say: Anna invited you to Skolan i Solna. Without it a front end holds a token it cannot interpret and can only show a bare sign-in box — which throws the mail away, and is the page most likely to be taken for phishing.
It consumes nothing and extends nothing. Reading a link is not using it, and a preview that touched expires_at would let anybody who can see the URL keep an invitation alive forever.
One answer for every way this can be wrong — unknown, consumed, withdrawn, expired, or the wrong kind of link. The same rule Service.AcceptAsMe applies, and for the same reason: from outside they are the same thing, and telling them apart would let a caller probe the table one token at a time.
The address in what comes back is the invitation's, unmasked. Masking is the HTTP layer's, because a caller inside the process may well have a reason to see it and a caller over the wire has not proved they are its addressee — see MaskEmail.
func (*Service) Provision ¶
Provision brings somebody into a tenant, now.
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 of everything.
**Provision adds a member; Service.Invite asks somebody to join.** That is the whole difference and it is worth being sure which one you want. This one writes the account immediately, which is what a bulk import wants and what a starter tenant in Config.OnRegistered wants — the person is in the tenant's people list from this call, whether or not they ever sign in. Invite writes no account at all, and accepting is what creates one.
Nothing is mailed. Somebody provisioned this way needs a way to be told, and what that says is the application's — which is the argument for Invite when the mail is the point.
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) 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) RequestEmailCode ¶ added in v0.6.0
func (s *Service) RequestEmailCode(ctx context.Context, in RequestEmailCodeInput) error
RequestEmailCode mails a short single-use code and answers the same way whether or not the address exists.
The enumeration rule is the whole shape of it. An address rig has never seen either gets an identity and a code, when EmailCodeOptions.AllowProvisioning says so, or gets nothing at all — and in both cases the caller is told the same nothing, because whether somebody has an account here is not a stranger's business. The refusal that *is* reported is the rate limit, which is about the caller's behaviour rather than about the address.
Any code already live for that person is revoked first. Three reasons: the newest code is then the one that works rather than probably the one that works, the table cannot be filled by asking repeatedly, and on the queued path the dispatcher finds the older row settled and marks its delivery skipped, which is exactly what Outbox documents.
The tenant is only for the log entry. A code proves an address and an address belongs to the person, so which tenant they end up in is decided when they sign in rather than here.
func (*Service) RevokeEverySession ¶ added in v0.6.0
RevokeEverySession ends every session a person has, in every tenant they belong to.
What "sign me out everywhere" means, and the only way to reach it: a person is global and their sessions are not, so anything narrower leaves somebody signed in to the tenants they were not looking at. github.com/simonjanss/rig/auth/session.Manager.RevokeAll wants a tenant and an account, and finding every pair a person has is exactly what this does.
Exported because the decision to use it is an application's. rig calls it nowhere: there is no credential left to change, and ending every session because somebody signed in with a code would make the second device sign the first one out.
func (*Service) RevokeInvitation ¶
func (s *Service) RevokeInvitation(ctx context.Context, in RevokeInput) error
RevokeInvitation withdraws an invitation.
One write, where this used to be two. It killed the link and soft-deleted the account in the same transaction, because either alone left something wrong — a dead link beside an account nobody could ever use, or an account gone and a live link that would recreate nothing. There is no account now: nothing was created when the invitation went out, so withdrawing it removes nothing and the tenant's people list does not change.
Which is the point. Before, "withdraw an invitation" was "delete a colleague who has not answered yet", and the only thing keeping that safe was that the invitation had to still be pending.
It still refuses one that is not pending, and the reason is now about the link alone: a consumed link belongs to a member, and removing somebody from a tenant is a different decision with a different name — and no endpoint in rig, deliberately.
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) SignInIdentity ¶ added in v0.5.0
func (s *Service) SignInIdentity(ctx context.Context, in SignInIdentityInput) (SignInResult, error)
SignInIdentity is everything a sign-in does once it knows who somebody is.
It is the whole tail of a sign-in: which of the person's accounts this session is for, the tenant list the picker draws from, the identity token, the session itself, and the audit entry. Service.VerifyEmailCode calls it once the code has been compared, and a provider sign-in calls it once the provider has said who this is — which is what stops the two paths answering "where does this person go" differently.
Input.TenantID may be uuid.Nil, and that is an ordinary answer rather than a missing one. Named: that tenant or a refusal. Nil: wherever they belong — which is where they were last, or their oldest tenant, or, for somebody who belongs nowhere yet, nowhere at all. The last case is a **success**: SignInResult.Session is nil, SignInResult.Identity is issued anyway, and where they go next is the picker's problem.
What it does not do, because its caller does: no rate-limit check, and no credential of any kind. What it does do is every refusal that is about the person rather than about how they proved it — an identity that is gone or disabled, because a provider link outlives both and the row in rig_identity_oauth carries no deleted_at and no is_active, and RequireVerifiedEmail.
That last one is what this door is for. oauth's LinkIdentity records the verified address it took as evidence, so a provider sign-in can be held to the rule rather than exempted from it on a column nobody filled in.
The code flow is not held to it, and that is deliberate rather than an oversight: it calls the unexported half of this, because confirming the address *is* what typing the code did, and gating the tail would refuse somebody on a column their own request had just filled in. Accepting an invitation and creating a tenant are ungated for the same reason and always have been.
Two things about the audit trail are worth knowing before wiring it to something new. It writes EventLoginSucceeded and EventLoginFailed, which is what github.com/simonjanss/rig/runtime/throttle.Standard counts and clears — so a provider sign-in lifts the address's code lockout, which is safe because it takes control of the provider account, and a refusal in here counts towards that lockout. And it writes what the limiter counts without consulting the limiter, which is right when there is nothing being guessed but means the bound on calling this has to come from the caller.
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.
func (*Service) Tenants ¶
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 ¶
VerifyEmail confirms an address from a link.
func (*Service) VerifyEmailCode ¶ added in v0.6.0
func (s *Service) VerifyEmailCode(ctx context.Context, in VerifyEmailCodeInput) (SignInResult, error)
VerifyEmailCode signs somebody in with the code that was mailed to them.
It answers the body every other sign-in answers — the tenant list, the one landed in marked, an identity token, and a session when there is a tenant to have one for — which is what makes it fit: the picker, "where you were last" and every client already work, because there is nothing new in the response.
It also confirms the address, because the code is the proof: it went to that address and came back. That is the same argument accepting an invitation makes, and it is why this calls the unexported tail — RequireVerifiedEmail would otherwise refuse somebody on a column their own request had just filled in.
type SignInIdentityInput ¶ added in v0.5.0
type SignInIdentityInput struct {
IdentityID uuid.UUID
// TenantID says which tenant the session is for, and may be uuid.Nil, which
// means "wherever they belong" — see [Service.SignInIdentity].
TenantID uuid.UUID
Remember bool
Client session.Client
IPAddress string
UserAgent string
// Method is how the person proved who they are — a provider name, "Google",
// or "EmailCode" — and lands in the audit entry's detail. Empty means
// nothing is claimed, which is what an invitation being accepted says.
Method string
}
SignInIdentityInput is a sign-in that already knows who somebody is.
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 wherever they were
// last — see [Service.SignInIdentity].
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
}
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 "sign me out everywhere" mean what it says: a person is
// global and their sessions are not, so ending every one of them has to reach
// the tenants the request was not made from.
AccountsForIdentity(ctx context.Context, identityID uuid.UUID) ([]*Account, error)
// LastAccountForIdentity is the account a person most recently held a
// session for, or nil when there is no record of one.
//
// It is what makes a sign-in that names no tenant land where somebody was
// rather than where they started — see [Service.SignInIdentity]. The
// account rather than the tenant, because an account is a person in a
// tenant and the caller needs the row either way.
//
// It must skip an account or a tenant that is no longer active, for the
// same reason [Store.AccountsForIdentity]'s caller does: landing somebody
// somewhere [Store.TenantsForIdentity] will not list is worse than landing
// them nowhere.
LastAccountForIdentity(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.
//
// Ordered for a person to read rather than to match whichever account a
// sign-in landed in: which one that was is *marked* — see
// [SignInResult.TenantID] — so the two orderings have nothing to disagree
// about.
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 [Service.Provision] is the part that is the same everywhere — the
// address is checked, the tenant's domains are honoured, and nothing is
// written twice.
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)
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)
// InvitationByID and InvitationByToken are one invitation, assembled the
// way the two listings assemble theirs.
//
// They exist for the two callers that hold one invitation rather than a
// person's set of them: the mail dispatcher, which has a delivery and needs
// the tenant's name to put in the mail, and the preview, which has a token
// out of a URL and needs to say what it is for. Neither filters on expiry —
// that is a comparison against the service's clock, and putting it in SQL is
// how the in-memory double and the real store end up disagreeing.
InvitationByID(ctx context.Context, id uuid.UUID) (*Invitation, error)
InvitationByToken(ctx context.Context, hash []byte) (*Invitation, error)
// InvitationSlot is the invitation holding one person's place in one
// tenant, or nil.
//
// What it reads is the unique index: one live invitation per person per
// tenant, where live means not consumed and not revoked and says nothing
// about expiry, because now() is not immutable and cannot be indexed on. So
// this must not filter on expiry either — an expired invitation still holds
// the slot, and [Service.Invite] is what clears it. A lookup that hid one
// would leave the index to refuse the next invitation, and nothing in the
// API could ever clear it.
InvitationSlot(ctx context.Context, tenantID, identityID uuid.UUID) (*Invitation, error)
// RevokeVerification cancels a row. 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)
// VerificationByHash finds a row 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 row used. It must be a no-op on one that is
// already consumed, so that two requests racing to redeem it cannot both
// win.
ConsumeVerification(ctx context.Context, id uuid.UUID, at time.Time) (bool, error)
// LiveVerification is the newest row of a kind for one person that may still
// be redeemed, or nil.
//
// It is how a sign-in code is found, and the reason there are two lookups
// rather than one: a request carries the address and the code, not a token,
// and six digits cannot be a key. [Store.VerificationByHash] is for the
// kinds whose secret is long enough to be one.
LiveVerification(ctx context.Context, identityID uuid.UUID, kind VerificationKind) (*Verification, error)
// ChargeVerificationAttempt charges one wrong guess against a row and
// revokes it when that was its last, reporting the count after the charge
// and whether the row is now dead.
//
// It must be one statement. A read followed by a write is a window two
// concurrent guesses both fit through, which is a handful of free attempts
// against a secret that only has a million values.
//
// Revoked rather than a state of its own: [Verification.Usable] already
// refuses a revoked row and so does the mail queue, so nothing else has to
// learn that a ceiling exists. A row burned by guessing and one an
// administrator withdrew are told apart by [Verification.Attempts].
ChargeVerificationAttempt(ctx context.Context, id uuid.UUID, max int, at time.Time) (attempts int, dead bool, err 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 row
// about the person rather than one tenant — confirming their address or
// signing them in, 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 row 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
// Attempts is how many wrong codes have been offered for this row, and it
// matters for [KindEmailCode] alone. Six digits are guessable in a way a
// 32-byte token is not, so a code has to die after a handful of tries rather
// than merely be slowed down by a rate limit.
Attempts int
// What an invitation carries, and nil for every other kind. It is what
// accepting creates the account from, which is what makes an invitation a
// pending membership rather than a mail about a membership that already
// exists.
//
// A database CHECK ties InvitedRole and InvitedToTenantID to the kind, so
// an invitation with neither cannot be written at all.
InvitedRole *Role
InvitedDisplayName string
// InvitedByAccountID and InvitedByAPIKeyID are who asked. They become the
// created_by pair of the account accepting creates, so the provenance
// survives the invitation being consumed.
InvitedByAccountID *uuid.UUID
InvitedByAPIKeyID *uuid.UUID
}
Verification is a single-use secret.
Only the hash is stored. A sign-in code is a live credential for the few minutes it lasts, and a database dump containing live ones is a database dump that hands over every account in it.
type VerificationKind ¶
type VerificationKind string
VerificationKind is what a single-use secret is for.
const ( // KindEmailVerification confirms an address belongs to whoever gave it. KindEmailVerification VerificationKind = "EmailVerification" // KindEmailCode is a short code mailed to an address and typed back, which // is how somebody with no provider account signs in. // // The one kind whose secret is guessable, which is why it is the only one // with an attempt ceiling and the only one not found by its hash — see // [Verification.Attempts] and [Store.LiveVerification]. KindEmailCode VerificationKind = "EmailCode" // KindInvitation is a pending membership. Accepting it is what creates the // account in the tenant, so the row carries everything that account will be // made from. KindInvitation VerificationKind = "Invitation" )
type VerifyEmailCodeInput ¶ added in v0.6.0
type VerifyEmailCodeInput struct {
// TenantID says which tenant the session is for, and may be left empty —
// with the same meaning it has for a provider sign-in: nobody knows which
// tenants an address belongs to before it has been proved, so a single
// sign-in page asks for none and the picker sorts it out.
TenantID uuid.UUID
EmailAddress string
Code string
Remember bool
Client session.Client
IPAddress string
UserAgent string
}
VerifyEmailCodeInput is a sign-in with a mailed code.