Skip to content
Trilha
Chapters

Reference

auth

Provider, Options, Auth, User and Store — the API of the auth package, with the defaults and what each field changes.

import "github.com/emersonjoe/trilha/auth" — login with the standard library, through a provider (OpenID Connect) or against the app's own users table. The package registers no route: it exposes handlers that your app/ publishes.

Providers#

func OIDC(issuer, clientID, clientSecret, redirectURL string) *Provider
func EntraID(tenant, clientID, clientSecret, redirectURL string) *Provider
func Keycloak(baseURL, realm, clientID, clientSecret, redirectURL string) *Provider
func Cognito(region, userPoolID, clientID, clientSecret, redirectURL string) *Provider
func Clerk(frontendAPI, clientID, clientSecret, redirectURL string) *Provider
ConstructorResulting issuerRoles read from
OIDCwhatever you passroles, groups
EntraIDhttps://login.microsoftonline.com/<tenant>/v2.0roles, groups, wids
Keycloak<baseURL>/realms/<realm>realm_access.roles, resource_access[clientID].roles
Cognitohttps://cognito-idp.<region>.amazonaws.com/<userPoolID>cognito:groups
Clerkthe Frontend API URL, normalized (https://<slug>.clerk.accounts.dev)roles, groups — Clerk's id_token carries the organization (org_id), not the role in it; a configured claim goes in Options.RoleClaims

Those claims are read from the id_token and from the access token, when the access token happens to be a JWT from the same issuer — the id_token wins wherever both carry the same claim. This is not a nicety: Keycloak puts realm_access and resource_access in the access token only, so reading the id_token alone gives a login that works and a Roles that is empty, and every RequireRole answers 403 for no visible reason. An opaque access token — what most providers issue — changes nothing; one that does not verify grants nothing.

Provider.LogoutDomain exists for Cognito: set it to the managed login domain (<prefix>.auth.<region>.amazoncognito.com, or your own) and Logout redirects to /logout?client_id=…&logout_uri=… there; the return URL must be in the app client's Allowed sign-out URLs. Left empty, Logout clears the local session, says so in the log and does not pretend it federated. Other providers ignore the field. Clerk publishes no end_session_endpoint either, and has no equivalent address: there Logout is always local, and the log says the Clerk session was left open.

Provider.HTTPClient swaps the HTTP client (default: 10 s timeout). Discovery happens on first use and is valid for one hour; an issuer that differs between the configuration and the document is an error, not a warning.

Options#

FieldDefaultWhat it does
Scopes []stringopenid profile emailscopes requested from the provider
Absolute time.Duration8 hmaximum session lifetime, counted from the login
Idle time.Duration30 minends an idle session; IdleOff: true disables it
CookieName stringtrilha_sessionsession cookie name
Audience string—names the public this Auth serves, for two of them in one process
LoginPath string/entrarwhere Require sends an anonymous browser
AfterLogin string/destination after the callback, when there is no next
AfterLogout string/destination after the logout
RoleClaims []string—additional claims of the auth.Claims (the decoded ID token) to read roles from, besides the standard ones
Store Storenilpersists the session; nil = signed cookie, stateless
OnLogin func(c, *User) error—runs inside Login and Callback, session not yet written; its error stops the login
OnLoginToken func(c, *User, *IDToken) error—runs inside Callback after OnLogin, with the verified ID token; its error stops the login
RequireVerifiedEmail boolfalserefuses a login whose e-mail the provider does not vouch for

The e-mail the provider did not vouch for#

A provider that answers with email is not saying the address belongs to this person. It says so with a second claim: email_verified (OIDC Core §5.1). Google, and any provider where somebody can type an address into a profile, will send email_verified: false — the person wrote it, nobody checked it.

That matters to whoever authorises by e-mail, which is most internal tools: an allow-list, or a company domain. u.Email alone authorises what somebody typed.

User.EmailVerified carries the claim, so OnLogin can decide. RequireVerifiedEmail: true decides for you, and the refusal happens before OnLogin — your rule never sees an address it should not have:

auth.New(p, auth.Options{
	RequireVerifiedEmail: true,
	OnLogin: func(c *trilha.Ctx, u *auth.User) error {
		if !allowed[u.Email] {
			return errors.New("e-mail not on the list")
		}
		return nil
	},
})

It is off by default because turning it on changes who gets in. Two details it is worth knowing:

  • the string form counts. Some providers send "true" rather than the JSON boolean, and both are read as verified;
  • an e-mail that came from preferred_username — the fallback for a provider that sends no email at all — is never verified. A username is not an address anybody vouched for.

auth.Sessions has no provider and no claim: there, EmailVerified is yours to set, and only your application knows whether it confirmed the address.

The ID token, for the API that issues the session#

A Trilha app is often the front of an API that already exists — which is what Config.Upstreams is for. There the app does OIDC and ends with a verified identity, but the backend is the one that issues the application's session, and the one that holds the account rules: create, refuse, waiting list. The correct way to ask it for that session is to hand over the ID token and let the backend verify it against the provider's JWKS — the provider stays the anchor of trust and no new shared secret is born.

OnLoginToken is where that happens. It runs inside Callback, after OnLogin and before the session is written, and receives the token the flow has just verified:

auth.New(p, auth.Options{
	OnLoginToken: func(c *trilha.Ctx, u *auth.User, t *auth.IDToken) error {
		sess, err := api.OpenSession(c.Context(), t.Raw) // the backend verifies it
		if err != nil {
			return err                                   // refused: the login stops
		}
		u.Extra = map[string]string{"api_session": sess} // this travels; the token does not
		return nil
	},
})
type IDToken struct {
	Raw    string  // the compact JWS, as the provider signed it
	Claims *Claims // verified: signature, issuer, audience, nonce, expiry
}

Claims.All is the whole payload, so it also answers "I need a claim the kit does not map" — hd, groups, one of your own — without touching Provider or RoleClaims.

Two things it does on purpose:

  • the token is not stored. It exists for the callback request and dies with it. A token in User.Extra travels wherever the session travels — the signed cookie, or the Store — with the session's lifetime rather than its own, which is why there is no KeepIDToken option. Forward it, trade it for what your backend issues, and keep that;
  • it does not replace OnLogin, it runs after it. OnLogin is the rule that runs on both doors (password and OIDC) and decides whether this person gets in; only then does the app spend a round trip with the token. Login — the door with no provider — never calls OnLoginToken: there is no ID token there.

Auth#

func New(p *Provider, o Options) *Auth      // no network
func (a *Auth) Start(c *trilha.Ctx) error   // → provider (PKCE, state, nonce)
func (a *Auth) Callback(c *trilha.Ctx) error // validates the callback and creates the session
func (a *Auth) Logout(c *trilha.Ctx) error   // deletes the session; RP-Initiated Logout when available
func (a *Auth) Require() trilha.MiddlewareFunc
func (a *Auth) RequireRole(roles ...string) trilha.MiddlewareFunc
func (a *Auth) Optional() trilha.MiddlewareFunc
func (a *Auth) User(c *trilha.Ctx) *User     // nil when anonymous
func (a *Auth) Session(c *trilha.Ctx) (*User, error)
func (a *Auth) LoginPath() string            // Options.LoginPath, or its default
func (a *Auth) Provider() *Provider          // the configured provider, for a screen that names it
func (a *Auth) Sessions(c *trilha.Ctx) ([]User, error) // every session of whoever is logged in, this one first
func (a *Auth) LogoutOthers(c *trilha.Ctx) error       // ends the others, keeps this one
func (a *Auth) Update(c *trilha.Ctx, fn func(*User)) error // changes the session that is open

func Sessions(o Options) *Auth               // the same type, without a provider
func (a *Auth) Login(c *trilha.Ctx, u *User) error // session for a user the app authenticated
func (a *Auth) RequireFunc(pred func(*User, *trilha.Ctx) bool) trilha.MiddlewareFunc

Require answers 302 to the login when the request is a navigation (Accept with text/html, outside /api/) and 401 otherwise. RequireRole answers 403 to someone authenticated without the role. One of the listed roles is enough; the comparison ignores case.

Changing a session that is open#

Login writes a session, and until Update existed that was the only door. It is the wrong one for a change in the middle of a request: it rotates the SessionID and redirects. And what Session and User answer is a copy, so mutating it persists nothing.

Update is the door that was missing:

// the administrator has just changed the organisation's colour
if err := sso.Update(c, func(u *auth.User) { u.Extra["brand"] = colour }); err != nil {
	return err
}
return c.Redirect("/brand")

It reads the stored session, applies the function, writes it back, and leaves the new value on the request — so sso.User(c), auth.Tenant(c) and the audit actor below that line already read it. What it does not do:

rotate the SessionIDthe identifier is the session's name in the store; a new one here would orphan the stored row and leave the cookie pointing at nothing. Rotating it is Login's job
redirectwhoever calls it is in the middle of handling a POST and decides what comes next
send a Set-Cookiewith a Store, the cookie carries only the identifier and the identifier has not changed. Without a Store the session is the cookie, so it is rewritten — same identifier, same expiry
auditonly the application knows what changed; c.Audit is its call

It takes a function and not a *User so that the read-modify-write does not straddle the request: the function runs on the session as the store has it right now. Extra arrives ready to be written to, so a session created without it needs no make(map[string]string) inside. No session is ErrNoSession — or the store's own error — and never a redirect.

Without it, an application that caches in Extra something that can change during the session — the organisation's brand, the plan, the permissions — either stops caching and pays a round trip per page, or shows the old value until the next login, up to Absolute later. SwitchTenant is this same call with an audit line.

Two publics in one process#

The internal area and the portal are routes of the same tree with a middleware each, which is the shape this framework encourages. Give each Auth its Audience:

var (
	Portal  = auth.Sessions(auth.Options{Audience: "portal", CookieName: "portal_session", Store: lojaPortal})
	Interno = auth.Sessions(auth.Options{Audience: "interno", CookieName: "interno_session", Store: lojaInterna})
)

A cookie of its own and a Store of its own are not enough to separate them. Without an audience every Auth in the binary parks the user of the request in the same slot of the Ctx, so one call to Interno.User(c) from the portal side — a helper, a layout, a handler that took the wrong instance — answers with the person the other side has just let in, and the page is drawn with the wrong identity. Nothing fails and nothing is logged.

Naming the public separates them in the two places that matter:

With Audience
the slot of the Ctxone per instance: Interno.User(c) does not read what Portal wrote
the sessioncarries the audience (User.Audience), and Session refuses one from another public — the same cookie name, or a shared Store, stops being a door

An application with a single Auth — the great majority — does not see any of this: with no Audience the slot and the session are exactly what they are today. Turning the option on in an application that is already running ends the open sessions once, because they were written without an audience.

auth.Tenant(c) keeps answering for the session the request went through, and Keys.User(c) is unchanged: an API key is not a session and has no public, so an Auth with an Audience does not answer with the caller of a key.

User#

type User struct {
	Subject   string    // sub: the stable identifier
	Email     string    // email, or preferred_username when there is none
	Name      string
	Roles     []string
	IssuedAt  time.Time // moment of the login
	ExpiresAt time.Time
	Seen      time.Time // last activity (idle window)
	SessionID string    // changes on every login
	Locale    string    // the language this person picked ("ht", "pt-BR")
	Extra map[string]string // what OIDC has no claim for (the API token, the tenant)
}

func (u *User) HasRole(role string) bool
func (a *Auth) LocaleOf(c *trilha.Ctx) string

The language this person reads in#

User.Locale is the language of whoever is logged in, in the spelling of Config.Locales. The screen that offers the choice writes it with Update, and Auth.LocaleOf is what hands it to the framework:

// app/setup.go
func Setup(a *trilha.App) error {
	a.Config().Locales = []string{"pt-BR", "ht", "fr"}
	a.Config().LocaleOf = sessao.Flow.LocaleOf // the session before any header
	return nil
}

// the screen where the person chooses
func POST(c *trilha.Ctx) error {
	if err := sessao.Flow.Update(c, func(u *auth.User) { u.Locale = c.Form("lang") }); err != nil {
		return err
	}
	return c.Redirect("/")
}

LocaleOf returns "" when there is no session or the person never picked one, and then c.Locale() carries on with ?lang, the cookie and Accept-Language. It is a field of its own and not one more entry in Extra for the same reason Tenant is: what the framework reads on every request has to be in the same place in every application.

The permission matrix#

A role list answers "is this person an admin". What an application actually asks is "may this person edit documents", and the answer is a matrix: role × module × level.

var Policy = auth.Policy{
	Modules: []string{"docs", "processes", "hr"},
	Levels:  auth.Levels{"view", "edit", "manage"},   // ordered: manage ⊇ edit ⊇ view
	Roles: map[string]auth.Grants{
		"admin":   auth.All("manage"),
		"analyst": {"docs": "edit", "processes": "view"},
		"reader":  auth.All("view"),
	},
}

The order of Levels is the whole meaning: manage covers edit covers view, so a cell holds one value instead of three booleans. A module a role does not name is a module it cannot reach — the absence is a denial, never an inheritance — and Default is what a signed-in user with no known role may do, which is nothing until you say otherwise. A role that was deleted should lose access, not inherit somebody else's.

SymbolWhat it does
Policy.Can(u, module, level) boolmay this user do this
Policy.Level(u, module) stringwhat they have, or ""
Policy.LevelOf(role, module) stringthe level of a role, for a cell of the grid, where there is no user yet
Policy.RolesSorted(), Policy.ModuleNames(), Policy.LevelNames()the names in a stable order — roles sorted, modules as declared, levels weakest first — which is what makes a grid render the same way twice
(*Auth) RequirePolicy(p, module, level)the guard
auth.All(level) Grantsthe same level on every module
auth.BindPolicy(c, p)read back what the grid posted
auth.PolicyStore, auth.PolicyFroma matrix people edit
ui.PolicyGrid(p, opts)the screen that edits it

The guard#

// app/docs/middleware.go
var see = acesso.Auth.RequirePolicy(acesso.Policy, "docs", "view")

func Middleware(c *trilha.Ctx, next trilha.Next) error { return see(c, next) }

Two lines and not one: middleware.go has to export a function with that signature, and a var of the right type is not one. MiddlewarePOST guards a single method, so a folder can be readable by one level and writable by another.

Anonymous goes to the login. A signed-in user who is not allowed gets 403 — they are known, just not permitted, and sending them to the login would loop — and the message says what was missing: needs edit on docs. That sentence is what the person repeats to whoever administers the application; a bare "forbidden" turns a two-minute fix into a support thread.

Hiding a button is not a rule#

if acesso.Policy.Can(acesso.Auth.User(c), "docs", "edit") { … }

Correct and cosmetic. The rule that holds is the middleware, because a hidden button is still an address somebody can type.

trilha audit warns when the policy declares a module and no route requires it: the matrix says the area is protected, and if nothing asks, the protection is a sentence in a file.

A matrix people edit#

Policy is data in the code, which is where it belongs when only deploys change it. An application whose administrators invent roles keeps the roles in a table:

type PolicyStore interface {
	Load(ctx context.Context) (map[string]Grants, error)
	Save(ctx context.Context, roles map[string]Grants) error
}

policy, err := auth.PolicyFrom(ctx, store, defaults)   // empty store keeps the defaults

PolicyFrom is a snapshot on purpose: a Policy is a value, and a value that changed underneath a request would let one request answer twice — allowed at the middleware, denied at the button. Call it again after saving.

The screen comes ready:

ui.PolicyGrid(policy, ui.PolicyGridOpts{
	Action: "/admin/permissions",
	CSRF:   trilha.CSRFInput(c),
	Labels: map[string]string{"docs": "Documents"},
})

One row per role, one column per module, a select of levels per cell, fields named grant.<role>.<module> — which is what auth.BindPolicy reads on the other side. No JavaScript: it is a form, it posts, the handler saves and redirects. A cell naming a module or a level the policy does not declare is dropped in silence, because answering 400 would only tell whoever forged it which name to try next.

Guard that screen with the module that administers the application. It is the most valuable screen there is.

trilha add permissions writes the policy, this screen, the guard and the test.

app/…/page.go
ui.PolicyGrid(access.Policy, ui.PolicyGridOpts{
	Action: "/admin/permissions",
	CSRF:   trilha.CSRFInput(c),
	Labels: map[string]string{"docs": "Documents"},
})
result
RoleDocumentsprocesseshr
admin
analyst
reader

What this does not express#

A rule about one record — the owner of a document, a row of a tenant — stays RequireFunc, written by hand. A policy that reached into the data would need the data, and then it would be a query and not a declaration.

Session without OIDC#

An app whose users are a table of its own — e-mail, password hash, role — builds the same *Auth with Sessions, and says who the person is itself:

sessions := auth.Sessions(auth.Options{Store: store, LoginPath: "/login"})

// app/login/page.go
func POST(c *trilha.Ctx) error {
	u, err := users.Verify(c.Form("email"), c.Form("password"))
	if err != nil {
		return c.Render(422, form(c, "wrong e-mail or password"))
	}
	return sessions.Login(c, &auth.User{Subject: u.ID, Email: u.Email,
		Roles: []string{u.Role}, Extra: map[string]string{"api_token": u.JWT}})
}

Login redirects to the next it was asked for, or to AfterLogin. Start and Callback answer a clear error on an Auth with no provider; Logout clears the session and lands, since there is nothing above this app to end.

User.Extra is what this session carries and a claim cannot express — the token an upstream injects, the tenant. It travels where the rest of the session travels: keep it small, and never put a password in it.

Passwords#

func HashPBKDF2(password string) (string, error)  // pbkdf2_sha256$600000$salt$hash
func CheckPBKDF2(encoded, password string) bool   // constant-time; false on a hash it cannot read
func PBKDF2(password, salt []byte, iter, keyLen int) []byte

CheckPBKDF2 reads two spellings, told apart by the prefix, because the table it has to keep working is not always Django's:

Written byShape
Django, passlibpbkdf2_sha256$<iterations>$<salt>$<digest base64>
hashlib, by handpbkdf2$<iterations>$<salt hex>$<digest hex>

The second one exists because hashlib.pbkdf2_hmac returns bytes and no format at all, so an app that uses neither Django nor passlib picks one, and hex is what it picks. There the salt is hex that was decoded to bytes before the derivation, which is why reading it as text gives the wrong answer for the right password. SHA-256 only; pbkdf2_sha512 is false.

HashPBKDF2 keeps writing the first: one spelling to write, two to read. So an existing users table stays valid without a password migration — the iteration count travels inside each hash, so raising DefaultPBKDF2Iterations locks nobody out. bcrypt and argon2 are better at this and neither is in the standard library, which is the whole reason this one is here.

Authorization the roles do not express#

A matrix of module and level, a tenant, the owner of a record: all the same shape, and none of them a list of role names.

// app/painel/middleware.go
func Middleware(c *trilha.Ctx, next trilha.Next) error {
	return sessions.RequireFunc(func(u *auth.User, c *trilha.Ctx) bool {
		return u.Extra["tenant"] == c.Param("tenant")
	})(c, next)
}

Anonymous never reaches the predicate: it is sent to the login first, as Require does. Hiding the menu item is cosmetic — the rule is the middleware.

Store#

type Store interface {
	Save(id string, u *User, ttl time.Duration) error
	Load(id string) (*User, bool)
	Delete(id string) error
}

func NewMemoryStore() *MemoryStore

With a Store the cookie carries only the identifier and the logout takes effect immediately for everyone. MemoryStore is for a single process: replicas do not share it, and a restart drops every session. For several replicas, implement the interface over your database or cache.

A store that is somewhere else#

type StoreContext interface {
	SaveContext(ctx context.Context, id string, u *User, ttl time.Duration) error
	LoadContext(ctx context.Context, id string) (*User, error) // ErrNoSession when there is none
	DeleteContext(ctx context.Context, id string) error
}

Store has no context and its Load has no error, which is fine for a map in this process and wrong for a table in Postgres. Without a context the store cannot honour the request's deadline, cancel the query when the browser goes away, or carry the trace — it is reduced to context.Background() and a made-up timeout, on every authenticated request. Without an error, a database that is down is indistinguishable from a session that does not exist, so everybody is sent to the login and no log says why.

Implement StoreContext as well and the flow uses it — same optional-interface shape as SessionLister above, so nothing written against Store breaks:

func (s *PGStore) LoadContext(ctx context.Context, id string) (*auth.User, error) {
	var blob []byte
	err := s.db.QueryRowContext(ctx, "select u from sessions where id = $1", id).Scan(&blob)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, auth.ErrNoSession // there is no such session
	}
	if err != nil {
		return nil, err // the database failed: 503, not the login page
	}
	var u auth.User
	return &u, json.Unmarshal(blob, &u)
}

