auth

package
v0.0.0-...-a9a94a2 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

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

View Source
const (
	ProjectRoleViewer      = "viewer"
	ProjectRoleContributor = "contributor"
	ProjectRoleMaintainer  = "maintainer"
)

Project membership roles (ADR-035 three-tier vocabulary).

View Source
const (
	RoleAdmin = "admin"
	RoleUser  = "user"
)

Global role values (15.2).

Variables

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

View Source
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).

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

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

View Source
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).

View Source
var ErrNotFound = errors.New("auth: not found")

ErrNotFound is the store's not-there sentinel.

Functions

func ClientIP

func ClientIP(trusted []*net.IPNet, xff string, remoteAddr string) string

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

func HashPassword(password string) (string, error)

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

func ParseCIDRs(list string) []*net.IPNet

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

func RoleAtLeast(have, min string) bool

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

func VerifyPassword(password, phc string) error

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

func NewJWTIssuer(key []byte, ttl time.Duration) (*JWTIssuer, error)

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.

func (*JWTIssuer) Mint

func (j *JWTIssuer) Mint(c Claims) (string, error)

Mint signs the claims into a compact HS256 JWT.

func (*JWTIssuer) TTL

func (j *JWTIssuer) TTL() time.Duration

TTL reports the configured token lifetime (for the login response's expiresIn).

func (*JWTIssuer) Verify

func (j *JWTIssuer) Verify(token string) (Claims, error)

Verify checks signature + expiry + issuer and returns the claims. Any failure (tampered payload, wrong key, expired, foreign issuer) is the one ErrInvalidToken.

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

func (s *PostgresSessionStore) Resolve(ctx context.Context, token string) (uuid.UUID, error)

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

func (s *PostgresSessionStore) RevokeAllForUser(ctx context.Context, userID uuid.UUID) error

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

func (s *PostgresUserStore) ByID(ctx context.Context, id uuid.UUID) (*User, error)

ByID fetches the user by id.

func (*PostgresUserStore) ByUsername

func (s *PostgresUserStore) ByUsername(ctx context.Context, username string) (*User, error)

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

func (s *PostgresUserStore) Deactivate(ctx context.Context, id uuid.UUID) error

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

func (s *PostgresUserStore) List(ctx context.Context, limit, offset int) ([]*User, int, error)

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

type ProjectMembership struct {
	Project string `json:"project"`
	Role    string `json:"role"`
}

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

func (s *Service) Logout(ctx context.Context, sessionToken string) error

Logout revokes the session (idempotent: a stale cookie logs out cleanly).

func (*Service) Me

func (s *Service) Me(ctx context.Context, sessionToken string) (*User, error)

Me resolves a session token to its user (the /auth/me probe; 401-shape on any doubt).

func (*Service) Refresh

func (s *Service) Refresh(ctx context.Context, sessionToken string) (*LoginResult, error)

Refresh rotates a live session (old token dies atomically, new cookie + JWT mint).

func (*Service) SessionTTL

func (s *Service) SessionTTL() time.Duration

SessionTTL reports the edge session lifetime (cookie Max-Age / response expiry).

type ServiceConfig

type ServiceConfig struct {
	SessionTTL time.Duration // edge session lifetime; default 24h
}

ServiceConfig carries the tunables (chart ConfigMap surface, 9.5).

type Session

type Session struct {
	Token     string
	ID        uuid.UUID
	UserID    uuid.UUID
	ExpiresAt time.Time
}

Session is a freshly minted session's caller-visible state (token + metadata).

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

func (*User) IsAdmin

func (u *User) IsAdmin() bool

IsAdmin reports the derived admin flag the resolver/AuthorContext carry.

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.

Jump to

Keyboard shortcuts

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