auth

package
v0.11.1 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 17 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 the whole bug class Laravel's $fillable exists to contain does not exist here.

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

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
	Email     string
	Password  string
	Roles     []string
	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.

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. Phase 2 generates these bodies from queries.sql with sqlc; the signature and the check do not change when it does.

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