The two errors have two destinations:

What happenedWhat the person gets
not logged inthe login page, as always
the store failed503, and one Error line in the log with the cause

Optional() still lets the request through as anonymous when the store is down — that is what Optional is for — but it logs the failure, so an outage does not read as everybody having logged out at once.

Sessions by owner#

type SessionLister interface {
	Sessions(subject string) []*User // the live sessions of one subject
}

var ErrNoSessionList error

A store that also implements SessionLister gives the account screen two things: Sessions(c) — every session of whoever is logged in, the current one first, each with IssuedAt, Seen and SessionID — and LogoutOthers(c), which deletes every session of theirs except this one and writes auth.logout_others to the audit trail with the count. MemoryStore implements it. A store that does not gets ErrNoSessionList from both, which is what the screen shows instead of an empty list: an empty list would say "nowhere else", and that would not be known.

A store that lives somewhere else implements the contextual one instead, the same optional-interface shape as StoreContext:

type SessionListerContext interface {
	SessionsContext(ctx context.Context, subject string) ([]*User, error)
}

The query then honours the request's deadline and is cancelled when the browser hangs up, and — the part that decides — a database that is down becomes an error in both operations instead of an empty slice. Without it the two lie in two different ways: the account screen draws "no other sessions" over the outage, and LogoutOthers answers success without having ended anything, to somebody who has just changed their password precisely to end them. Both callers already handle an error (ErrNoSessionList), so there is no new path in the application: there is a value that stops being false.

