security

package
v0.18.0 Latest Latest
Warning

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

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

Documentation

Overview

Package security holds the authorization primitives of the framework.

It is not an optional package: Grant, Policy, sessions and password hashing live in the core because security is the product thesis, not a plugin the user may forget to install.

Index

Constants

View Source
const (

	// MinPasswordLen is the shortest password the framework accepts. Length
	// beats composition rules: it is the only parameter that reliably raises
	// the cost of an offline attack.
	MinPasswordLen = 12
)

argon2id parameters. They are deliberately not configurable through the environment: an insecure hash configuration is the most common way to break authentication without noticing.

View Source
const SessionCookieName = "arandu_session"

SessionCookieName is the cookie the framework reads and writes. It is fixed on purpose: a configurable cookie name buys nothing and breaks the CSRF binding when two parts of a project disagree about it.

Variables

View Source
var (
	// ErrNoSession means the request carries no session cookie, or the cookie
	// signature does not match the application key.
	ErrNoSession = errors.New("arandu: no session")
	// ErrSessionExpired means the session id is well formed but the backend no
	// longer holds it -- expired, or destroyed by a logout elsewhere.
	ErrSessionExpired = errors.New("arandu: session expired")
)

Errors returned by SessionStore.

View Source
var ErrCSRF = errors.New("arandu: invalid or expired CSRF token")

ErrCSRF means the token is invalid, expired, or bound to another session.

View Source
var ErrForbidden = errors.New("arandu: action not authorized")

ErrForbidden is the only authorization error. Handlers translate it to 403.

View Source
var ErrInvalidPassword = errors.New("arandu: invalid password")

ErrInvalidPassword means the password does not match the stored hash.

Functions

func HashPassword

func HashPassword(plain string) (string, error)

HashPassword returns the hash in PHC string format, which is self-describing and therefore allows rehashing when parameters change.

func NeedsRehash

func NeedsRehash(encoded string) bool

NeedsRehash reports that the hash was produced with older parameters, so the caller should rehash on the next successful login.

func ValidTenant added in v0.10.0

func ValidTenant(tenant string) bool

ValidTenant reports whether a tenant identifier is safe to use as a namespace.

Exported because the adapters build keys from it and a second, slightly different definition in each of them is how one of them ends up permissive.

func VerifyPassword

func VerifyPassword(plain, encoded string) error

VerifyPassword compares in constant time.

Types

type Action

type Action string

Action is the intended operation, in "module.verb" form.

type CSRF

type CSRF struct {
	// contains filtered or unexported fields
}

CSRF issues double-submit tokens signed with HMAC and bound to the session. It keeps no server-side state: the token carries its own expiry, so a deployment does not need Redis just to protect forms.

func NewCSRF

func NewCSRF(appKey []byte, ttl time.Duration) *CSRF

NewCSRF returns a token issuer keyed by the application key.

func (*CSRF) Issue

func (c *CSRF) Issue(sessionID string) (string, error)

Issue generates a token for a session.

func (*CSRF) Validate

func (c *CSRF) Validate(sessionID, token string) error

Validate checks signature, expiry and the binding to the session.

type Grant

type Grant struct {
	// contains filtered or unexported fields
}

Grant is the proof that an authorization decision happened.

THIS IS THE CENTRAL PIECE OF THE FRAMEWORK. Grant has only unexported fields, so it cannot be built by writing a struct literal: every repository signature requires one, and reaching the database without a Grant does not compile.

What the compiler does NOT decide is which Grant. Authorize is the mandatory path and the only one where a Policy answered; SystemGrant is the named escape hatch and jobs.GrantFor wraps it, and both are exported, so a handler can construct a Grant nobody authorized. What stops that is `aru doctor` -- a lint, not the type system -- with system-grant-outside-scope, system-grant-without-tenant and tenant-from-request.

This comment used to say "no public constructor other than Authorize", which was never true and read as a compile-time guarantee for something a lint enforces. It is the difference between the promise and the mechanism, and stating it wrong here is worse than anywhere else: this is the doc a reader checks the thesis against.

The alternative shape -- authorization as a call the handler remembers to make -- is authorization that gets forgotten, and nothing warns you.

func Authorize

func Authorize[T any](ctx context.Context, p Policy[T], s Subject, a Action, resource T) (Grant, error)

Authorize runs the policy and, when allowed, issues the Grant.

func SystemGrant

func SystemGrant(a Action, tenant string) Grant

func (Grant) Action

func (g Grant) Action() Action

Action exposes what was authorized.

func (Grant) Check

func (g Grant) Check(expected Action) error

Check is the guard every repository operation must call.

It fails on the zero value -- the only Grant a caller outside this package can build -- and when the grant was issued for a different action, which catches copy-paste between repository methods.

func (Grant) Subject

func (g Grant) Subject() Subject

Subject exposes who was authorized -- used to scope SQL by tenant.

type MemoryBackend

type MemoryBackend struct {
	// contains filtered or unexported fields
}

MemoryBackend keeps sessions in the process memory.

It is the right choice for development and for a single instance. Behind more than one pod it silently logs people out on every deploy and on every request routed elsewhere -- use the redis adapter there.

func NewMemoryBackend

func NewMemoryBackend() *MemoryBackend

