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