A password change is where LogoutOthers belongs: a password changes because somebody may have the old one, and that somebody may be signed in right now.

Cookies#

CookieLifetimeContent
trilha_oidc_state10 minstate of the request in progress
trilha_oidc_nonce10 minnonce of the request in progress
trilha_oidc_verifier10 minPKCE verifier
trilha_oidc_next10 mindestination after the login (relative path only)
trilha_sessionAbsolutethe session (or its id, with a Store)

All are signed (they require TRILHA_SECRET), HttpOnly, SameSite=Lax and Secure under HTTPS. The four flow cookies are deleted on the callback, whether it succeeds or not.

Accepted algorithms#

RS256, RS384, RS512, ES256, ES384. The list is fixed: the token's alg chooses nothing. RSA keys with a modulus smaller than 2048 bits are ignored in the JWKS, kid is required, and clock tolerance is 60 seconds.

Audit#

trilha audit checks, when the project imports trilha/auth: client secret written in the code (critical) and redirect_uri over http:// outside localhost (critical).

API keys#

auth.Sessions is a cookie and OIDC is somebody else's bearer. auth.APIKeys is the third one: the key this application issues to its own callers. It is a pile of rules a beginner does not know — store only the hash, show the secret once, keep a handle so a key can be found without opening the hash, a scope per route, a limit per key rather than per address, revocation that takes effect now, a record of use — and the first version always stores the key in the clear.