NewMemoryBackend returns an empty in-memory session backend.

func (*MemoryBackend) Delete

func (m *MemoryBackend) Delete(ctx context.Context, id string) error

Delete removes the session, if present.

func (*MemoryBackend) Get

func (m *MemoryBackend) Get(ctx context.Context, id string) (Subject, error)

Get returns the subject, or ErrSessionExpired when the id is unknown.

func (*MemoryBackend) Put

func (m *MemoryBackend) Put(ctx context.Context, id string, s Subject, ttl time.Duration) error

Put stores the subject under id for the given ttl.

type Policy

type Policy[T any] interface {
	// Can decides whether subject may perform action on resource. resource may
	// be the zero value for collection actions (e.g. "customer.list").
	Can(ctx context.Context, s Subject, a Action, resource T) error
}

Policy decides. One policy per entity, always in the module's <entity>.policy.go file -- the CLI generates the skeleton and `aru doctor` complains when a repository exists without a matching policy.

type SessionBackend

type SessionBackend interface {
	// Get returns the subject, or ErrSessionExpired when the backend does not
	// hold the id -- expired, evicted, or destroyed by a logout elsewhere.
	//
	// ErrSessionExpired specifically, not the backend's own not-found error.
	// Callers branch on it to send somebody back to the login page, and a
	// backend that returns something else makes swapping the store change the
	// behaviour of the application. Found by audit: the kv backend returned its
	// own kv.ErrNotFound, so an expired session in Redis fell through to the
	// generic error path that a single-instance deployment never reached.
	Get(ctx context.Context, id string) (Subject, error)
	Put(ctx context.Context, id string, s Subject, ttl time.Duration) error
	Delete(ctx context.Context, id string) error
}

SessionBackend stores the subject behind a session id.

The core ships MemoryBackend only, which is enough for development and for a single instance. The redis adapter provides the distributed store with active invalidation; see docs/05-repositorios.md.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore issues and validates sessions.

The cookie value is the session id plus an HMAC of it. The signature is checked before the backend is touched, so a forged cookie never reaches the store -- and, in a distributed store, never costs a network round trip.

func NewSessionStore

func NewSessionStore(appKey []byte, ttl time.Duration, secure bool, b SessionBackend) *SessionStore

NewSessionStore returns a store. Pass secure=false only in development: without the Secure attribute the cookie travels over plain HTTP.

func (*SessionStore) Destroy

func (s *SessionStore) Destroy(ctx context.Context, w http.ResponseWriter, id string) error

Destroy removes the session and clears the cookie.

func (*SessionStore) IDFromRequest

func (s *SessionStore) IDFromRequest(r *http.Request) string

IDFromRequest returns the session id when the cookie signature is valid, and the empty string otherwise. It is the function to hand to the CSRF middleware, which binds its token to this id.

func (*SessionStore) Load

func (s *SessionStore) Load(ctx context.Context, r *http.Request) (Subject, error)

Load returns the subject bound to the request's session cookie.

func (*SessionStore) Rotate

func (s *SessionStore) Rotate(ctx context.Context, w http.ResponseWriter, oldID string, sub Subject) (string, error)

Rotate issues a new session id for the same subject and destroys the old one.

It MUST be called on login: keeping the pre-login id is session fixation, the bug that lets an attacker plant a known id and inherit the session after the victim authenticates. `aru doctor` checks for this call.

func (*SessionStore) Start

Start creates a session for the subject and writes the cookie.

type Subject

type Subject struct {
	ID     string
	Tenant string
	Roles  []string
	// contains filtered or unexported fields
}

Subject is whoever is acting. It comes from the session, never from the request body.

func Guest added in v0.16.0

func Guest(tenant string) Subject

Guest is a reader with no session, declared on purpose.

It exists because a public page is a real requirement and the alternative was worse. Authorize refuses an empty subject before it consults a policy -- which is right, because an empty subject is almost always a forgotten session load -- and that left no way at all to say "anybody may read a published post". The only path was security.SystemGrant, which skips the policy entirely: a blog served with the same instrument a scheduled job uses.

So the refusal stays and the exception is explicit. A zero Subject is still refused. This one reaches the policy, and the POLICY decides:

func (PostPolicy) Can(ctx context.Context, s security.Subject, a security.Action, p models.Post) error {
	if s.IsGuest() {
		if a == PostView && !p.PublishedAt.IsZero() {
			return nil
		}
		return fmt.Errorf("%s is not public", a)
	}
	…
}

Nothing is loosened by this. Authorization still happens in one place, the Grant is still the only way to a repository, and a policy that says nothing about guests denies them -- which is what every generated policy does, so the default is closed.

The tenant is required and is the application's, from configuration. A visitor cannot choose whose rows they read, and RULE 14 is not suspended because nobody signed in.

func (Subject) HasRole

func (s Subject) HasRole(r string) bool

HasRole reports whether the subject carries the given role.

func (Subject) IsGuest added in v0.16.0

func (s Subject) IsGuest() bool

IsGuest reports whether this subject is a declared anonymous reader.

A policy that never asks denies them, because it will fall through to its final refusal -- HasRole answers false for a guest, and there is no id to compare an owner against.

Jump to

Keyboard shortcuts

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