contracts

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

View Source
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 seven events this module emits. The first three are kit/rest's, published by the Spec module.go mounts; the last four are the lifecycle's. Both sets are listed in the manifest, and kit/app refuses to start if a route would publish one that is not.

View Source
const (
	PermissionUserRead   = "user:read"
	PermissionUserManage = "user:manage"
)

The two permissions a user has. They are named after the resource and not after the module, and there are two rather than five because reading a colleague's profile and changing it are the only distinction anybody has yet wanted to grant separately.

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.

View Source
const (
	StatusInvited  = "invited"
	StatusActive   = "active"
	StatusInactive = "inactive"
)

The lifecycle. A user is invited before they have a password, active once they can sign in, and inactive when somebody has taken that away. Deleting is kit/crud's soft delete, which keeps the row and releases the address.

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

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

func HashPassword(password string) (string, error)

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

type Deactivated struct {
	UserID uuid.UUID `json:"userId"`
	At     time.Time `json:"at"`
}

Deactivated is the payload of EventDeactivated: this person can no longer sign in.

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 PasswordSet

type PasswordSet struct {
	UserID uuid.UUID `json:"userId"`
	At     time.Time `json:"at"`
}

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

func (Roles) Has

func (r Roles) Has(name string) bool

func (*Roles) Scan

func (r *Roles) Scan(src any) error

func (Roles) Value

func (r Roles) Value() (driver.Value, error)

Value writes the array. Scan reads it. Both delegate to lib/pq, which is already linked in — golang-migrate speaks to Postgres through it — so this is the array codec the program already carries rather than a second one.

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 {
	// 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 and makes the user active. 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: the four commands generic CRUD cannot safely infer, because each is a rule about the state it came from and each publishes an event, plus the two reads the auth module needs.

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.

Each command is idempotent when repeated with the same argument: the callers that retry — a browser, a redelivered event — must not each produce an event.

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 `` /* 164-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) CanSignIn

func (u *User) CanSignIn() bool

CanSignIn reports whether this user could authenticate with a password.

func (*User) CheckPassword

func (u *User) CheckPassword(password string) bool

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.

func (User) TableName

func (User) TableName() string

TableName pins the table, so the entity and migrations/000007 agree.

func (*User) Validate

func (u *User) Validate(context.Context) error

Validate is the entity's own check, run by kit/crud on every write whichever door it came through. It normalises as well as refuses: an address that differs only in case or in whitespace is the same mailbox, and two callers must not disagree about that.

Directories

Path Synopsis
Package usertest is the conformance suite for contracts.Service, and a fake that passes it.
Package usertest is the conformance suite for contracts.Service, and a fake that passes it.

Jump to

Keyboard shortcuts

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