account

package
v0.2.0 Latest Latest
Warning

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

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

Documentation

Overview

Package account owns people: users, the teams they belong to, the role they hold in each, and the sessions and invitations that get them there.

Index

Constants

View Source
const DefaultCookieName = "yacht_session"

DefaultCookieName is the cookie the session provider reads.

View Source
const (
	// MinPasswordLength is counted in runes rather than bytes so that a
	// passphrase in a non-Latin script is not held to a shorter limit than the
	// same number of characters in ASCII.
	//
	// Exported so that the form can say the number and the browser can enforce
	// it before a round trip does. The rule itself stays here — a minlength
	// attribute is a courtesy, not a check.
	MinPasswordLength = 12
)
View Source
const StepUpWindow = 10 * time.Minute

StepUpWindow is how long a session stays recently-authenticated.

Long enough to change a password and then think better of it without proving twice; short enough that a laptop somebody walked away from is not a credential.

Variables

View Source
var (
	ErrPasswordTooShort = errors.New("account: a password must be at least 12 characters")
	ErrPasswordTooLong  = errors.New("account: that password is too long")
)

ErrPasswordTooShort and ErrPasswordTooLong are two of the three things this package will refuse a password for.

There are no composition rules, deliberately. Every rule is a constraint an attacker subtracts from the search space, and in practice they push people towards a shorter password with a digit on the end. Length is the only lever that is monotonically good, which is why it is the only one pulled.

View Source
var ErrCredentialInvalid = errors.New("account: those sign-in details are not valid")

ErrCredentialInvalid is returned for every failed password sign-in.

One error for an unknown address, an address with no password, a wrong password, and a stored hash that will not parse. A caller that could tell them apart would be a user-list disclosure with extra steps, which is the same rule ErrTokenInvalid and ErrSessionInvalid already state.

View Source
var ErrForbidden = errors.New("account: not permitted")

ErrForbidden is returned when the actor's role does not carry the authority the change needs.

View Source
var ErrInvitationNotFound = errors.New("account: no such invitation")

ErrInvitationNotFound is returned when there is no such invitation to revoke in this team.

Distinct from success so a caller can say "there was nothing to revoke" rather than reporting that a still-live token has been withdrawn.

View Source
var ErrLastOwner = errors.New("account: a team must keep at least one owner")

ErrLastOwner is returned when a change would leave a team with no owner.

View Source
var ErrNeedsReauthentication = errors.New("account: this needs to be confirmed first")

ErrNeedsReauthentication is returned when a session has not proved who is holding it recently enough to change how it can be signed in to.

Not a failure to report as an error: it is the signal that asks the person to confirm, so it has to be distinguishable, which is why it is not folded into ErrForbidden.

View Source
var ErrNoPassword = errors.New("account: no password is set")

ErrNoPassword is returned when there was nothing to remove.

Distinct from success so that the page can say "you do not have one" rather than report that a credential which never existed has been withdrawn — which is how somebody stops looking for the one that is still there.

View Source
var ErrNotAMember = errors.New("account: not a member of this team")

ErrNotAMember is returned when a person holds no role in a team.

View Source
var ErrPasswordIsEmail = errors.New("account: a password cannot be your email address")

ErrPasswordIsEmail is the third. An address is the one string an attacker guessing this account already has in front of them.

View Source
var ErrSessionInvalid = errors.New("account: session is not valid")

ErrSessionInvalid is returned for every session that cannot be used.

One error for unknown, expired and malformed alike: a caller that could tell them apart could tell a real token from a guess, and so could anyone probing the endpoint that surfaces the difference.

View Source
var ErrTeamTaken = errors.New("account: a team with that id already exists")

ErrTeamTaken is returned when the team id already belongs to someone else.

View Source
var ErrTokenInvalid = errors.New("account: token is not valid")

ErrTokenInvalid is returned for every magic link and invitation that cannot be used.

One error for unknown, expired, already-consumed and malformed alike. A caller that could tell them apart would hand that difference to whoever is holding the token, and "this link has already been used" tells an attacker their guess was a real link.