var Keys = auth.APIKeys(auth.KeyOptions{
	Store:     chaves.NewStore(db),        // nil keeps them in memory
	Prefix:    "ak",                       // keys read "ak_<handle>_<secret>"
	Scopes:    []string{"docs:read", "docs:write"},
	RateLimit: trilha.RateLimit{RPS: 10, Burst: 30},
})

// app/api/v1/middleware.go
var exige = Keys.Require("docs:read")
func Middleware(c *trilha.Ctx, next trilha.Next) error { return exige(c, next) }
SymbolRole
APIKeys(KeyOptions)the set of keys of an application
Issue(c, name, scopes, ttl)creates one and answers the secret — the only time it exists
Require(scopes...)the middleware: bearer, hash, revocation, expiry, scope, limit
Revoke(c, id) / All()ends one now (auth.ErrUnknownKey for an id nobody issued); lists them for the screen
User(c)the caller, as an auth.User with the scopes as roles
KeyStore / MemoryKeyStore()five methods over whatever the app runs; memory for tests
ui.SecretOnce(c, secret)the card that shows it once, with the sentence that has to be there
ui.APIKeysTable(c, []ui.APIKeyRow, ui.APIKeysOpts{...})the list, with the handle and never the key; DateFormat draws the created and last-used cells in the screen's own format, LogFormat the last-used one when it wants the hour

