Documentation
¶
Overview ¶
Package auth is the first first-party module and the canonical reference: every module the CLI generates has exactly this shape.
Files of a module (vertical layout, not one directory per layer):
module.go -> registration and routes user.entity.go -> the entity user.policy.go -> who may do what user.repo.go -> data access, requires a Grant user.service.go -> business rules user.request.go -> input types and Validate
Index ¶
- Constants
- Variables
- func NewID() (string, error)
- func NormalizeEmail(email string) string
- func ResetAddress(payload string) string
- func ResetPayload(u User) string
- func SubjectOf(u User) security.Subject
- func VerificationPayload(u User) string
- type CreateUserRequest
- type LoginRequest
- type Module
- type RegisterRequest
- type Service
- func (s *Service) Authenticate(ctx context.Context, tenant, email, plain, client string) (User, error)
- func (s *Service) ConfirmPassword(ctx context.Context, sub security.Subject, plain, client string) error
- func (s *Service) CreateUser(ctx context.Context, actor security.Subject, in CreateUserRequest) (User, error)
- func (s *Service) EnsureAdmin(ctx context.Context, tenant, email, plain string) (User, error)
- func (s *Service) EnsureUser(ctx context.Context, tenant, name, email, plain string, roles []string, ...) (User, error)
- func (s *Service) FindForReset(ctx context.Context, tenant, email, client string) (User, error)
- func (s *Service) FindForVerification(ctx context.Context, tenant, userID string) (User, error)
- func (s *Service) Lookup(ctx context.Context, tenant, email string) (User, error)
- func (s *Service) MarkVerified(ctx context.Context, tenant, payload string) (User, bool, error)
- func (s *Service) Names(ctx context.Context, tenant string, ids []string) (map[string]string, error)
- func (s *Service) Register(ctx context.Context, tenant string, in RegisterRequest) (User, error)
- func (s *Service) ResetPassword(ctx context.Context, payload, address, plain string) (User, error)
- func (s *Service) SetPassword(ctx context.Context, tenant, email, plain string) (User, error)
- type TenantResolver
- type TooManyAttemptsError
- type User
- type UserEvent
- type UserPolicy
- type UserRepo
- func (r *UserRepo) Confirm(ctx context.Context, g security.Grant, id string, at time.Time) (bool, error)
- func (r *UserRepo) Create(ctx context.Context, g security.Grant, u User) (User, error)
- func (r *UserRepo) Delete(ctx context.Context, g security.Grant, id string) error
- func (r *UserRepo) Find(ctx context.Context, g security.Grant, id string) (User, error)
- func (r *UserRepo) FindByEmail(ctx context.Context, g security.Grant, email string) (User, error)
- func (r *UserRepo) List(ctx context.Context, g security.Grant, q data.Query) ([]User, error)
- func (r *UserRepo) NamesByID(ctx context.Context, g security.Grant, ids []string) (map[string]string, error)
- func (r *UserRepo) SetPassword(ctx context.Context, g security.Grant, id, hash string) error
- func (r *UserRepo) Update(ctx context.Context, g security.Grant, u User) (User, error)
Constants ¶
const ( ActionUserView security.Action = "auth.user.view" ActionUserCreate security.Action = "auth.user.create" ActionUserUpdate security.Action = "auth.user.update" ActionUserDelete security.Action = "auth.user.delete" )
Actions of this module. They are constants, not strings at the call site: a typo in an action name would silently authorize nothing, or worse, everything.
const ( // EventUserRegistered is somebody signing up. The account exists and the // address is not confirmed yet. EventUserRegistered = "auth.user.registered" // EventEmailVerified is the address being confirmed, and it is published // only by the click that confirmed it -- a second click on the same link // publishes nothing, or every welcome mail is sent twice. EventEmailVerified = "auth.email.verified" // EventPasswordReset is the password being replaced out of band. EventPasswordReset = "auth.password.reset" )
The domain events this module publishes.
They are the three moments another part of the system has to react to: a new account, an address that is now real, and a credential that changed. Anything else about a user is a row somebody can read.
The names are past tense and in the vocabulary of the domain, which is what events.Event asks for: a consumer that had to diff two user rows to learn what happened would be a consumer coupled to this table.
Variables ¶
var ErrEmailTaken = errors.New("auth: email already registered in this tenant")
ErrEmailTaken is returned when the tenant already has that address.
var ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInvalidCredentials is the single answer to a failed login. The reason lives in the log, never in the response: telling the client which half was wrong turns the endpoint into an account enumeration oracle.
var ErrResetLinkSpent = errors.New("auth: the password reset link is no longer valid")
ErrResetLinkSpent is a password reset link that no longer names a password this application would change.
One error for five refusals -- the payload does not parse, the typed address is not the one it was sent to, the account is gone, its address moved, the password already changed -- because the screen has one honest answer for all of them ("ask for another one"), and because five distinct answers would be five facts about somebody's account handed to whoever is holding a link they should not have.
var ErrUserNotFound = errors.New("auth: user not found")
ErrUserNotFound is returned when no row matches, so callers do not have to import database/sql to compare against sql.ErrNoRows.
var ErrVerificationAddressChanged = errors.New("auth: the verification link was issued for a different address")
ErrVerificationAddressChanged is a link that no longer belongs to the account it names: the address was replaced after the mail went out, or the payload is not one this application minted.
It is one error and not two because a handler has one answer for both -- the link is not valid -- and because a payload carrying no address cannot match the stored one, which is the same refusal for the same reason.
Functions ¶
func NewID ¶ added in v0.2.0
NewID returns a version 4 UUID as text.
It forwards to data.NewID, which is where the one implementation lives: two id generators in one binary is two answers to "what does an id look like".
func NormalizeEmail ¶ added in v0.2.0
NormalizeEmail lowercases and trims the address.
Addresses are case-insensitive in practice, and storing them normalized is what keeps a plain UNIQUE index correct on every engine -- rather than a functional index in Postgres and a collation in MySQL.
func ResetAddress ¶ added in v0.25.0
ResetAddress is the address a reset payload was minted for, or the empty string when the payload is not one this application wrote.
It exists so the screen behind the link can fill in the e-mail field it asks for. That field was Required and never filled, so the one thing the person had to type on a form reached from their own inbox was the address the link had just been sent to.
It is not authorization and proves nothing on its own: read it only from a payload that has already come out of security.Signer.Verify.
func ResetPayload ¶ added in v0.25.0
ResetPayload is what a password reset link carries: the tenant, the account, the address it was mailed to, and a fingerprint of the password that account had when the link was minted.
Nothing is written when the mail goes out. That is the whole design (ADR 0032): no table, no cleanup job, no decision about what a click means once the row is gone -- and the second replica behind a load balancer accepts a link the first one issued, which an in-memory store of tokens cannot.
What makes it single use ¶
The fingerprint. Replacing the password replaces the hash, so every link ever minted against the old one stops verifying at the same instant -- the one that was just used, and any earlier ones still sitting in the inbox. A table would have had to delete the used row, remember to delete the siblings, and sweep what nobody ever clicked; this expires itself, and it expires the whole set.
Why the tenant is in it ¶
The link is consumed with no session, so there is no Grant to read the tenant from. Resolving it from the host at that moment is what RULE 14 forbids, and concretely: a link minted for one customer, posted at another customer's host, changed the password of whichever account had that address there. Read from the payload the tenant is not request data -- it is a value this application signed, which is the provenance a session cookie has.
Why the fields carry their length ¶
Every field is written with its byte length in front of it, for the reason security.Signer writes the purpose that way. With a plain separator, a tenant of "a" with an id of "b|c" and a tenant of "a|b" with an id of "c" are the same byte string, and a link minted for one account resets another.
func SubjectOf ¶
SubjectOf builds the session subject for a user. It is the only place that decides what goes into the session, which is what keeps roles out of reach of the request body.
func VerificationPayload ¶ added in v0.25.0
VerificationPayload is what a verification link carries: the account, and the address the link was mailed to.
The address is in it because a link carrying only an id confirms whatever address the account has at the moment it is clicked, not the one somebody proved control of. Change the address after the mail is out and the old link still stamps the new one as verified, which is verification proving nothing. It was latent here only because no method changed an address yet, and whoever writes that method has no reason to open this file. Laravel binds the same two things, by hashing the address into the signed route.
The id is written with its length in front of it, for the reason security.Signer writes the purpose that way: without it an id of "a" with an address of "b|c" and an id of "a|b" with an address of "c" are the same byte string, and a link minted for one account verifies another.
Types ¶
type CreateUserRequest ¶
CreateUserRequest is the input contract. Fields are explicit: there is no mass assignment, so a request body cannot write a column nobody meant to expose -- the bug class does not exist here rather than being contained.
func (CreateUserRequest) Validate ¶
func (r CreateUserRequest) Validate() validation.Errors
Validate reports the errors per field.
type LoginRequest ¶
LoginRequest is the login input. It does not check the password length: the rule that applies here is whether the credentials match, and a length message on login would leak the policy of existing accounts.
The identifier is the e-mail address and there is no field for anything else. That is a decision rather than an omission: this type, the form field named "email", Service.Authenticate's parameter and UserRepo.FindByEmail all name the same thing, and adding a general Identifier here would leave three of them still meaning an address. Service.Authenticate carries the full argument for why there is no username() hook and what an application does instead.
func (LoginRequest) Validate ¶
func (r LoginRequest) Validate() validation.Errors
Validate reports the errors per field.
type Module ¶
type Module struct {
// contains filtered or unexported fields
}
Module registers the authentication routes.
func New ¶
func New(svc *Service, tenant TenantResolver) *Module
New returns the module. The service and the tenant resolver are built by the caller, because the wiring is explicit: if you want to know where UserRepo comes from, it is written in the application's main.
A nil resolver means the empty tenant, which is what a single-tenant application that never set one ends up with -- consistent, and still isolated, because every row is written with that same value.
func (*Module) Health ¶
Health reports whether the module can reach its storage. It feeds /_arandu/health, so a database that went away turns into a failing probe rather than a stream of 500s.
func (*Module) Migrations ¶
Migrations declares the schema this module owns.
Every type here spells the same in SQLite, PostgreSQL and MySQL, which is what lets one project develop on a file and deploy on Postgres without a second schema. The three things that would have broken that, and what replaced them:
- uuid columns are TEXT, and the id is generated by the application;
- roles are TEXT holding JSON, not jsonb and not text[];
- created_at has no database default, the value comes from Go.
The email is stored lowercased by the repository, so a plain UNIQUE index is enough and no database-specific case-insensitive collation is needed.
func (*Module) Routes ¶
Routes registers the module's routes.
The sign-in screen is guarded, and it is the one route here that needs to be: without the guest guard it renders for somebody who already has a session, which reads to them as having been signed out. There is nothing to guard on the two POSTs -- signing in again is harmless, and signing out without a session is a no-op that ends where it should.
type RegisterRequest ¶ added in v0.19.0
type RegisterRequest struct {
Name string
Email string
// Password and PasswordConfirmation are the two boxes of the form. Both are
// here rather than compared in the handler, so the rule is in the same place
// as the length rule and is tested with it.
Password string
PasswordConfirmation string
}
RegisterRequest is what a self-registration form sends.
It has no Roles field, and that is the whole difference from CreateUserRequest. A registration form that carried roles would be a registration form that could ask for "admin" -- and the only thing between the request and the column would be the handler remembering to drop it.
The policy refuses it a second time, on the candidate rather than on the request. Two answers to the same question, because this one is the one that still holds after somebody adds a field here.
func (RegisterRequest) Validate ¶ added in v0.19.0
func (r RegisterRequest) Validate() validation.Errors
Validate reports the errors per field.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service holds the business rules. It receives its dependencies through the constructor -- explicit wiring, generated by the CLI, no container.
func NewService ¶
NewService wires the module.
func (*Service) Authenticate ¶
func (s *Service) Authenticate(ctx context.Context, tenant, email, plain, client string) (User, error)
Authenticate verifies the credentials and returns the user.
The identifier is the e-mail address, and there is no hook to change it ¶
This is the paragraph for whoever came looking for Laravel's username(). There is no equivalent, deliberately: an account is named by its address, in the form field, in LoginRequest.Email, in this parameter and in UserRepo.FindByEmail, and all four say so.
What a hook would cost is not one method. The address is normalised on the way in and on the way out, and a plain UNIQUE (tenant_id, email) is what makes the lookup case-insensitive on every engine (see NormalizeEmail); the throttle key lower-cases and trims the identifier for that same reason, so a second kind of identifier with different rules is a second budget for the same account and twice the guesses. Registration, verification and the reset link are all built on proving control of an address -- a person who signed in by handle would have no proven address to reset through. And the hook itself is the second way to name an account, which is what RULE 9 refuses: two applications on this framework would disagree about what a login is.
Signing in by handle is therefore an application-level feature -- resolve the handle to an address and call this -- and never a switch inside the framework.
Note that there is no repository call without a Grant: the lookup by email uses SystemGrant, because at this point in the request there is no subject yet. That call is auditable, which is the point -- `aru doctor --strict` lists it.
client is where the attempt came from, and it is half of the throttle key: pass middleware.KeyByIP(r), which reads the peer address and never a header the client can write. It is a parameter rather than something read from the context because a value that arrives implicitly is a value that arrives empty, and an empty one merges every attacker in the world into one counter -- which turns the lockout below into the way to lock a real account out.
The throttle takes its unit here, before the users table is read, rather than after the password turns out to be wrong. Recording the failure afterwards leaves the length of an argon2 hash in which nothing has been written down, and a burst of simultaneous guesses all pass through it: measured, a budget of five answered eight requests fired at once, and would have answered as many as an attacker cared to open sockets for. The unit is given back below when the attempt never reached a credential, and forgotten entirely when it turns out to be the account's owner.
func (*Service) ConfirmPassword ¶ added in v0.25.0
func (s *Service) ConfirmPassword(ctx context.Context, sub security.Subject, plain, client string) error
ConfirmPassword re-verifies the password of a subject that is already signed in, for a screen that asks for it again before something that matters.
It is not Authenticate. That one is the sign-in path: it takes an address, decides a tenant and hands back a user to open a session for. Widening it to also accept a subject would give one function two meanings and two failure modes, and the caller would pick between them with a nil (RULE 9). This one takes the subject the session already carries and answers a yes or no.
It is throttled, and that is not optional: without it a screen behind RequireAuth is a password oracle for anybody holding a stolen session cookie -- unlimited guesses at the password of the account they have already taken, which is the password they will try on the next site.
client is where the request came from: pass middleware.KeyByIP(r).
func (*Service) CreateUser ¶
func (s *Service) CreateUser(ctx context.Context, actor security.Subject, in CreateUserRequest) (User, error)
CreateUser shows the full path: validate, Authorize, Grant, Repository.
func (*Service) EnsureAdmin ¶
EnsureAdmin creates the first administrator of a tenant when it has none.
It is deliberately NOT called at boot: seeding that happens by itself is how a known password ends up in production. Call it from a one-off command, with credentials that come from the environment.
func (*Service) EnsureUser ¶ added in v0.19.0
func (s *Service) EnsureUser(ctx context.Context, tenant, name, email, plain string, roles []string, verified bool) (User, error)
EnsureUser is EnsureAdmin for any account, and it is what a seeder calls.
Same circle, same break: a seeder has no request behind it and therefore no subject, so there is no policy to ask. It is in the module that owns the table rather than in each application's seeders, so `aru doctor` has one call site to account for instead of one per project.
verified writes the timestamp. A seeded reader who cannot comment because nobody clicked a link in a mailbox that does not exist is a demo that does not demonstrate anything -- and the flag is explicit here, so a seeder that wants the unverified case gets it by asking.
It is idempotent: an existing address is returned untouched, including its password. A seeder that reset the password of an existing account would be a seeder that locked somebody out on the second run.
func (*Service) FindForReset ¶ added in v0.25.0
FindForReset returns the account a password reset link would be minted for, after taking one unit of the throttle's budget.
The caller must answer identically whether this returns a user or ErrUserNotFound. A "send me a link" form that says "no such account" is an account enumeration oracle with a nicer name, and it is a better one than a sign-in form: one request, one bit, no password to guess.
It is throttled here rather than in the screen for the reason Authenticate is: the published screen belongs to the project the moment the starter kit writes it, and a control in a file the project may delete is a control that disappears. Unthrottled, this endpoint is a way to send mail from this application's domain, to an address chosen by whoever found the URL, as often as they ask -- which is a sending reputation, not a form.
client is where the request came from: pass middleware.KeyByIP(r), which reads the peer address and never a header the caller can write.
func (*Service) FindForVerification ¶ added in v0.19.0
FindForVerification reads a user by id, for the handler that has just checked a signed link and needs to know who it is about.
It exists so that the handler does not reach the repository, and so that the SystemGrant is here, in the module that owns the table, where `aru doctor` already expects to find them.
func (*Service) Lookup ¶ added in v0.19.0
Lookup returns a user by address, for a caller with no subject to authorize.
It is what a seeder uses to find an account it did not create in the same run. The alternative is a SELECT on the users table from outside this module, and then that table has two owners -- which is how a schema change breaks code nobody thought was reading it.
Never call it to decide whether an address is registered in a response. That is an account enumeration oracle, and every screen in this framework that could have been one answers the same thing either way.
func (*Service) MarkVerified ¶ added in v0.19.0
MarkVerified records that the address was confirmed, and reports whether this call is what confirmed it.
The boolean is what lets a handler tell "welcome, you are in" from "you already did this" -- the second one happens every time somebody clicks the link in a second e-mail client, and answering it with an error reads as the link being broken.
A verification link proves control of the address it was sent to, and nothing else. So this takes the payload of the signed token and reads the row itself: nothing here is trusted to arrive from the request. The address in the payload is compared with the stored one, which is what binds the link to what it proved -- see VerificationPayload and ErrVerificationAddressChanged.
The comparison is a plain one rather than a constant-time one: the address is not a secret from whoever is holding a link that was mailed to it, and the signature was already checked in constant time before this was called.
The flip itself is UserRepo.Confirm, which changes the column only while it is still null. The check above cannot stand in for it: a link is opened by the person and prefetched by whatever scans their mail within the same second, and two reads that both saw an unverified row both used to write, and both used to store the event -- which is the welcome mail sent twice by a consumer behaving correctly. The boolean now comes from the database rather than from a read that had already gone stale.
func (*Service) Names ¶ added in v0.19.0
func (s *Service) Names(ctx context.Context, tenant string, ids []string) (map[string]string, error)
Names resolves user ids to display names, for a screen that shows who wrote something.
One query for the whole list. A comment thread that looked each author up separately would be an N+1 on the page most likely to have twenty rows on it.
A failure is the caller's to decide about: a thread is worth rendering with ids in it, and not worth failing over.
func (*Service) Register ¶ added in v0.19.0
Register creates a user from a registration form.
It is CreateUser with a guest in place of the actor, and nothing else. The tenant comes from the resolver rather than from the form -- a tenant a registration form could name is a registration form that joins any customer -- and the roles are not read from the input at all: RegisterRequest has none, and the policy refuses a candidate that has any.
The returned user is unverified. Sending the link is the caller's, because who the mail is from and what the link points at are the application's business and not this module's.
func (*Service) ResetPassword ¶ added in v0.25.0
ResetPassword replaces the password of the account a reset payload names.
The payload must already have come through security.Signer.Verify -- this checks what a signature cannot, which is whether the link is still the current one. See ResetPayload for why that is a fingerprint and not a row.
address is what the person typed on the form, and it is compared with the one the link was minted for. It changes no outcome an attacker cares about, since the token is the credential; what it does is stop the form's Required e-mail field from being decoration. It was: the screen asked for an address, threw it away, and reset whichever account the token named.
There is no tenant parameter. The tenant is in the payload, signed, exactly so that this call cannot be made to depend on the host the link was opened at.
func (*Service) SetPassword ¶ added in v0.19.1
SetPassword replaces the password of an existing account, and publishes EventPasswordReset.
It is the only method that replaces a password, and every caller goes through it: the published reset screen after it has consumed the signed link, the operator command for when nobody can reach that link -- the mail is not configured yet, the domain is not verified yet, the account is the first administrator and there is nobody to ask -- and a seeder. A second method for the screen would be a second place the hash is computed and a second place the event is published from, and one of the two would eventually stop publishing it (RULE 9).
It proves nothing itself, and that is deliberate rather than missing. Who is allowed to do this is the caller's: the screen proves control of the address with a signed link before it calls, the command is a person at a terminal. Rules about the password -- a minimum length, a history -- are the application's for the same reason, and the published screen enforces its own. So do not reach this from anything a request can address without that proof in front of it.
The hash is computed here, so no caller can be handed the chance to store a plain password by writing the field directly. It is computed before the transaction opens, because argon2 is a tenth of a second and holding a row lock for it is a tenth of a second every other writer of that row waits.
It writes one column ¶
This used to read the row, set the field, and write the whole row back with UserRepo.Update -- with the read outside the transaction. Anything that changed in between was reverted from a stale snapshot: a role granted while somebody was resetting their password disappeared again, and a verification clicked in the same minute was undone. Nothing failed and nothing was logged. UserRepo.SetPassword writes the one column, which is what MarkVerified was given in UserRepo.Confirm and for the same reason.
The row is read back inside the transaction rather than reported from the snapshot, so what is returned and what is published describe the account as it is at commit. That matters most for the event: EventPasswordReset is what a "your password was changed" notice is sent from, and a payload carrying the address the account had a minute ago sends that notice to an address that no longer belongs to it.
type TenantResolver ¶ added in v0.1.1
TenantResolver decides which tenant a request belongs to.
It is only consulted on login, which is the one moment where there is no session to ask: everywhere else the tenant comes from the Grant, and therefore from the session. That asymmetry is the point -- a tenant taken from the request body or from a header after login would defeat the isolation the whole policy layer is built on.
Phase 2 adds the resolver that reads the host name, which is the default for a real multi-tenant deployment.
func FixedTenant ¶ added in v0.1.1
func FixedTenant(id string) TenantResolver
FixedTenant is the resolver for a single-tenant application: every login belongs to the same tenant.
This is not a "single-tenant mode". It is the same code path with a constant where the resolver would be, which is why an application that starts single and grows into multi changes one line and no queries.
type TooManyAttemptsError ¶ added in v0.25.0
type TooManyAttemptsError struct {
// RetryAfter is how much of the lockout is left.
RetryAfter time.Duration
}
TooManyAttemptsError is what a sign-in gets while the identity, and the address it arrived from, are locked out.
It carries the time left rather than being a bare sentinel, because the screen has to be able to say something true. "Try again later" with no number is what makes somebody press the button four more times, and each press is an argon2 hash this process did not need to compute.
It says nothing about the account, and cannot: the lockout is decided before the users table is read, so the same answer arrives for an address that is registered and one that never was. Otherwise the enumeration oracle that ErrInvalidCredentials exists to close would reopen here, and it would be a better one -- a lockout that fires only for real accounts is a yes/no answer with no guessing left in it.
func (TooManyAttemptsError) Error ¶ added in v0.25.0
func (e TooManyAttemptsError) Error() string
Error reports the lockout without naming the account it applies to.
func (TooManyAttemptsError) Seconds ¶ added in v0.25.0
func (e TooManyAttemptsError) Seconds() int
Seconds is RetryAfter as a whole number of seconds, rounded up and never below one. It is what the Retry-After header and the sentence on the form both need, and rounding up is what keeps "try again in 0 seconds" off the screen.
type User ¶
type User struct {
ID string
TenantID string
// Name is what the application shows instead of the address. It is optional
// -- an application that never asks for one leaves it empty and nothing
// breaks -- and it is the field a comment thread signs a post with.
Name string
Email string
Password string
Roles []string
// VerifiedAt is when the address was confirmed, and the zero value means it
// was not.
//
// A time and not a bool, because "when" is the question asked afterwards --
// by support, by a fraud check, by a report -- and a bool cannot be widened
// into it later without a second column.
VerifiedAt time.Time
CreatedAt time.Time
}
User is the entity. It has no persistence methods: this is not Active Record.
The Password field holds an argon2id hash and never leaves this type -- see MarshalJSON and LogValue below.
func (User) LogValue ¶
LogValue implements slog.LogValuer, so passing the whole user to a log call records the id and nothing else. This is the safe default: it means a careless log line cannot leak the hash.
func (User) MarshalJSON ¶
MarshalJSON keeps the hash out of any response, log or dump. Without it, a single observability.Dump(ctx, "user", u) would publish the hash on the debug page.
type UserEvent ¶ added in v0.25.0
type UserEvent struct {
UserID string `json:"user_id"`
Tenant string `json:"tenant_id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
}
UserEvent is the payload of every event this module publishes.
One shape for the three, because they answer the same question -- which account, in which tenant, at which address -- and a consumer that already switches on the name should not also have to switch on the shape.
The hash is not in it and must never be added: an outbox row is read by the relay, by whoever is looking at the dead letter queue, and by whatever the application publishes to. That is three places a credential would travel to for no reason.
type UserPolicy ¶
type UserPolicy struct{}
UserPolicy is the only authority over who does what with a User.
It denies by default: the switch has no default branch that allows. `aru doctor` fails when a repository exists without a matching policy.
type UserRepo ¶
type UserRepo struct {
// contains filtered or unexported fields
}
UserRepo is the only door to the users table.
Every method starts with g.Check: the Grant is required by the signature, and the check proves the grant was issued for this exact action.
The SQL is written with "?" placeholders and with types every supported database shares, so the same statements run on SQLite and on PostgreSQL. The dialect rebinds the placeholders; nothing else needs translating.
func NewUserRepo ¶
NewUserRepo returns a repository over an instrumented handle.
func (*UserRepo) Confirm ¶ added in v0.25.0
func (r *UserRepo) Confirm(ctx context.Context, g security.Grant, id string, at time.Time) (bool, error)
Confirm stamps the address as verified, and reports whether this call is what stamped it.
It is a single conditional statement rather than a read followed by Update, and the difference is a duplicate welcome mail. A link sitting in an inbox is opened by the person and prefetched by whatever scans their mail, within the same second: two requests read an unverified row, two full-row updates succeed, and the service publishes auth.email.verified twice for one confirmation. `verified_at IS NULL` in the statement makes the database the referee, so exactly one caller is told it confirmed the address.
It writes one column, which is the other half of the same problem. Update writes name, email, password and roles back from a snapshot taken before the transaction opened, so a confirmation that overlapped a password change put the old hash back -- and, once an address can be changed, put the old address back and marked it verified, undoing the binding the link exists to enforce.
It does not read the row: the caller already has it, and asking twice would widen the window this closes.
func (*UserRepo) Create ¶
Create inserts the user and returns it as stored.
The id and the timestamp are generated here rather than by the database: a DEFAULT that produces a uuid is spelled differently in every engine, and generating them in Go is what keeps one schema working everywhere.
func (*UserRepo) FindByEmail ¶
FindByEmail returns one user by email, scoped to the grant's tenant.
The address is normalized the same way on write and on read, which is what makes a plain UNIQUE index behave case-insensitively without a database specific collation.
func (*UserRepo) List ¶
List returns a page of users in the grant's tenant.
Pagination is keyset based on (created_at, id): OFFSET grows more expensive with every page and skips rows when data changes underneath.
func (*UserRepo) NamesByID ¶ added in v0.19.0
func (r *UserRepo) NamesByID(ctx context.Context, g security.Grant, ids []string) (map[string]string, error)
NamesByID returns the display name of each id, in one query.
It exists because of what the alternative looks like on a comment thread: twenty comments, twenty lookups, and a page that is fine on a laptop and slow on the first article somebody actually discusses.
Ids that do not exist are absent from the map rather than mapped to "". A caller can then tell "no such user" from "a user with no name", and the two deserve different words on a screen.
The placeholder list is built from the count and never from the values, so this is a parameterised query however many ids arrive.
func (*UserRepo) SetPassword ¶ added in v0.25.0
SetPassword writes the password column of one user, and nothing else.
It is Confirm's shape applied to the other column that is written on its own, and it exists for the same reason: Update writes name, email, roles and verified_at from whatever snapshot the caller happened to read, so any of them changing between that read and the write is silently put back. That is a lost update with three ways to hurt, and none of them fails or logs anything:
- an administrator grants a role while somebody is resetting their password, and the reset takes it away again;
- the person clicks the verification link in the same minute, and the reset un-verifies the address;
- once an address can be changed, the reset restores the old one -- together with the verified stamp that belonged to it, which undoes the binding the verification link exists to enforce.
The read the caller did for the id is still outside the transaction, and does not need to be inside it: this statement names the row rather than describing its contents, so nothing it writes depends on what was read.
A missing row is ErrUserNotFound rather than silence. A password reset that changed nothing and said it worked is somebody typing a new password and signing in with the old one.