Functions

func HashToken

func HashToken(raw string) []byte

HashToken returns the stored form of a token.

Plain SHA-256, deliberately: unlike a password this is full-entropy random, so there is nothing to brute-force and a slow KDF would only add latency to every request that carries a session cookie.

func NewToken

func NewToken() (raw string, hash []byte, err error)

NewToken returns a fresh credential and the hash to store for it.

One implementation shared by sessions, magic links and invitations, so all three get the same entropy and the same storage rule rather than three chances to get it wrong.

The caller sends raw to the person and stores hash. Never the reverse: a database dump must not be a set of working credentials.

Types

type Invitation

type Invitation struct {
	ID        uuid.UUID
	TeamID    string
	Email     string
	Role      Role
	ExpiresAt time.Time
	CreatedAt time.Time
}

Invitation is an outstanding invitation, as a page that offers to withdraw it needs it.

There is no token field, and there is no field the token can be derived from. The struct is the shape the team page renders, and a page that printed the invitation link would let anyone who can read it — including whoever is looking over the reader's shoulder — join as the invitee. Leaving the secret out of the type is what makes that impossible rather than merely avoided.

type Member

type Member struct {
	UserID    uuid.UUID
	Email     string
	Name      string
	Role      Role
	CreatedAt time.Time
}

Member is one person's place in a team, carrying enough of the person for a management page to name them without a query per row.

type Membership

type Membership struct {
	UserID    uuid.UUID
	TeamID    string
	TeamName  string
	Role      Role
	CreatedAt time.Time
}

Membership is a person's place in a team, carrying the team's name so a switcher can be rendered without a query per row.

type Method

type Method string

Method names a way in.

It exists for the sign-in log line, where an operator reading an unexpected session needs to know which door it came through. It is deliberately not a field on Session and not an argument to anything that makes a decision: nothing downstream of a completed sign-in is allowed to behave differently by method, because that is how two ways in stop producing the same session.

const (
	MethodMagicLink Method = "magic_link"
	MethodPassword  Method = "password"
)

type Proof

type Proof struct {
	CurrentPassword string
}

Proof is how a session re-proves who is holding it.

Empty means "rely on a recent sign-in". Somebody who has no password has no other option, and that is exactly the case adding a first password has to support — which is why this is a struct with one optional field rather than a required argument.

type Role

type Role string

Role is what a person may do in a team.

The split is at the line where an action stops being undoable by redeploying: a member deploys, scales and redeploys; an admin can also delete apps, manage domains and storage, and invite people; an owner can also manage admins and the team itself.

const (
	RoleOwner  Role = "owner"
	RoleAdmin  Role = "admin"
	RoleMember Role = "member"
)

func (Role) AtLeast

func (r Role) AtLeast(other Role) bool

AtLeast reports whether r carries at least the authority of other.

func (Role) CanAdminister

func (r Role) CanAdminister() bool

CanAdminister covers deleting apps, managing domains and storage, and inviting people.

func (Role) CanOwn

func (r Role) CanOwn() bool

CanOwn covers managing admins, transferring ownership and deleting the team.

func (Role) Valid

func (r Role) Valid() bool

Valid reports whether r is a role the system knows.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service owns people, teams and the roles between them.

func NewService

func NewService(pool *pgxpool.Pool, log *slog.Logger) *Service

NewService wires the store into the account service.

func (*Service) AcceptInvitation

func (s *Service) AcceptInvitation(
	ctx context.Context, raw string, userID uuid.UUID,
) (string, Role, error)

AcceptInvitation spends an invitation and returns the team and role it grants.

The invitation is spent and the membership written in one transaction, so an invitation can never be marked accepted by someone who did not end up in the team, and the token can never be spent twice.

func (*Service) AuthenticatePassword

func (s *Service) AuthenticatePassword(ctx context.Context, email, plain string) (User, error)

AuthenticatePassword verifies an address and a password and returns the person they prove.

Returns a User and nothing else, so that it produces exactly what ConsumeMagicLink produces and the caller opens a session the same way for both. Rate limiting is the caller's, because the limiter that exists is in-process and documents itself as a brake rather than a boundary. The equal cost of a hit and a miss is this function's, and is not something a caller could add afterwards.