trilha add api-keys writes the package, this screen and the test.

app/…/page.go
k, secret, err := Keys.Issue(c, f.Name, f.Scopes, 0)
if err != nil {
	return err
}
return c.Render(200, ui.SecretOnce(c, secret))
// ...
return ui.APIKeysTable(c, rows, ui.APIKeysOpts{Revoke: "/keys/revoke"})
result
NameKeyMayCreatedLast used
Documents integrationdocs-integrationdocs:read docs:write
Nightly exportrevokednightly-exportdocs:read never

Only the hash is stored, peppered with trilha.Pepper — HMAC-SHA256 under a key derived from the app's secret. A stolen table of digests is not a list anybody can attack offline, and without a secret Issue refuses rather than writing an unkeyed hash that would look like it worked.

The handle is the half that identifies without opening the hash. It is what a screen shows and how a request finds its key; the secret is compared in constant time, and revocation and expiry are checked after that comparison — answering faster for a revoked key than for a wrong one says which of the two happened.

A key is an actor. Require puts an auth.User in the request with the scopes as roles and via: "api_key", so c.Audit, the log and the policy all see a caller instead of a hole.

The limit is per key, not per address: one caller behind one key is one budget, whatever their IP is doing. Use is recorded once a minute — writing on every request turns a read-only endpoint into a write per call, and "is this key still in use?" does not need the second.

