Documentation
¶
Overview ¶
Package auth is the Epic 15 identity seam inside the apiserver (ADR-033 / Arch §12.3, stories 15.1 + 15.2, ISI-2920).
It owns the WRITE path of the local-cred store ISI-2758 landed the read path for: argon2id password hashing, opaque server-side session mint/rotate/revoke over auth.session, the short-lived internal JWT mint, per-IP login rate limiting, user CRUD + bootstrap admin over auth.user, and the 15.9 OIDC group→access mapping seam. It runs IN the apiserver — there is no separate ksquad-auth binary/Deployment (ADR-033 / §17.3).
Discipline carried over from 0006/ISI-2758:
- only sha256(token) ever touches the database — the plaintext bearer token is never persisted;
- fail-closed everywhere: any doubt (unknown user, spent password, revoked/expired session, deactivated account) is an indistinguishable denial;
- no user-enumeration oracles: login/reset answer identically whether or not the account exists.
Index ¶
- Constants
- Variables
- func ClientIP(trusted []*net.IPNet, xff string, remoteAddr string) string
- func GenerateSigningKey() string
- func HashPassword(password string) (string, error)
- func ParseCIDRs(list string) []*net.IPNet
- func RoleAtLeast(have, min string) bool
- func SetHashConcurrency(n int)
- func VerifyPassword(password, phc string) error
- type Claims
- type GroupAccess
- type GroupMapping
- type JWTIssuer
- type LoginResult
- type MembershipStore
- type PostgresMembershipStore
- func (s *PostgresMembershipStore) Grant(ctx context.Context, userID uuid.UUID, project, role, createdBy string) error
- func (s *PostgresMembershipStore) ListForUser(ctx context.Context, userID uuid.UUID) ([]ProjectMembership, error)
- func (s *PostgresMembershipStore) Revoke(ctx context.Context, userID uuid.UUID, project string) error
- func (s *PostgresMembershipStore) RoleForPrincipal(ctx context.Context, principal, project string) (string, error)
- type PostgresSessionStore
- func (s *PostgresSessionStore) Create(ctx context.Context, userID uuid.UUID, ttl time.Duration) (Session, error)
- func (s *PostgresSessionStore) PruneExpired(ctx context.Context) (int64, error)
- func (s *PostgresSessionStore) Resolve(ctx context.Context, token string) (uuid.UUID, error)
- func (s *PostgresSessionStore) Revoke(ctx context.Context, token string) error
- func (s *PostgresSessionStore) RevokeAllForUser(ctx context.Context, userID uuid.UUID) error
- func (s *PostgresSessionStore) Rotate(ctx context.Context, token string, ttl time.Duration) (Session, error)
- type PostgresUserStore
- func (s *PostgresUserStore) ByID(ctx context.Context, id uuid.UUID) (*User, error)
- func (s *PostgresUserStore) ByUsername(ctx context.Context, username string) (*User, error)
- func (s *PostgresUserStore) Count(ctx context.Context) (int, error)
- func (s *PostgresUserStore) Create(ctx context.Context, u *User) error
- func (s *PostgresUserStore) Deactivate(ctx context.Context, id uuid.UUID) error
- func (s *PostgresUserStore) List(ctx context.Context, limit, offset int) ([]*User, int, error)
- func (s *PostgresUserStore) Update(ctx context.Context, id uuid.UUID, upd UserUpdate) (*User, error)
- type ProjectMembership
- type RateLimiter
- type RoleAssignment
- type Service
- func (s *Service) Login(ctx context.Context, username, password, clientIP string) (*LoginResult, error)
- func (s *Service) Logout(ctx context.Context, sessionToken string) error
- func (s *Service) Me(ctx context.Context, sessionToken string) (*User, error)
- func (s *Service) Refresh(ctx context.Context, sessionToken string) (*LoginResult, error)
- func (s *Service) SessionTTL() time.Duration
- type ServiceConfig
- type Session
- type SessionStore
- type User
- type UserStore
- type UserUpdate
Constants ¶
const ( ProjectRoleViewer = "viewer" ProjectRoleContributor = "contributor" ProjectRoleMaintainer = "maintainer" )
Project membership roles (ADR-035 three-tier vocabulary).
const ( RoleAdmin = "admin" RoleUser = "user" )
Global role values (15.2).
Variables ¶
var ( // ErrInvalidCredentials is login's single failure for unknown user / wrong // password / deactivated account (indistinguishable by design). ErrInvalidCredentials = errors.New("auth: invalid credentials") // ErrRateLimited is the brute-force brake answer (15.1: 5 failures/15min/IP default). ErrRateLimited = errors.New("auth: too many attempts") // ErrSessionExpired covers refresh/me/logout over a dead session token. ErrSessionExpired = errors.New("auth: session expired") )
Caller-visible service errors.
var ErrInvalidPassword = errors.New("auth: invalid credentials")
ErrInvalidPassword is returned by VerifyPassword when the credential does not match. It is deliberately indistinguishable from "no such user" at the call site (login runs Verify against a dummy hash for unknown users so even the TIMING matches — see Service.Login).
var ErrInvalidToken = errors.New("auth: invalid token")
ErrInvalidToken is the single, opaque failure for a token that does not verify (bad signature, expired, malformed). No reason is surfaced to the caller.
var ErrLastAdmin = errors.New("auth: refusing to remove the last active admin")
ErrLastAdmin is the lockout guard (PR #90 review finding 4): the mutation (demote / deactivate) would leave ZERO active admins, and bootstrapAdmin only runs on an empty user table — there would be no recovery path.
var ErrNoMembership = errors.New("auth: no project membership")
ErrNoMembership is the "this user holds no role on this Project" sentinel. It is NOT an error condition to log — it is the ordinary deny outcome the middleware turns into a 404 (existence-hiding).
var ErrNotFound = errors.New("auth: not found")
ErrNotFound is the store's not-there sentinel.
Functions ¶
func ClientIP ¶
ClientIP extracts the client address for the login limiter (PR #90 review finding 1). X-Forwarded-For is honored ONLY when the socket peer (remoteAddr) is inside the trusted proxy set: the chain is then walked RIGHT→LEFT past trusted hops, and the first untrusted entry is the client. An untrusted (or absent) proxy means the XFF list is attacker-controlled decoration — the socket address is used instead. An empty trust set trusts no one.
func GenerateSigningKey ¶
func GenerateSigningKey() string
GenerateSigningKey mints a fresh 32-byte HS256 key (base64-encoded for env/config transport). Used by cmd/apiserver when no durable key is configured — auto-generated keys mean sessions survive only until the pod restarts (Helm 9.5 supplies the durable one).
func HashPassword ¶
HashPassword derives the argon2id PHC string ("$argon2id$v=19$m=..,t=..,p=..$salt$hash") for a plaintext password. The salt is freshly random per call; the plaintext is never retained. The derivation rides the bounded-concurrency gate (see hashGate).
func ParseCIDRs ¶
ParseCIDRs parses a comma-separated list of IPs / CIDR prefixes into the trust set ClientIP consumes ("10.0.0.0/8,127.0.0.1"). Bare IPs become /32 (/128). An empty string yields an empty set — trust NOTHING by default.
func RoleAtLeast ¶
RoleAtLeast reports whether the held Project role satisfies the required minimum, using the ADR-035 ordering viewer < contributor < maintainer. An unknown/empty held role never satisfies any requirement (deny-by-default); an unknown/empty required role is treated as "any membership suffices" only when the held role is itself a known role (rank ≥ 1).
func SetHashConcurrency ¶
func SetHashConcurrency(n int)
SetHashConcurrency sets the maximum number of simultaneous argon2 derivations. n <= 0 removes the bound. It must be called before any hashing starts (startup config / init of a test binary), not raced against in-flight logins.
func VerifyPassword ¶
VerifyPassword checks a plaintext against a stored argon2id PHC string in constant time (subtle.ConstantTimeCompare over the derived key). A malformed stored hash fails closed with ErrInvalidPassword — a corrupt credential row must never authenticate.
Types ¶
type Claims ¶
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
UserID string `json:"uid"`
TeamID string `json:"tid,omitempty"`
Role string `json:"role,omitempty"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
SessionID string `json:"sid,omitempty"`
}
Claims is the internal JWT payload. Subject is the STABLE principal (matches AuthorContext.Principal / author_principal stamps); role is the bounded two-value global role (admin|user) the BFF uses for adaptive-nav hints (8.16) — it is NEVER an authorization decision by itself (the server re-resolves the session per call).
type GroupAccess ¶
type GroupAccess struct {
Admin bool `json:"-"`
Membership *ProjectMembership `json:"-"`
}
GroupAccess is the mapped access for one OIDC group claim value: either the string "admin" (global admin) or a {project, role} object.
func (*GroupAccess) UnmarshalJSON ¶
func (g *GroupAccess) UnmarshalJSON(data []byte) error
UnmarshalJSON accepts the values.yaml / config shapes:
{"platform-admins": "admin",
"k8s-devs": {"project": "my-project", "role": "contributor"}}
type GroupMapping ¶
type GroupMapping map[string]GroupAccess
GroupMapping is the parsed auth.oidc.groupMapping config: group claim value → access.
func ParseGroupMapping ¶
func ParseGroupMapping(raw string) (GroupMapping, error)
ParseGroupMapping parses the raw JSON config, failing closed on any malformed entry (a typo'd mapping must never silently grant nothing or everything).
func (GroupMapping) Resolve ¶
func (gm GroupMapping) Resolve(groups []string) RoleAssignment
Resolve maps the user's group claims against the mapping. Unmapped groups are silently ignored (no implicit grant); a user in both an admin group and project groups is promoted to admin; duplicate projects collapse to the strongest role (maintainer > contributor > viewer) so the assignment is deterministic.
type JWTIssuer ¶
type JWTIssuer struct {
// contains filtered or unexported fields
}
JWTIssuer issues HS256 mint/Verify pair.
func NewJWTIssuer ¶
NewJWTIssuer builds the issuer. key must be at least 32 bytes (HS256 security floor); ttl <= 0 defaults to 1h. A zero key is rejected — callers generate one at startup (cmd/apiserver) and log the auto-generation warning.
type LoginResult ¶
type LoginResult struct {
SessionToken string
AccessToken string
ExpiresIn int64 // JWT lifetime, seconds
User *User
}
LoginResult is the successful login's mint: the opaque session token (for the HttpOnly cookie), the internal JWT, and the authenticated user.
type MembershipStore ¶
type MembershipStore interface {
// RoleForPrincipal returns the caller's role on the named Project, or ErrNoMembership if the
// principal is unknown or holds no grant there.
RoleForPrincipal(ctx context.Context, principal, project string) (string, error)
// ListForUser returns every (Project, role) grant a user holds (8.15 review surface).
ListForUser(ctx context.Context, userID uuid.UUID) ([]ProjectMembership, error)
// Grant upserts a user's role on a Project (one role per user per Project — the strongest
// intended role, since callers collapse duplicates before writing). createdBy is the acting
// admin's principal ("" ⇒ NULL, the 15.9 IdP-sync row).
Grant(ctx context.Context, userID uuid.UUID, project, role, createdBy string) error
// Revoke removes a user's grant on a Project (idempotent: no row ⇒ no error).
Revoke(ctx context.Context, userID uuid.UUID, project string) error
}
MembershipStore is the persistence seam for auth.project_membership (Postgres-backed in production; fakes in unit tests). RoleForPrincipal is the enforcement hot path — it resolves a caller's identity string straight to their role on a Project in one join, so the middleware never carries a user UUID.
type PostgresMembershipStore ¶
type PostgresMembershipStore struct {
// contains filtered or unexported fields
}
PostgresMembershipStore is the production MembershipStore over the shared *sql.DB.
func NewPostgresMembershipStore ¶
func NewPostgresMembershipStore(db *sql.DB) *PostgresMembershipStore
NewPostgresMembershipStore builds the production membership store.
func (*PostgresMembershipStore) Grant ¶
func (s *PostgresMembershipStore) Grant(ctx context.Context, userID uuid.UUID, project, role, createdBy string) error
Grant upserts against the UNIQUE(user_id, project) constraint: a re-grant updates the role and re-stamps provenance. An out-of-vocabulary role is refused by the DB CHECK (surfaced as an error).
func (*PostgresMembershipStore) ListForUser ¶
func (s *PostgresMembershipStore) ListForUser(ctx context.Context, userID uuid.UUID) ([]ProjectMembership, error)
ListForUser returns the user's grants ordered by Project name (stable for the review surface).
func (*PostgresMembershipStore) Revoke ¶
func (s *PostgresMembershipStore) Revoke(ctx context.Context, userID uuid.UUID, project string) error
Revoke deletes the grant (idempotent — a missing row is not an error).
func (*PostgresMembershipStore) RoleForPrincipal ¶
func (s *PostgresMembershipStore) RoleForPrincipal(ctx context.Context, principal, project string) (string, error)
RoleForPrincipal joins auth.user (stable principal) → auth.project_membership in one query. A deactivated user resolves to ErrNoMembership too (deactivated_at IS NULL), so a soft-deleted account cannot ride a stale grant.
type PostgresSessionStore ¶
type PostgresSessionStore struct {
// contains filtered or unexported fields
}
PostgresSessionStore is the production SessionStore.
func NewPostgresSessionStore ¶
func NewPostgresSessionStore(db *sql.DB) *PostgresSessionStore
NewPostgresSessionStore builds the production session store.
func (*PostgresSessionStore) Create ¶
func (s *PostgresSessionStore) Create(ctx context.Context, userID uuid.UUID, ttl time.Duration) (Session, error)
Create inserts a live session row and returns the bearer token (never persisted).
func (*PostgresSessionStore) PruneExpired ¶
func (s *PostgresSessionStore) PruneExpired(ctx context.Context) (int64, error)
PruneExpired is the janitor (0006 retention note): expired rows are operational residue, deleted so the table stays hot-index-only-live.
func (*PostgresSessionStore) Resolve ¶
Resolve returns the userID bound to a LIVE session token (fail-closed on any doubt).
func (*PostgresSessionStore) Revoke ¶
func (s *PostgresSessionStore) Revoke(ctx context.Context, token string) error
Revoke signs out one session (idempotent — revoking a dead/unknown token is a no-op so logout can never fail on a stale cookie).
func (*PostgresSessionStore) RevokeAllForUser ¶
RevokeAllForUser kills every live session of a user (deactivation / password reset).
func (*PostgresSessionStore) Rotate ¶
func (s *PostgresSessionStore) Rotate(ctx context.Context, token string, ttl time.Duration) (Session, error)
Rotate atomically replaces a live session: a new row is inserted and the old one revoked in one transaction. An unknown/expired/revoked token rotates nothing (ErrNotFound) — refresh must fail closed exactly like resolution does.
type PostgresUserStore ¶
type PostgresUserStore struct {
// contains filtered or unexported fields
}
PostgresUserStore is the production UserStore over the shared *sql.DB.
func NewPostgresUserStore ¶
func NewPostgresUserStore(db *sql.DB) *PostgresUserStore
NewPostgresUserStore builds the production user store.
func (*PostgresUserStore) ByUsername ¶
ByUsername fetches the user by login key (any state — the login path checks deactivation itself so the failure stays indistinguishable from unknown user).
func (*PostgresUserStore) Count ¶
func (s *PostgresUserStore) Count(ctx context.Context) (int, error)
Count returns the total user count (bootstrap idempotency probe).
func (*PostgresUserStore) Create ¶
func (s *PostgresUserStore) Create(ctx context.Context, u *User) error
Create inserts a new user. The stable principal is minted here ("user:"+username) and can never be overridden by the caller — identity is immutable after this.
func (*PostgresUserStore) Deactivate ¶
Deactivate soft-deletes (one-way deactivated_at stamp). Session revocation is the service layer's job (same transaction span is not required: the resolver filters deactivated_at IS NULL, so the cookie dies immediately regardless). Deactivating the LAST active admin is refused with ErrLastAdmin — there is no recovery path once the install has no admin and a non-empty user table.
func (*PostgresUserStore) List ¶
List returns one page of users ordered by creation (oldest first — stable for pagination) plus the total count.
func (*PostgresUserStore) Update ¶
func (s *PostgresUserStore) Update(ctx context.Context, id uuid.UUID, upd UserUpdate) (*User, error)
Update applies the PATCH surface and returns the updated row. Both column writes run in ONE transaction (PR #90 review: no torn half-updates), and a demotion that would leave zero active admins is refused with ErrLastAdmin.
type ProjectMembership ¶
ProjectMembership is one (project, role) pair a group mapping grants.
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter is a sliding-window counter keyed by client IP.
func NewRateLimiter ¶
func NewRateLimiter(limit int, window time.Duration) *RateLimiter
NewRateLimiter builds a limiter allowing `limit` FAILED attempts per `window`. Non-positive limits disable limiting (limit <= 0 ⇒ Allow always true) — used by tests and by an explicit operator opt-out, never the default.
func (*RateLimiter) Allow ¶
func (r *RateLimiter) Allow(ip string) bool
Allow reports whether an attempt from ip is currently permitted WITHOUT recording it — the caller records the outcome via Failure (bad credentials) or Success (authentic login, clears the window).
func (*RateLimiter) Failure ¶
func (r *RateLimiter) Failure(ip string)
Failure records one failed attempt for ip.
func (*RateLimiter) Success ¶
func (r *RateLimiter) Success(ip string)
Success clears ip's failure window — authentic logins never consume the brute-force budget.
type RoleAssignment ¶
type RoleAssignment struct {
// GlobalRole is "admin" when a mapped group grants admin (conflict promotion:
// admin wins over project-level memberships, 15.9), or "user" when any mapped
// group grants a project membership — the base global_role the provisioned
// record carries (auth.user.global_role ∈ {admin,user}, NOT NULL, 0008).
// Empty means NOTHING was mapped: no implicit grant (15.9).
GlobalRole string
// Memberships are the (project, role) pairs granted by mapped groups.
Memberships []ProjectMembership
}
RoleAssignment is the resolved outcome of mapping a user's group claims.
type Service ¶
type Service struct {
Users UserStore
Sessions SessionStore
JWT *JWTIssuer
Limiter *RateLimiter
// contains filtered or unexported fields
}
Service is the auth core. Construct with NewService; zero-value fields fail closed.
func NewService ¶
func NewService(users UserStore, sessions SessionStore, jwt *JWTIssuer, limiter *RateLimiter, cfg ServiceConfig) *Service
NewService assembles the core. Defaults: session TTL 24h.
func (*Service) Login ¶
func (s *Service) Login(ctx context.Context, username, password, clientIP string) (*LoginResult, error)
Login authenticates username+password and mints the edge session + internal JWT. clientIP feeds the per-IP failure limiter.
There is deliberately NO group/claim input here (PR #90 review finding 3): group→access mapping (15.9) consumes claims from a TRUSTED OIDC token exchange, never from the client's request body. That leg lands with the OIDC login flow; the mapping itself lives in groupmapping.go.
func (*Service) Me ¶
Me resolves a session token to its user (the /auth/me probe; 401-shape on any doubt).
func (*Service) Refresh ¶
Refresh rotates a live session (old token dies atomically, new cookie + JWT mint).
func (*Service) SessionTTL ¶
SessionTTL reports the edge session lifetime (cookie Max-Age / response expiry).
type ServiceConfig ¶
ServiceConfig carries the tunables (chart ConfigMap surface, 9.5).
type SessionStore ¶
type SessionStore interface {
Resolve(ctx context.Context, token string) (uuid.UUID, error)
Create(ctx context.Context, userID uuid.UUID, ttl time.Duration) (Session, error)
Rotate(ctx context.Context, token string, ttl time.Duration) (Session, error)
Revoke(ctx context.Context, token string) error
RevokeAllForUser(ctx context.Context, userID uuid.UUID) error
PruneExpired(ctx context.Context) (int64, error)
}
SessionStore is the session persistence seam. Resolve carries the SAME fail-closed live-session predicate the apiserver's PostgresSessionResolver enforces (revoked_at IS NULL AND expires_at > now(), user not deactivated) — one source of truth for "is this token alive".
type User ¶
type User struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Principal string `json:"principal"`
Email *string `json:"email,omitempty"`
PasswordHash string `json:"-"`
TeamID uuid.UUID `json:"teamId"`
GlobalRole string `json:"globalRole"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy *string `json:"createdBy,omitempty"`
DeactivatedAt *time.Time `json:"deactivatedAt,omitempty"`
}
User is the stored user record. PasswordHash is never serialized to API responses (internal/apiserver maps to a response shape without it).
type UserStore ¶
type UserStore interface {
ByUsername(ctx context.Context, username string) (*User, error)
ByID(ctx context.Context, id uuid.UUID) (*User, error)
Create(ctx context.Context, u *User) error
List(ctx context.Context, limit, offset int) ([]*User, int, error)
Update(ctx context.Context, id uuid.UUID, upd UserUpdate) (*User, error)
Deactivate(ctx context.Context, id uuid.UUID) error
Count(ctx context.Context) (int, error)
}
UserStore is the persistence seam for auth.user (Postgres-backed in production; fakes in unit tests).
type UserUpdate ¶
type UserUpdate struct {
GlobalRole *string
Email *string // nil = unchanged; empty string = clear
}
UserUpdate is the PATCH surface (15.2: role + deactivation + optional profile attrs). Zero fields are left untouched.