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
- Variables
- func HashPassword(plain string) (string, error)
- func NeedsRehash(encoded string) bool
- func ValidTenant(tenant string) bool
- func VerifyPassword(plain, encoded string) error
- type Action
- type CSRF
- type Grant
- type MemoryBackend
- type Policy
- type SessionBackend
- type SessionStore
- func (s *SessionStore) Destroy(ctx context.Context, w http.ResponseWriter, id string) error
- func (s *SessionStore) IDFromRequest(r *http.Request) string
- func (s *SessionStore) Load(ctx context.Context, r *http.Request) (Subject, error)
- func (s *SessionStore) Rotate(ctx context.Context, w http.ResponseWriter, oldID string, sub Subject) (string, error)
- func (s *SessionStore) Start(ctx context.Context, w http.ResponseWriter, sub Subject) (string, error)
- type Subject
Constants ¶
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.
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 ¶
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.
var ErrCSRF = errors.New("arandu: invalid or expired CSRF token")
ErrCSRF means the token is invalid, expired, or bound to another session.
var ErrForbidden = errors.New("arandu: action not authorized")
ErrForbidden is the only authorization error. Handlers translate it to 403.
var ErrInvalidPassword = errors.New("arandu: invalid password")
ErrInvalidPassword means the password does not match the stored hash.
Functions ¶
func HashPassword ¶
HashPassword returns the hash in PHC string format, which is self-describing and therefore allows rehashing when parameters change.
func NeedsRehash ¶
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
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 ¶
VerifyPassword compares in constant time.
Types ¶
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.
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 ¶
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.
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) 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 ¶
func (s *SessionStore) Start(ctx context.Context, w http.ResponseWriter, sub Subject) (string, error)
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
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.