Documentation
¶
Overview ¶
Package contracts is everything another module, an app or a test may know about users: the entity, the events, the permissions and the Service interface. The implementation is in ../internal.
One row per person per tenant ¶
There is no membership table. One tenant per host means a request is about one tenant before it is about anybody, so the same person working in two tenants is two rows: two passwords, two sets of roles, two profiles. That is what "tenant isolation belongs to the database" implies once it is taken seriously — every question about a user becomes an ordinary tenant-scoped query, row-level security answers it, and the join table that used to hold the answer, along with everything that had to agree with it, is gone.
The cost is real and worth stating: a person who works for two customers signs in twice and changes their password twice. The alternative is a global identity table that no tenant's policy can protect, which is the thing this architecture exists not to have.
Index ¶
- Constants
- Variables
- func EqualWork(password string)
- func HashPassword(password string) (string, error)
- type Deactivated
- type EmailVerified
- type Invited
- type PasswordRegistration
- type PasswordSet
- type PendingRegistration
- type RegistrationApproved
- type RegistrationPage
- type RegistrationPending
- type RegistrationUnverified
- type Registrations
- type Roles
- type RolesSet
- type Service
- type User
Constants ¶
const ( EventCreated = "user.user.created" EventUpdated = "user.user.updated" EventDeleted = "user.user.deleted" EventInvited = "user.invited" EventPasswordSet = "user.password_set" EventRolesSet = "user.roles_set" EventDeactivated = "user.deactivated" )
The base events this module emits. The first three are kit/rest's, published by the Spec module.go mounts; the last four are lifecycle commands. Pending registration and approval events are declared in registration.go. All are listed in the manifest, and kit/app refuses to start if a route would publish one that is not.
const ( PermissionUserRead = "user:read" PermissionUserManage = "user:manage" PermissionRegistrationApprove = "user:approve" )
Account review, profile reading and full management can be granted separately. Registration approval never grants permission to replace passwords or roles.
user:manage is the permission an administrator holds. It is not the same as tenant:manage, which reaches every tenant: a user administrator changes their own tenant's people and nobody else's, because every query they cause runs inside their own tenant's transaction.
const ( EventRegistrationPending = "user.registration_pending" EventRegistrationApproved = "user.registration_approved" EventRegistrationUnverified = "user.registration_unverified" EventEmailVerified = "user.email_verified" )
const ( StatusInvited = "invited" StatusPending = "pending" StatusUnverified = "unverified" StatusActive = "active" StatusInactive = "inactive" )
A user is invited before password setup, pending while awaiting approval, unverified while awaiting mailbox confirmation, active once they can sign in, and inactive when that access is removed. Deleting is kit/crud's soft delete, which keeps the row and releases the address.
const MinPasswordLength = 12
MinPasswordLength is the shortest password this application accepts. Length is the only rule: composition rules push people towards Passw0rd! and a twelve-character passphrase beats it, which is what every guidance since NIST SP 800-63B has said.
Variables ¶
var ErrRegistrationExists = fmt.Errorf("%w: this email address is already registered", crud.ErrConflict)
ErrRegistrationExists is the only registration conflict that leaves the transaction usable for a neutral public acknowledgment. The existing account is unchanged. Other failures must abort the caller's transaction.
var ErrWeakPassword = fmt.Errorf("a password is at least %d characters", MinPasswordLength)
ErrWeakPassword is a password shorter than MinPasswordLength.
Functions ¶
func EqualWork ¶
func EqualWork(password string)
EqualWork does the work CheckPassword would have done, and throws it away.
A login for an address nobody has must cost what a login with a wrong password costs. Without this the difference between "no such account" and "wrong password" is a stopwatch, which is an account enumeration oracle and the first step of a targeted attack. The auth module calls it on the path where it found no user.
func HashPassword ¶
HashPassword returns the PHC encoding of password:
$argon2id$v=19$m=65536,t=1,p=4$<salt>$<key>
The salt is 16 bytes from crypto/rand, so two people with the same password have different hashes and a precomputed table is worth nothing.
Types ¶
type Deactivated ¶
Deactivated is the payload of EventDeactivated: this person can no longer sign in.
type EmailVerified ¶ added in v1.1.0
type Invited ¶
type Invited struct {
UserID uuid.UUID `json:"userId"`
Email string `json:"email"`
Status string `json:"status"`
At time.Time `json:"at"`
}
Invited is the payload of EventInvited: a user account now exists in this tenant. It is published by Invite and by the bootstrap's Provision alike — the fact is that somebody can now be in this tenant, and Status says whether they still have to be given a way in.
type PasswordRegistration ¶ added in v1.1.0
type PasswordRegistration struct {
Email, DisplayName string
Password string `json:"-"`
Roles []string
}
PasswordRegistration is a trusted application command, not a public request schema. The composing registrar validates consent and confirmation, chooses roles itself and never accepts grants from the visitor. Password is hashed in the user owner and is never serialized or published in an event.
type PasswordSet ¶
PasswordSet is the payload of EventPasswordSet. It carries no password, no hash and no salt: what a subscriber may act on is that it happened and to whom, which is what an audit trail and a "your password changed" notice need.
type PendingRegistration ¶ added in v1.1.0
type PendingRegistration = PasswordRegistration
PendingRegistration retains the approval registrar's existing input name.
type RegistrationApproved ¶ added in v1.1.0
type RegistrationPage ¶ added in v1.1.0
type RegistrationPending ¶ added in v1.1.0
type RegistrationUnverified ¶ added in v1.1.0
type Registrations ¶ added in v1.1.0
type Registrations interface {
// RegisterPending creates a pending account with a password and trusted
// roles. An existing email returns ErrRegistrationExists without changing
// that account or aborting the transaction. Validation and hashing precede the insert on
// every attempt; it never reads an existing account to decide its response.
RegisterPending(context.Context, db.Tx[db.Tenant], PendingRegistration) (*User, error)
// RegisterUnverified has the same insertion and conflict guarantees, but
// only VerifyEmail can activate the resulting password-bearing account.
RegisterUnverified(context.Context, db.Tx[db.Tenant], PasswordRegistration) (*User, error)
// VerifyEmail locks the user, compares the normalized expected email with
// the current canonical email, and activates only an unverified account
// with a password. Wrong email or lifecycle state, including replay after
// activation, conflicts. Credentials and roles remain unchanged.
VerifyEmail(context.Context, db.Tx[db.Tenant], uuid.UUID, string) (*User, error)
// PendingRegistrations lists oldest first, then by ID, with bounded offset
// pagination. Zero limit uses the standard default; invalid bounds fail.
PendingRegistrations(context.Context, db.Tx[db.Tenant], int, int) (RegistrationPage, error)
// ApproveRegistration activates a pending account without changing its
// password or roles. Already-active accounts are unchanged; invited and
// inactive and unverified accounts conflict. Actor identifies the approver.
ApproveRegistration(context.Context, db.Tx[db.Tenant], uuid.UUID, uuid.UUID) (*User, error)
}
Registrations owns distinct approval and mailbox-verification lifecycles over tenant-local user rows. The application opts in and auth owns mailbox tokens.
type Roles ¶
type Roles []string
Roles is the set of role names a user holds, one text[] column.
It is a named type rather than []string so that the array codec is written once. kit/crud's schema reads it as a list of strings, so it renders and it is a field a PATCH could name — which is why the Spec names it Immutable. Granting a role is Service.SetRoles, which says so in an event; it is not something that happens inside a bulk update of a profile.
type RolesSet ¶
type RolesSet struct {
UserID uuid.UUID `json:"userId"`
Was []string `json:"was"`
Now []string `json:"now"`
At time.Time `json:"at"`
}
RolesSet is the payload of EventRolesSet: what this person may do has changed. Both sets are carried, because the interesting question about a grant is what it added.
type Service ¶
type Service interface {
Registrations
// Invite creates a user with no password, in status invited, and publishes
// user.invited. Inviting an address that is already here is a conflict.
Invite(ctx context.Context, tx db.Tx[db.Tenant], email, displayName string) (*User, error)
// SetPassword hashes and stores a password for an invited or active user.
// Pending, unverified and inactive users conflict; setup cannot bypass activation. The
// same password again is still a write and still an event: a person who
// changes their password to what it already was has still done it.
SetPassword(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID, password string) error
// SetRoles replaces the roles this user holds. The same set again changes
// nothing and publishes nothing.
SetRoles(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID, roles []string) (*User, error)
// Deactivate stops the user signing in. Their sessions are somebody else's
// business: the auth module refuses a session whose user is not active, so
// there is no list of sessions to walk here.
Deactivate(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*User, error)
// Get is one user of this tenant.
Get(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*User, error)
// ByEmail is the login lookup: the user of this tenant with that address,
// compared without case. It is ErrNotFound for an address nobody has.
ByEmail(ctx context.Context, tx db.Tx[db.Tenant], email string) (*User, error)
// Provision creates a user from a cross-tenant transaction, naming the
// tenant. An empty password makes an invited user; anything else makes an
// active one. Either way it publishes user.invited.
//
// It is the control plane's door and nothing else's. Every other way a user
// comes into being is Invite, inside the tenant's own transaction; this
// exists because a tenant's first administrator is created from outside
// that tenant — by the bootstrap, in the same transaction as the tenant
// itself, and by the operator inviting one into a tenant they do not
// otherwise have a transaction in.
Provision(ctx context.Context, tx db.Tx[db.System], tenantID uuid.UUID, email, displayName, password string, roles []string) (*User, error)
}
Service is the user lifecycle: explicit commands generic CRUD cannot safely infer, including password registration and activation, plus tenant-scoped reads.
Every command takes the caller's transaction rather than opening one, so the state change and its event commit together. The errors are kit/crud's: ErrNotFound, ErrInvalid, ErrConflict.
Commands document their retry behavior. An unchanged idempotent command emits no event; verification replay conflicts rather than asserting fresh proof.
type User ¶
type User struct {
crud.Base
// Email identifies the person within the tenant. It is stored as it was
// given and compared without case, which is what the unique index in
// migrations/000007 does too.
Email string `` /* 186-byte string literal not displayed */
// DisplayName is what a screen shows. It is optional: an invitation has an
// address and nothing else.
DisplayName string `json:"displayName,omitempty" gorm:"type:text;not null;default:''" maxLength:"200" doc:"Name to show" example:"Ada Lovelace"`
// Status is a closed set; the enum tag is what a form renders as a select
// and what Validate refuses a value outside.
Status string `` /* 183-byte string literal not displayed */
// Roles are the names of the roles this person holds. What a name grants is
// the auth module's business, which is why this is a list of strings and
// not a list of permissions: a role can be re-granted without touching a
// single user row.
Roles Roles `json:"roles" gorm:"type:text[];not null;default:'{}'" required:"false" doc:"Roles this person holds in this tenant"`
// PasswordHash is argon2id in the PHC encoding, or empty for somebody who
// has never set one — an invited user, or one who only signs in through an
// identity provider. It is json:"-", so it is in no response, in no request
// and in no generated screen.
PasswordHash string `json:"-" gorm:"type:text"`
}
User is one person in one tenant.
The struct is the whole surface: the json tags are the API, the gorm tags are the table, and crud.Base contributes the id, the timestamps, the soft delete and the tenant column row-level security matches on.
func (*User) CheckPassword ¶
CheckPassword reports whether password is this user's.
It recomputes with the parameters the stored hash carries rather than the constants above, so a hash written under older parameters still verifies, and it compares in constant time, so the number of leading bytes that matched is not something a caller can measure.