A scope the application never declared is a panic at wiring time. The alternative is a route that guards nothing because of a typo, and nobody finding out until they read it in a breach report.

Who is using this key, where, and when did they stop#

That is the first question after a key is handed to a partner, and it is not a security question. The key already knows who called and when it was last seen; what KeyOptions.Usage adds is the shape of the use.

var Chaves = auth.APIKeys(auth.KeyOptions{
	Usage:     auth.UsageMemory(),          // or a table behind the same three methods
	UsageKeep: 400 * 24 * time.Hour,        // retention, swept on the same loop
})

func main() { … ; if err := Chaves.Setup(a); err != nil { … } ; … }
rel, _ := Chaves.Usage(c.Context(), auth.UsageQuery{Key: id, Since: time.Now().AddDate(0, 0, -30)})
rel.Total, rel.Errors, rel.Last
rel.ByRoute   // []UsageRoute{Method, Route, Count, Errors, Last}, busiest first
rel.ByDay     // []UsageDay, oldest first — what a chart draws
rel.ByKey     // []UsageKeyTotal, when the query was not about one key

Usage is an auth.UsageStore, three methods over whatever the application already runs: Add(ctx, []auth.UsageRow) takes the batch a flush produced — one row per key, route and day, with the last status — Query answers the auth.UsageReport above, and Prune drops what is older than UsageKeep. auth.UsageMemory() is that interface over a map, which is the right store for one process and for a test.

