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 SubjectOf(u User) security.Subject
- type CreateUserRequest
- type LoginRequest
- type Module
- type RegisterRequest
- type Service
- func (s *Service) Authenticate(ctx context.Context, tenant, email, plain string) (User, 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) 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, userID 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) SetPassword(ctx context.Context, tenant, email, plain string) (User, error)
- type TenantResolver
- type User
- type UserPolicy
- type UserRepo
- 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) 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.
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 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.
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.
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.
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.
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 ¶
Authenticate verifies the credentials and returns the user.
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.
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) 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 id from the signed token and reads the row itself: nothing here is trusted to arrive from the request.
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) SetPassword ¶ added in v0.19.1
SetPassword replaces the password of an existing account.
It exists for one caller: the operator command that resets a password from the terminal, when whoever owns the address cannot reach the reset link -- the mail is not configured yet, the domain is not verified yet, the account is the first administrator and there is nobody else to ask.
It is NOT what a password-reset screen calls. That flow proves control of the address first, and its rules -- a minimum length, a history, a notification -- are the application's. This one is the door with no lock on it, and it is deliberately only reachable from a command a person types.
The hash is computed here, so no caller can be handed the chance to store a plain password by writing the field directly.
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 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 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) 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.