func (*Service) BootstrapOwner

func (s *Service) BootstrapOwner(
	ctx context.Context, teamID, teamName string, user User,
) error

BootstrapOwner gives someone with no team one to act as, handing the first of them the team the install was already running as.

This is the spec's third lockout guard. An install that switches accounts on has apps already deployed under YACHT_OWNER_ID; without this the first person to sign in lands in a fresh empty team and those apps belong to an owner nobody can authenticate as — visible only as a dashboard that has lost everything.

The configured team is claimed only when it has no owner at all, and that is read and acted on inside one transaction holding the team row. Two people completing sign-in at the same moment therefore produce exactly one owner: the loser takes the second branch and gets a team of their own. The check is "has no owner" rather than "has no members" so that an install whose team was left with members but no owner is still recoverable.

Idempotent: someone who already holds a role in the configured team is left exactly as they are, so a retried sign-in cannot mint a second team.

func (s *Service) ConsumeMagicLink(ctx context.Context, raw string) (User, error)

ConsumeMagicLink spends a sign-in link and returns the person it proves.

Spending and reading are one statement, so a link cannot be followed twice — a link sits in a mailbox forever, and one that still worked the second time would be a permanent key to the account.

func (*Service) CreateSession

func (s *Service) CreateSession(
	ctx context.Context, userID uuid.UUID, teamID, userAgent, ip string, ttl time.Duration,
) (string, error)

CreateSession issues a session for a person acting in a team, and returns the token to put in their cookie.

The token is returned once and never again: only its hash is stored, so a database dump is not a set of live sessions.

Membership of teamID is checked here rather than trusted from the caller. The active team is what every downstream query is scoped by, so a session opened onto a team the person does not belong to is a silent grant of that team's apps.

func (*Service) CreateTeam

func (s *Service) CreateTeam(
	ctx context.Context, id, name string, ownerUserID uuid.UUID,
) (Team, error)

CreateTeam creates a team and makes the creator its owner.

Both writes happen in one transaction: a team whose creator is not a member is a team nobody can administer, and it would take a database edit to fix.

An id already held by another team is refused rather than adopted. The underlying upsert exists so an operator can re-run creation idempotently, but without this check picking an existing id would hand the caller ownership of that team's apps.

func (*Service) EnsureUser

func (s *Service) EnsureUser(ctx context.Context, email, displayName string) (User, error)

EnsureUser returns the user for an address, creating them if new.

The address is lowercased on the way in, because a person who signs up as Alice@ and is later invited as alice@ is one person. An empty display name leaves the stored one alone: sign-in knows the address and not the name, and must not blank out what the person set.

func (*Service) HasPassword

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

HasPassword reports whether an account can be signed in to with one.

Answers the account page, which already knows who it is rendering for, so it takes a user id rather than a session. It is never called from a signed-out path: "does this address have a password" is precisely the disclosure the sign-in design exists to withhold.

func (*Service) InvitationFor

func (s *Service) InvitationFor(ctx context.Context, raw string) (Invitation, error)

InvitationFor reads an invitation without spending it.

Needed because acceptance is bound to the address the invitation names, so a signed-out visitor cannot simply be let in on the strength of holding the token. Reading it tells the caller which mailbox to prove, and spends nothing if they cannot.

func (*Service) Invite

func (s *Service) Invite(
	ctx context.Context, actor uuid.UUID, teamID, email string, role Role, ttl time.Duration,
) (string, error)

Invite issues an invitation to join a team, and returns the token to mail.

The actor's authority is checked with the same rule that governs role changes: inviting is administration, and an admin cannot invite anyone who could administer, because appointing an admin is a step away from appointing an owner.

Inviting an address that already has an outstanding invitation replaces it, so the older token stops working. Two live tokens for one address would mean revoking the invitation on screen leaves the other one usable.

func (s *Service) IssueMagicLink(
	ctx context.Context, email string, ttl time.Duration,
) (string, User, bool, error)