Counting must not cost the request. Require increments a bucket in memory — one map write under a mutex, nothing that can block on a network — and the buckets go to the store in batches. Setup flushes on a timer and on shutdown; without it, Flush is yours to call. A store that fails on a flush gets its counts back in the buffer, because losing them because a database was restarting is the exact failure this design is avoiding.

The route is the pattern. /documents/{id}, never /documents/8f2c…: a counter per concrete path is a counter with one row per request. A request the fallback answered has no pattern, and it is not counted — the address there is user input, and whoever asks for enough addresses that do not exist would be writing rows.

Without Usage, nothing changes, byte for byte. Keys.Usage on such an application answers ErrNoUsage rather than an empty report: no data and no counting are different answers.

A revoked key keeps its history. "Who was using this?" arrives after the revocation, not before.

ociosas, _ := Chaves.Idle(c.Context(), time.Now().AddDate(0, 0, -90))

Idle is the keys with no recorded call since that moment. The issue that asked for this wanted it in trilha audit; it is here instead, because that command reads code on somebody's laptop and the counters live in production — a check that cannot see them is a check that always says everything is fine.

The screens are ui.APIUsage and the calls column of ui.APIKeysTable, and trilha add api-keys writes both. The panel takes the kit's own shape — an ui.APIUsageData{Total, Errors, Last, Days []ui.APIUsageDay, Routes []ui.APIUsageRoute} with an ui.APIUsageOpts{Days, Limit, Empty} — and not the report of this package: a component of the kit that imported auth would drag authentication into every application that draws a button. Copying five fields across is the price of that, and it is the right price.

app/…/page.go
rel, _ := Keys.Usage(c.Context(), auth.UsageQuery{Key: id, Since: thirty})
ui.APIUsage(c, ui.APIUsageData{
	Total: rel.Total, Errors: rel.Errors, Last: rel.Last,
	Days: rel.ByDay, Routes: rel.ByRoute,
}, ui.APIUsageOpts{Days: 30})
result
Calls1842last 30 days
Errors60 %
Last
Calls per day
Calls per day
1220
2250
3280
4310
5340
6370
7400
MethodRouteCallsErrorsLast
GET/documents/{id}12102
POST/documents6324

Multi-tenant by column#

One column is the most common shape of multi-tenant, and forgetting that column in one query is the most common bug of multi-tenant: the report that shows another organisation's rows, found by a customer.

The framework carries the value and points at the query that forgot it. The query is yours. There is no ORM here, and a WHERE this package generated would be a WHERE nobody could read in a review — which is the opposite of what a tenant filter needs.

// at login, or in OnLogin
u.Tenant = row.TenantID

// in a repository
rows, err := db.QueryContext(c, `SELECT … FROM documents WHERE tenant_id = $1`, auth.Tenant(c))
SymbolRole
auth.User.Tenanta field of its own, next to Roles; travels wherever the session travels
auth.Tenant(c)the organisation of the current session, or ""
sso.RequireTenant()refuses a session with no organisation chosen
Options.ChooseTenantPathwhere a browser goes to pick one; empty answers 403
sso.SwitchTenant(c, id)moves the session and writes both sides down

It is a field and not one more entry in Extra because everything the framework does with it has to find it in the same place in every application: it goes into the audit trail as actor.tenant and onto the access record as tenant — which is the first filter of any support question, and the field that says whether "they saw the wrong rows" is about a query or about somebody having changed organisation.

RequireTenant treats "logged in, no organisation" as what it is: an administrator who has not picked one, or a first login. A browser is redirected; anything else gets 403, because a redirect to a screen is not an answer an API can use.

Checking that somebody may enter an organisation is the application's job. SwitchTenant moves the session and audits it; it does not know what a membership is, and pretending to would be a check that looks like a guarantee and is not one.

The query that forgot#

trilha audit counts, per table, how many queries filter it by tenant — and names the ones that do not:

warn  queries that may be missing the tenant filter
      internal/docs/repo.go:88: documents is filtered by tenant in 7 of 8 queries, and not in this one

It is a text heuristic and says so: no SQL parser, no verdict, a place to look. A query that is right on purpose — a global report, an admin listing — is worth a comment saying so, for the next person as much as for the tool.

Units inside the organisation#

