auth

package
v0.21.1 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 18 Imported by: 0

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

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

View Source
var ErrEmailTaken = errors.New("auth: email already registered in this tenant")

ErrEmailTaken is returned when the tenant already has that address.

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

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

func NewID() (string, error)

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

func NormalizeEmail(email string) string

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 SubjectOf

func SubjectOf(u User) security.Subject

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.

Types

type CreateUserRequest

type CreateUserRequest struct {
	Email    string
	Password string
	Roles    []string
}

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

type LoginRequest struct {
	Email    string
	Password string
}

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

func (m *Module) Health(ctx context.Context) error

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

func (m *Module) Migrations() []kernel.Migration

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) Name

func (m *Module) Name() string

Name is the module identifier.

func (*Module) Routes

func (m *Module) Routes(r *httpx.Router)

Routes registers the module's routes.

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

func NewService(repo *UserRepo, session *security.SessionStore, csrf *security.CSRF) *Service

NewService wires the module.

func (*Service) Authenticate

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

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

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

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

func (s *Service) FindForVerification(ctx context.Context, tenant, userID string) (User, error)

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

func (s *Service) Lookup(ctx context.Context, tenant, email string) (User, error)

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

func (s *Service) MarkVerified(ctx context.Context, tenant, userID string) (User, bool, error)

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

func (s *Service) Register(ctx context.Context, tenant string, in RegisterRequest) (User, error)

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

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

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

type TenantResolver func(r *http.Request) string

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

func (u User) LogValue() slog.Value

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

func (u User) MarshalJSON() ([]byte, error)

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.

func (User) Verified added in v0.19.0

func (u User) Verified() bool

Verified reports whether the address was confirmed.

A method rather than the comparison at each call site: `!u.VerifiedAt.IsZero()` written in eight places is eight chances to write it without the `!`.

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.

func (UserPolicy) Can

Can decides whether the subject may perform the action on the user.

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

func NewUserRepo(db *data.DB) *UserRepo

NewUserRepo returns a repository over an instrumented handle.

func (*UserRepo) Create

func (r *UserRepo) Create(ctx context.Context, g security.Grant, u User) (User, error)

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) Delete

func (r *UserRepo) Delete(ctx context.Context, g security.Grant, id string) error

Delete removes one user within the grant's tenant.

func (*UserRepo) Find

func (r *UserRepo) Find(ctx context.Context, g security.Grant, id string) (User, error)

Find returns one user by id, scoped to the grant's tenant.

func (*UserRepo) FindByEmail

func (r *UserRepo) FindByEmail(ctx context.Context, g security.Grant, email string) (User, error)

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

func (r *UserRepo) List(ctx context.Context, g security.Grant, q data.Query) ([]User, error)

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) Update

func (r *UserRepo) Update(ctx context.Context, g security.Grant, u User) (User, error)

Update writes the mutable fields. The tenant is not one of them: moving a user between tenants is not an update, it is a migration.

Jump to

Keyboard shortcuts

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