IssueMagicLink mints a sign-in link for an address, if anyone holds it.

existed reports whether the address is registered, and is the only thing that differs between the two cases. The caller mails a link when it is true and nothing when it is false, while answering the visitor identically either way: a sign-in form that responds differently to a registered address is a user-list disclosure.

An unknown address is deliberately not registered here. Doing so would let anyone fill the user table from the sign-in form, and would make the first sign-in of a stranger indistinguishable from that of an invited person.

func (*Service) ListMembers

func (s *Service) ListMembers(ctx context.Context, teamID string) ([]Member, error)

ListMembers returns everyone in a team, with the role each holds.

Scoped by team and by nothing else. There is no actor to check here because the caller has already been proved to be acting in this team — the role gate on the route resolved the session, and the team id it hands over is that session's own active team rather than anything the request supplied. A team id that arrived from a form would make this a directory of everybody.

func (*Service) ListPendingInvitations

func (s *Service) ListPendingInvitations(
	ctx context.Context, teamID string,
) ([]Invitation, error)

ListPendingInvitations returns the invitations to a team that nobody has accepted and that have not expired.

Scoped by team, for the same reason ListMembers is: the team id comes from the caller's own session, which the role gate has already resolved.

func (*Service) Provider

func (s *Service) Provider(cookieName string) *Sessions

Provider returns the identity.Provider backed by these sessions.

func (*Service) RemoveMember

func (s *Service) RemoveMember(
	ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID,
) error

RemoveMember takes a person out of a team.

func (*Service) RemovePassword

func (s *Service) RemovePassword(ctx context.Context, sessionID uuid.UUID, proof Proof) error

RemovePassword takes the password off an account, leaving the magic link.

Allowed deliberately: the person can still get in through their mailbox, so this cannot lock anybody out, and refusing it would mean a password added once can never be taken back. Every other session goes with it — removing a password is what somebody does when they believe it is known.

func (*Service) ResolveSession

func (s *Service) ResolveSession(ctx context.Context, raw string) (Session, error)

ResolveSession returns the session a token stands for.

The presented token is hashed and looked up by hash; raw values are never compared, because there is no raw value stored to compare against.

func (*Service) RevokeAllSessions

func (s *Service) RevokeAllSessions(ctx context.Context, userID uuid.UUID) error

RevokeAllSessions ends every session a person holds.

This is the answer to a stolen cookie. Without it a leaked session runs until it expires and there is nothing the person can do about it.

func (*Service) RevokeInvitation

func (s *Service) RevokeInvitation(
	ctx context.Context, actor uuid.UUID, teamID string, id uuid.UUID,
) error

RevokeInvitation withdraws an invitation that has not been accepted.

Deleting the row is what takes the token out of circulation, which is the only way to take back an invitation sent to the wrong address — the mail itself cannot be recalled.

func (*Service) RevokeOtherSessions

func (s *Service) RevokeOtherSessions(ctx context.Context, userID, keep uuid.UUID) error

RevokeOtherSessions ends every session a person holds except one.

RevokeAllSessions cannot serve here: it takes out the session doing the asking, so a change made from the account page would sign the person out of the browser they are looking at, and they would read that as the change having failed rather than having worked.

func (*Service) RevokeSession

func (s *Service) RevokeSession(ctx context.Context, raw string) error

RevokeSession ends one session — the sign-out on this browser.

func (*Service) RoleIn

func (s *Service) RoleIn(ctx context.Context, userID uuid.UUID, teamID string) (Role, error)

RoleIn returns the role a person holds in a team.

Absence is ErrNotAMember rather than an empty role, so a caller cannot mistake "no membership" for "least privilege" and let a stranger in as a member.

func (*Service) SetPassword

func (s *Service) SetPassword(
	ctx context.Context, sessionID uuid.UUID, plain string, proof Proof,
) error

SetPassword adds or replaces the password on the account holding a session.

It takes a session id and not a user id, for the reason SwitchTeam takes one: whose password this is comes from the session row, never from anything the request supplied. A user id in this signature would be a way to set anybody's password by knowing their id, and the only thing standing in the way would be every caller remembering to check.