Tenant is the organisation. Inside a city hall the same organisation has secretariats, and a secretariat has departments, and a department has sectors — and the questions people actually ask live down there: this analyst sees their own sector's demands, this task goes to unit X, the secretary sees everything below them.

A unit is a path, so "below me" is a question about a prefix and not a join nobody wrote:

// at login, or when somebody is assigned
u.Units = []string{"sec-adm/protocolo"}
SymbolRole
auth.User.Units []stringthe units this session belongs to, as paths, next to Tenant
auth.Unit(c)the first unit of the session, or ""
auth.Units(c)every unit of the session
auth.UnitWithin(unit, ancestor) boolis this unit that one, or under it
auth.Scope, auth.ScopeOrg, auth.ScopeUnit, auth.ScopeUnitTreehow far a grant reaches
auth.Grant(level, scope) stringthe value of one cell of the matrix
Policy.CanIn(u, module, level, unit) boolmay this person do this here
Policy.ScopeOf(role, module) Scopethe scope of a role's grant
Policy.ScopeNameOf(role, module) string, Policy.ScopeNames()the same, as the strings ui.PolicyGrid draws and posts
Policy.UnitScoped(module) booldoes any role hold this module by unit
(*Auth) Policy(p, module, level) Requirementthe check a handler makes per record
Requirement.In(c, unit) errornil, or the refusal
trilha.Actor.Unitthe unit written onto the audit trail
trilha.ListParams.Unitthe unit a listing is filtered by

The three scopes#

A cell of the matrix is a level and, optionally, the scope it holds in — written with Grant:

var Policy = auth.Policy{
	Modules: []string{"demands", "reports"},
	Levels:  auth.Levels{"view", "edit", "manage"},
	Roles: map[string]auth.Grants{
		"analyst":   {"demands": auth.Grant("edit", auth.ScopeUnit)},      // their own sector
		"secretary": {"demands": auth.Grant("manage", auth.ScopeUnitTree)}, // theirs and below
		"auditor":   {"demands": "view"},                                   // the organisation
	},
}

ScopeOrg is the empty scope and what a level with nothing after it means, so a matrix written before units existed goes on meaning exactly what it meant. On the wire a cell is "edit/unit" or "manage/tree"; Level, LevelOf and Can read past the scope, so Can still answers what it always answered — "may do it somewhere", which is what a menu asks.

UnitWithin compares by segment and never by string prefix: sec-adm/protocolo is within sec-adm and not within sec. A rule that said otherwise would hand a whole secretariat to whoever named a unit carefully.

The check per record#

The folder is entered once; each demand has its own unit. RequirePolicy is the door of the subtree and Requirement.In is the check inside it:

// app/demands/id_/route.go
var edit = acesso.Auth.Policy(acesso.Policy, "demands", "edit")

func POST(c *trilha.Ctx) error {
	d, err := demands.Find(c.Context(), c.Param("id"))
	if err != nil {
		return err
	}
	if err := edit.In(c, d.Unit); err != nil {   // the unit of the record, not of the address
		return err
	}
	c.Audit("demand.edited", d.ID)               // already says which unit
	…
}

Anonymous goes to the login; somebody known and in another unit gets 403, and the message names the unit — needs edit on demands in sec-adm/protocolo — because "forbidden" in an application with forty units is a support thread about which one the person is in. On the way through it writes the unit onto Actor.Unit, so every c.Audit below that line says where it happened without the handler repeating it.

trilha audit warns about a route that guards a unit-scoped module and never calls .In: the folder was entered, and then every record of it is served the same.

The listing#

A listing filters; it does not decide. ListParams.Unit arrives from the query like every other filter — so paging and sorting keep it — and what the query is allowed to see comes from the session:

var q Listing                       // embeds trilha.ListParams
if err := c.Bind(&q); err != nil { return nil, err }
rows, err := repo.List(c.Context(), auth.Tenant(c), auth.Units(c), q.Unit, q.Limit(), q.Offset())

Nothing here writes SQL, for the same reason the tenant column does not: a WHERE this package generated would be a WHERE nobody could read in a review. A filter that came from the address is not a permission — the permission is CanIn on the route that opens one record.

The screen#

ui.PolicyGrid draws a second select per cell when the policy carries scopes — any ui.ScopedPolicy, which auth.Policy is — named scope.<role>.<module>, and auth.BindPolicy reads it back with the level. PolicyGridOpts.ScopeLabels renames the three for the screen. trilha add tenant writes the tree itself: ui.Tree to browse it, ui.TreePicker to put somebody in a unit.

Out of scope, deliberately: synchronising the tree from a corporate directory. That is an import, it belongs to the application, and a half of it built into the framework would be a half nobody could replace.