Recent authentication is verified here rather than trusted from the caller, the same way CreateSession verifies membership itself. Adding a password is how a stolen cookie becomes a permanent credential, so the check that stops that must not be skippable by a handler that forgot to make it.

No old password is required as such, and that is not a relaxation. Somebody adding their first password has no old one, so demanding it for a change but not for an add would be two flows, two forms and two chances to get the recency check wrong. One Proof covers both, and for a person who does have a password, typing it is what satisfies it.

Replacing a password ends every other session; adding a first one does not. Gaining a way in is not a change of trust, and signing somebody out of their other devices for it reads as the feature having broken something. The difference is read inside the transaction, so it cannot race.

func (*Service) SetRole

func (s *Service) SetRole(
	ctx context.Context, actor uuid.UUID, teamID string, target uuid.UUID, role Role,
) error

SetRole gives target a role in a team, adding them if they are not a member.

func (*Service) SwitchTeam

func (s *Service) SwitchTeam(ctx context.Context, sessionID uuid.UUID, teamID string) error

SwitchTeam points a session at another of the person's teams.

Membership is checked against the session's own user, not against anything the request supplied, so a crafted team id switches into nothing.

func (*Service) TeamsFor

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

TeamsFor returns every team a person belongs to, with the role they hold.

func (*Service) User

func (s *Service) User(ctx context.Context, userID uuid.UUID) (User, error)

User returns the person a user id names.

Takes an id rather than an address, because every caller already holds one from a session they resolved. A lookup by address reachable from a handler would be a way to ask whether somebody has an account, which is the disclosure the sign-in design exists to withhold.

type Session

type Session struct {
	ID           uuid.UUID
	UserID       uuid.UUID
	ActiveTeamID string
	TeamName     string
	TeamEmail    string

	// Role the person holds in the active team. Carried on the session because
	// resolving one already proves the membership exists — sub-project C's
	// route gates would otherwise re-query it on every request.
	Role Role

	UserAgent string
	IP        string
	ExpiresAt time.Time
	CreatedAt time.Time

	// AuthenticatedAt is when whoever holds this session last proved they are
	// its owner, as opposed to when the session began. Set at creation and
	// refreshed when a password is re-entered to confirm a change.
	//
	// Separate from CreatedAt because a session that re-proves itself is still
	// the same session. Folding the two together would mean the only way to
	// refresh the window is to mint a new one, which is a sign-in rather than a
	// confirmation.
	AuthenticatedAt time.Time
}

Session is a signed-in browser.

It carries the active team's name and address so the request that presented the cookie can be resolved to an owner in one round trip, the same reason Membership carries the team name.

func (Session) RecentlyAuthenticated

func (s Session) RecentlyAuthenticated(d time.Duration) bool

RecentlyAuthenticated reports whether this session proved who holds it within d.

Read at the moment of a write and never at page render: a form that was rendered inside the window and submitted outside it must not be honoured, and the service checks this itself for exactly that reason.

type Sessions

type Sessions struct {
	// contains filtered or unexported fields
}

Sessions resolves the owner of a request from a session cookie.

The third identity.Provider the engine ships, alongside SingleOwner and StaticToken. It exists as a Provider rather than as a replacement for the seam so that every handler, the app service, the store and the orchestrator stay untouched: they already take an OwnerID and never ask where it came from, and a wrapping application still swaps the whole provider out.

func (*Sessions) Resolve

func (p *Sessions) Resolve(ctx context.Context, r *http.Request) (identity.Owner, error)

Resolve returns the ACTIVE TEAM as the owner, not the user.

Everything downstream is scoped by owner_id, and owner_id means team. A session that resolved to the person would silently widen every query to every team they belong to.

type Team

type Team struct {
	ID          string
	DisplayName string
	Email       string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Team is a principal: the thing owner_id points at everywhere else.

type User

type User struct {
	ID          uuid.UUID
	Email       string
	DisplayName string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

User is a person.

Jump to

Keyboard shortcuts

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