auth

package
v0.0.0-...-4108e51 Latest Latest
Warning

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

Go to latest
Published: Jun 11, 2026 License: OSL-3.0 Imports: 30 Imported by: 0

Documentation

Overview

Package auth provides user authentication and authorization.

Index

Constants

View Source
const (
	// PasswordResetExpiry is how long password reset tokens are valid
	PasswordResetExpiry = 1 * time.Hour

	// PasswordSetupExpiry is how long an invited user has to set their
	// initial password via the link in the welcome email. Longer than
	// PasswordResetExpiry because invites typically wait in an inbox
	// before the recipient acts on them.
	PasswordSetupExpiry = 7 * 24 * time.Hour

	// DefaultSessionDurationHours is the default session duration in hours
	DefaultSessionDurationHours = 24

	// Account lockout settings for brute-force protection
	// MaxFailedLoginAttempts is the number of failed attempts before lockout
	MaxFailedLoginAttempts = 5

	// AccountLockoutDuration is how long an account is locked after max failed attempts
	AccountLockoutDuration = 15 * time.Minute
)

Configuration constants

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

Predefined roles

View Source
const (
	ActionView    = "view"
	ActionCreate  = "create"
	ActionUpdate  = "update"
	ActionDelete  = "delete"
	ActionExecute = "execute"
	ActionApprove = "approve"
	ActionAdmin   = "admin"
	// ActionCancelOwn / ActionCancelAny gate the session-authed Cancel
	// button on pending Purchase History rows (issue #46).
	//
	// Default grants:
	//   * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers
	//     both verbs.
	//   * RoleUser — DefaultUserPermissions() adds cancel-own:purchases.
	//     Allows cancelling pending executions whose created_by_user_id
	//     matches the session user. Legacy rows with NULL creator are
	//     out of reach for non-admins via this verb; admins still cancel
	//     them via cancel-any.
	//   * RoleReadOnly — neither verb. Read-only users cannot cancel.
	//
	// cancel-any has no default non-admin grant; the constant exists so
	// future operator roles can be granted broad cancel rights without
	// escalating to admin. Add it to a custom group's Permissions to
	// enable that path.
	//
	// The legacy email-token cancel path stays unchanged as an escape
	// hatch and is gated by token possession, not these verbs.
	ActionCancelOwn = "cancel-own"
	ActionCancelAny = "cancel-any"
	// ActionRetryOwn / ActionRetryAny gate the session-authed Retry
	// button on failed Purchase History rows (issue #47). Mirror image
	// of the cancel verbs above:
	//
	//   * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers
	//     both verbs.
	//   * RoleUser — DefaultUserPermissions() adds retry-own:purchases.
	//     Allows retrying failed executions whose created_by_user_id
	//     matches the session user. Legacy rows with NULL creator are
	//     out of reach for non-admins via this verb; admins still
	//     retry them via retry-any.
	//   * RoleReadOnly — neither verb. Read-only users cannot retry.
	//
	// retry-any has no default non-admin grant; the constant exists so
	// future operator roles can be granted broad retry rights without
	// escalating to admin.
	//
	// Retry creates a NEW purchase execution from the failed row's
	// stored Recommendations slice; it is NOT a status mutation of the
	// original row (the original keeps its `failed` status as a
	// historical record and gains a retry_execution_id pointer to the
	// successor). The "execute purchases" action is therefore the
	// natural permission to require, but the retry verbs let us gate
	// the *source* — a user without retry-own can still trigger fresh
	// purchases via the Recommendations page; they just can't act on
	// somebody else's failed row.
	ActionRetryOwn = "retry-own"
	ActionRetryAny = "retry-any"
	// ActionApproveOwn / ActionApproveAny gate the session-authed Approve
	// button on pending Purchase History rows (issue #286). Mirror image
	// of the cancel-{own,any} verbs above:
	//
	//   * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers
	//     both verbs.
	//   * RoleUser — DefaultUserPermissions() adds approve-own:purchases.
	//     Allows approving pending executions whose created_by_user_id
	//     matches the session user. Legacy rows with NULL creator are
	//     out of reach for non-admins via this verb; admins still
	//     approve them via approve-any.
	//   * RoleReadOnly — neither verb. Read-only users cannot approve.
	//
	// approve-any has no default non-admin grant; the constant exists so
	// future operator roles can be granted broad approve rights without
	// escalating to admin. Add it to a custom group's Permissions to
	// enable that path.
	//
	// The legacy email-token approve path stays unchanged as an escape
	// hatch and is gated by token possession + the per-account
	// contact_email gate (PR #101), not these verbs.
	ActionApproveOwn = "approve-own"
	ActionApproveAny = "approve-any"
	// ActionExecuteOwn / ActionExecuteAny gate the direct-execute shortcut
	// on the Recommendations page (issue #289). A holder skips the approval
	// email and immediately commits the purchase, with audit fields
	// (executed_by_user_id, executed_at, pre_approval_skip_reason) stamped
	// on the execution row.
	//
	//   * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers
	//     both verbs.
	//   * RoleUser — NO default grant. This is a finance-impacting permission
	//     that must be explicitly granted per-user/per-role. Even trusted
	//     users submit via the approval flow by default; only deliberately
	//     privileged accounts should hold this verb.
	//   * RoleReadOnly — neither verb.
	//
	// execute-own: allows direct-execute only for executions where
	//   created_by_user_id == session user (the user drafted the purchase
	//   themselves). Like approve-own, legacy rows with NULL creator are
	//   unreachable for non-admins via this verb.
	// execute-any: allows direct-execute regardless of creator; no ownership
	//   check. No default non-admin grant; add to a custom operator group.
	ActionExecuteOwn = "execute-own"
	ActionExecuteAny = "execute-any"
	// ActionUpdateAny is the privileged escape that lets a holder manage
	// (pause / resume / run / delete) a SCHEDULED purchase execution
	// regardless of who created it (issue #950). It complements the base
	// update:purchases verb every authenticated user already holds: that
	// base verb authorises managing only your OWN scheduled purchases
	// (created_by_user_id == session.UserID), while update-any drops the
	// per-record ownership check.
	//
	//   * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; update-any is
	//     NOT in adminCarvedOuts, so admins manage every scheduled purchase.
	//   * RoleUser — NO default grant. A standard user manages only the
	//     scheduled purchases they created (base update:purchases + creator
	//     match). Legacy rows with NULL created_by_user_id are out of reach
	//     for non-admins (they hold neither update-any nor a creator match).
	//   * Custom operator groups — add update-any:purchases to let a role
	//     manage everyone's scheduled purchases without escalating to admin.
	//
	// There is no separate update-own verb: the existing update:purchases
	// grant already plays that role, mirroring how cancel-own/approve-own
	// gate History rows. The creator match is enforced in the handler
	// (authorizeExecutionManagement), not in HasPermission.
	ActionUpdateAny = "update-any"
	// ActionRevokeOwn / ActionRevokeAny gate the in-app Revoke button on
	// completed purchase_history rows while still within the provider's
	// free-cancel window (issue #290).
	//
	// Default grants:
	//   * RoleAdmin   -- implicit via {ActionAdmin, ResourceAll}.
	//   * RoleUser    -- DefaultUserPermissions() adds revoke-own:purchases.
	//     "Own" is currently enforced at ACCOUNT scope, not creator scope:
	//     a user may revoke a completed purchase in any cloud account they
	//     are allowed to access (the check in
	//     api.checkRevokeOwnAccountAccess via GetAllowedAccountsAPI), because
	//     purchase_history rows pre-date created_by_user_id and have no
	//     reliable per-creator attribution. Rows with no account association
	//     (CloudAccountID NULL) are out of reach for non-admins (fail-closed);
	//     admins still revoke them via revoke-any.
	//     NOTE: whether revoke-own should instead be creator-scoped is a
	//     product decision tracked in issue #950; do not tighten this to
	//     created_by_user_id without resolving that issue first.
	//   * RoleReadOnly -- neither verb.
	//
	// revoke-any has no default non-admin grant; the constant exists so
	// future operator roles can be granted broad revoke rights without
	// escalating to admin.
	ActionRevokeOwn = "revoke-own"
	ActionRevokeAny = "revoke-any"
)

Predefined actions

View Source
const (
	ResourceRecommendations = "recommendations"
	ResourcePlans           = "plans"
	ResourcePurchases       = "purchases"
	ResourceHistory         = "history"
	ResourceConfig          = "config"
	ResourceAccounts        = "accounts"
	ResourceUsers           = "users"
	ResourceGroups          = "groups"
	ResourceAPIKeys         = "api-keys"
	// ResourceRIExchange gates RI-exchange-specific operations. The execute
	// verb on this resource is deliberately separate from execute:purchases
	// because RI exchanges are financially irreversible (the AWS API does
	// not have a rollback path once an exchange is submitted). Admins carry
	// implicit access via {ActionAdmin, ResourceAll}. Non-admin roles must
	// be explicitly granted execute:ri-exchange by a custom group; there is
	// no default user-role grant.
	ResourceRIExchange = "ri-exchange"
	ResourceAll        = "*"
)

Predefined resources

View Source
const DefaultAdminGroupID = "00000000-0000-5000-8000-000000000001"

DefaultAdminGroupID is the fixed UUID of the Administrators group seeded by migration 000024. SetupAdmin auto-assigns new admin users to this group so the group card shows members on a fresh install.

View Source
const DefaultPurchaserGroupID = "00000000-0000-5000-8000-000000000007"

DefaultPurchaserGroupID is the fixed UUID of the Purchaser group, relocated by migration 000064 to resolve the UUID collision with "Standard Users" (issue #942). It holds the three money-spending verbs carved out of the admin:* wildcard (issue #923).

View Source
const GroupPurchaser = "Purchaser"

GroupPurchaser is the canonical name of the system-managed Purchaser group. MUST match the literal name inserted by migration 000059_seed_purchaser_group.up.sql so name-based lookups agree with the seeded row.

View Source
const (

	// PasswordResetRateLimit is the minimum interval between password reset requests
	// for the same email address. A second request within this window is silently
	// dropped (the existing token remains valid) to prevent a griefing vector where
	// an attacker repeatedly requests resets to perpetually invalidate the victim's
	// legitimate link.
	PasswordResetRateLimit = 1 * time.Minute
)

Password validation constants following NIST guidelines

Variables

View Source
var (
	ErrInvalidEmail   = errors.New("invalid email format")
	ErrEmailInUse     = errors.New("email already in use")
	ErrAdminExists    = errors.New("admin user already exists")
	ErrPasswordPolicy = errors.New("password does not meet policy")

	// ErrNoGroups is returned when a create/update would leave a user with
	// zero group memberships. Authorization derives entirely from groups, so
	// a zero-group user can do nothing; the API rejects it as a 400 rather
	// than silently creating an inert account (issue #907).
	ErrNoGroups = errors.New("user must belong to at least one group")

	// ErrLastAdmin is returned when an update or delete would remove the last
	// remaining member of the Administrators group, which would lock everyone
	// out of admin-gated functionality. Mapped to 409 (issue #907).
	ErrLastAdmin = errors.New("cannot remove the last administrator")

	// ErrSelfEscalation is returned when a user attempts to grant themselves
	// a group they are not already a member of without holding the manage-users
	// permission. Mapped to 403 (issue #907).
	ErrSelfEscalation = errors.New("cannot escalate your own group membership")

	// ErrCurrentPasswordIncorrect is returned by UpdateUserProfile when the
	// caller-supplied current password does not match the stored hash. Mapped
	// to 401 at the API layer (the acting user is verifying their own
	// credential, so a precise message is safe -- issue #929).
	ErrCurrentPasswordIncorrect = errors.New("Current password is incorrect")

	// MFA login-gate sentinels — used by the login API handler to map
	// to machine-readable response codes (mfa_required /
	// invalid_mfa_code) so the frontend can branch on the error class
	// without substring-matching the human message. See issue #497.
	ErrMFARequired    = errors.New("mfa_required")
	ErrInvalidMFACode = errors.New("invalid_mfa_code")

	// MFA service-operation sentinels — returned (wrapped via fmt.Errorf
	// "%w") by the MFA lifecycle methods in service_mfa.go so the API
	// handler can map each error class to the right HTTP status code via
	// errors.Is rather than brittle substring matching. See issue #512.
	//
	// ErrMFAInvalidPassword        — wrong current password on setup/disable.
	// ErrMFAInvalidCode            — wrong TOTP or recovery code.
	// ErrMFACodeRequired           — MFA-enabled user supplied no code on disable.
	// ErrMFANoEnrollmentInProgress — MFAEnable called before MFASetup.
	// ErrMFAEnrollmentExpired      — pending enrollment window elapsed.
	// ErrMFANotEnabled             — regenerate/disable called when MFA is off.
	// ErrMFAAuthFailed             — generic opaque auth failure (user not found or
	//                               DB error; maps to 401 to prevent user enumeration).
	//
	// Message strings are intentionally identical to the pre-sentinel
	// fmt.Errorf literals so that existing tests relying on err.Error()
	// substrings continue to pass unchanged. See issue #512.
	ErrMFAInvalidPassword        = errors.New("invalid password")
	ErrMFAInvalidCode            = errors.New("invalid MFA code")
	ErrMFACodeRequired           = errors.New("MFA code or recovery code required")
	ErrMFANoEnrollmentInProgress = errors.New("no MFA enrollment in progress")
	ErrMFAEnrollmentExpired      = errors.New("MFA enrollment expired")
	ErrMFANotEnabled             = errors.New("MFA is not enabled")
	ErrMFAAuthFailed             = errors.New("authentication failed")
)

Validation sentinels — wrapped by service_user.go's validators so the API handler can map each to a precise HTTP status code. Plain fmt.Errorf returns fall through to a generic 500 in internal/api/handler.go's handleRequestError, which hides the real cause from the user (see issue #349).

Callers use errors.Is to detect the category; the wrapped message (when set via fmt.Errorf("%w: %s", ...)) carries the specific user- facing detail (e.g. "invalid role: guest", "password does not meet policy: must be at least 12 characters").

Functions

func DeriveCSRFKey

func DeriveCSRFKey(masterSecret []byte) ([]byte, error)

DeriveCSRFKey derives a stable 32-byte CSRF key from a master secret using HKDF-SHA256 with a fixed domain-separation label.

Production wiring passes the (already stable, deploy-provided) credential encryption key as the master secret, so every process and every Lambda cold-start derives the SAME CSRF key. This is what makes a CSRF token minted by one instance validate on another instance: ValidateCSRFToken recomputes HMAC-SHA256(csrfKey, rawSessionToken), and the csrfKey is now identical across the fleet instead of a per-process random value.

HKDF domain separation (csrfKeyHKDFInfo) ensures the derived CSRF key is cryptographically independent of the master secret, so leaking one does not reveal the other. The master secret must be non-empty.

func DeriveTestCSRFToken

func DeriveTestCSRFToken(rawSessionToken string) string

DeriveTestCSRFToken returns the CSRF token a service configured with TestCSRFKey expects for the given raw session token. It reuses the production derivation (HMAC-SHA256(key, rawSessionToken)) so tests assert the real contract rather than duplicating the crypto.

func IsUnrestrictedAccess

func IsUnrestrictedAccess(allowed []string) bool

IsUnrestrictedAccess returns true if the allowed list grants access to all accounts — either because it's empty (backward-compat default) or contains a "*" wildcard entry. Handlers can use this to short-circuit their filter loops without iterating accounts when access is unrestricted.

WARNING — fail-open default (03-L5): an empty allowed list means "all accounts", not "no accounts". This is a deliberate backward-compatibility default so existing groups without an AllowedAccounts configuration grant full access. New callers that intend to express "no access" must represent that with an explicit sentinel (e.g. a list containing only a non-existent account ID) and must NOT rely on an empty list for the "deny all" case.

func MatchesAccount

func MatchesAccount(allowed []string, accountID, accountName string) bool

MatchesAccount returns true if the allowed list matches an account by its internal ID or display name. Exact string match against either field. The name is optional — pass "" when unavailable; the match then falls back to ID-only. Empty allowed list or a "*" entry matches any account.

func TestCSRFKey

func TestCSRFKey() []byte

TestCSRFKey returns the fixed 32-byte CSRF key shared by test services. Pass it as ServiceConfig.CSRFKey so a service built via NewService derives CSRF tokens deterministically (no ephemeral random key), letting tests in other packages reproduce the expected token with DeriveTestCSRFToken.

Types

type APICreateAPIKeyRequest

type APICreateAPIKeyRequest struct {
	Name        string       `json:"name"`
	Permissions []Permission `json:"permissions,omitempty"`
	ExpiresAt   *time.Time   `json:"expires_at,omitempty"`
}

APICreateAPIKeyRequest represents the API request to create an API key

type APICreateAPIKeyResponse

type APICreateAPIKeyResponse struct {
	APIKey string      `json:"api_key"` // Full key - only returned once
	KeyID  string      `json:"key_id"`
	Info   *APIKeyInfo `json:"info"`
}

APICreateAPIKeyResponse represents the API response for creating an API key

type APICreateGroupRequest

type APICreateGroupRequest struct {
	Name            string          `json:"name"`
	Description     string          `json:"description,omitempty"`
	Permissions     []APIPermission `json:"permissions"`
	AllowedAccounts []string        `json:"allowed_accounts,omitempty"`
}

APICreateGroupRequest is the request type for creating groups via API

type APICreateUserRequest

type APICreateUserRequest struct {
	Email    string   `json:"email"`
	Password string   `json:"password"`
	Groups   []string `json:"groups,omitempty"`
}

APICreateUserRequest is the request type for creating users via API. Groups must be non-empty: authorization is group-membership-only (issue #907).

type APICreateUserResponse

type APICreateUserResponse struct {
	*APIUser
	InviteEmailSent  *bool  `json:"invite_email_sent,omitempty"`
	InviteEmailError string `json:"invite_email_error,omitempty"`
}

APICreateUserResponse is the response type for POST /api/users. It embeds APIUser so existing consumers keep reading the flat {id, email, role, ...} fields and only callers that need the new invite-status information have to look at the extra optional fields.

InviteEmailSent is non-nil only when the request created an invited (passwordless) user. true means the invite email was handed to the configured sender; false means the user row exists but the recipient hasn't been told how to activate it and the admin should re-mail the setup link via Forgot Password.

type APIGroup

type APIGroup struct {
	ID              string          `json:"id"`
	Name            string          `json:"name"`
	Description     string          `json:"description,omitempty"`
	Permissions     []APIPermission `json:"permissions"`
	AllowedAccounts []string        `json:"allowed_accounts"`
	CreatedAt       string          `json:"created_at,omitempty"`
	UpdatedAt       string          `json:"updated_at,omitempty"`
}

APIGroup is the group type for API responses.

AllowedAccounts has NO omitempty for the same reason Groups doesn't on APIUser — the frontend treats it as always present. See issue #350.

type APIKeyInfo

type APIKeyInfo struct {
	ID          string       `json:"id"`
	Name        string       `json:"name"`
	KeyPrefix   string       `json:"key_prefix"`
	Permissions []Permission `json:"permissions,omitempty"`
	ExpiresAt   *time.Time   `json:"expires_at,omitempty"`
	CreatedAt   time.Time    `json:"created_at"`
	LastUsedAt  *time.Time   `json:"last_used_at,omitempty"`
	IsActive    bool         `json:"is_active"`
}

APIKeyInfo represents public API key information (without sensitive data)

type APIListAPIKeysResponse

type APIListAPIKeysResponse struct {
	APIKeys []*APIKeyInfo `json:"api_keys"`
}

APIListAPIKeysResponse represents the API response for listing API keys

type APIPermission

type APIPermission struct {
	Action      string                   `json:"action"`
	Resource    string                   `json:"resource"`
	Constraints *APIPermissionConstraint `json:"constraints,omitempty"`
}

APIPermission is the permission type for API responses

type APIPermissionConstraint

type APIPermissionConstraint struct {
	Accounts  []string `json:"accounts,omitempty"`
	Providers []string `json:"providers,omitempty"`
	Services  []string `json:"services,omitempty"`
	Regions   []string `json:"regions,omitempty"`
	MaxAmount float64  `json:"max_amount,omitempty"`
}

APIPermissionConstraint is the permission constraint type for API responses

type APIUpdateGroupRequest

type APIUpdateGroupRequest struct {
	Name            string          `json:"name,omitempty"`
	Description     string          `json:"description,omitempty"`
	Permissions     []APIPermission `json:"permissions,omitempty"`
	AllowedAccounts []string        `json:"allowed_accounts"`
}

APIUpdateGroupRequest is the request type for updating groups via API. AllowedAccounts has no omitempty: clients must be able to send an explicit empty slice to clear account restrictions. Nil means "not sent".

type APIUpdateUserRequest

type APIUpdateUserRequest struct {
	Email  string   `json:"email,omitempty"`
	Groups []string `json:"groups,omitempty"`
}

APIUpdateUserRequest is the request type for updating users via API.

Groups is decoded from JSON, so the handler cannot use a nil slice to mean "not sent". A non-empty Groups replaces the user's membership; an empty/nil Groups means "leave membership unchanged" (callers that intend to change groups always send at least one, since zero-group users are forbidden).

type APIUser

type APIUser struct {
	ID         string   `json:"id"`
	Email      string   `json:"email"`
	Groups     []string `json:"groups"`
	MFAEnabled bool     `json:"mfa_enabled"`
	CreatedAt  string   `json:"created_at,omitempty"`
	UpdatedAt  string   `json:"updated_at,omitempty"`
	LastLogin  string   `json:"last_login,omitempty"`
}

APIUser is the user type for API responses.

Groups deliberately has NO omitempty: the frontend's TS type (frontend/src/api/types.ts) declares groups as a required string[], and renderers read user.groups.length / iterate user.groups without a guard. Omitting the field on empty slices breaks that contract and crashes the admin users page with "TypeError: Cannot read properties of undefined (reading 'length')" — see issue #350.

type AuthContext

type AuthContext struct {
	User            *User
	Groups          []*Group
	AllowedAccounts []string     // Computed from all groups (union)
	Permissions     []Permission // Computed from group memberships
}

AuthContext represents the complete authorization context for a user It combines group memberships and the permissions computed from them.

func (*AuthContext) CanAccessAccount

func (ctx *AuthContext) CanAccessAccount(accountID, accountName string) bool

CanAccessAccount checks if the user can access a specific account by its ID or display name. Access is derived from the union of the user's groups' AllowedAccounts via MatchesAccount. Administrators-group members carry the "*" wildcard (seeded with allowed_accounts=['*']) and so match any account; a user with no groups has an empty AllowedAccounts and, combined with the permission check at the call site, is denied (fail closed).

func (*AuthContext) HasPermission

func (ctx *AuthContext) HasPermission(action, resource string) bool

HasPermission checks if the auth context has a specific permission. Authorization is derived purely from group-granted permissions: a user who is a member of the Administrators group holds {ActionAdmin, ResourceAll} and therefore passes any check; a user with no groups holds no permissions and is denied everything (fail closed).

The admin:* wildcard is intentionally narrow for the three carved-out money-spending verbs (execute:purchases, approve-any:purchases, retry-any:purchases). Those require explicit membership in a group that grants them directly (e.g. the Purchaser group seeded by migration 000054).

type ChangePasswordRequest

type ChangePasswordRequest struct {
	CurrentPassword string `json:"current_password"`
	NewPassword     string `json:"new_password"`
}

ChangePasswordRequest for users changing their own password

type CreateAPIKeyRequest

type CreateAPIKeyRequest struct {
	Name        string       `json:"name"`
	Permissions []Permission `json:"permissions,omitempty"`
	ExpiresAt   *time.Time   `json:"expires_at,omitempty"`
}

CreateAPIKeyRequest for creating a new user API key

type CreateAPIKeyResponse

type CreateAPIKeyResponse struct {
	APIKey string      `json:"api_key"` // Full key - only returned on creation
	KeyID  string      `json:"key_id"`
	Info   *UserAPIKey `json:"info"`
}

CreateAPIKeyResponse returns the newly created API key (only shown once)

type CreateUserRequest

type CreateUserRequest struct {
	Email    string   `json:"email"`
	Password string   `json:"password"`
	GroupIDs []string `json:"group_ids,omitempty"`
}

CreateUserRequest for admin creating users. GroupIDs must contain at least one group: authorization derives entirely from group membership (issue #907).

type CreateUserResult

type CreateUserResult struct {
	User             *User
	InviteEmailSent  *bool
	InviteEmailError string
}

CreateUserResult bundles the created user with optional invite-email delivery status. InviteEmailSent is nil unless the request triggered an invite (req.Password == ""). When non-nil it reflects whether the invite email actually reached the configured sender — false means the account exists but the recipient has no way to activate it yet and the admin should re-mail the setup link via the Forgot Password flow until a dedicated Resend Invite endpoint exists. InviteEmailError carries the underlying send error in the false case so callers can surface it.

type DBConnection

type DBConnection interface {
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
	Ping(ctx context.Context) error
}

DBConnection defines the interface for database operations needed by PostgresStore

type EmailSenderInterface

type EmailSenderInterface interface {
	SendPasswordResetEmail(ctx context.Context, email, resetURL string) error
	SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error
	SendUserInviteEmail(ctx context.Context, email, setupURL string) error
}

EmailSenderInterface defines the methods required for sending emails

type Group

type Group struct {
	ID              string       `json:"id" dynamodbav:"PK"`
	Name            string       `json:"name" dynamodbav:"Name"`
	Description     string       `json:"description,omitempty" dynamodbav:"Description"`
	Permissions     []Permission `json:"permissions" dynamodbav:"Permissions"`
	AllowedAccounts []string     `json:"allowed_accounts,omitempty" dynamodbav:"AllowedAccounts"`
	// SystemManaged marks groups that are seeded by migrations and
	// should not be renamed or deleted via the API. Only membership
	// can change for system-managed groups.
	SystemManaged bool      `json:"system_managed,omitempty" dynamodbav:"SystemManaged"`
	CreatedAt     time.Time `json:"created_at" dynamodbav:"CreatedAt"`
	UpdatedAt     time.Time `json:"updated_at" dynamodbav:"UpdatedAt"`
	CreatedBy     string    `json:"created_by" dynamodbav:"CreatedBy"`
}

Group represents a permission group

type LoginRequest

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
	MFACode  string `json:"mfa_code,omitempty"`
}

LoginRequest represents a login attempt

type LoginResponse

type LoginResponse struct {
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expires_at"`
	User      *UserInfo `json:"user"`
	CSRFToken string    `json:"csrf_token,omitempty"`
}

LoginResponse is returned after successful login

type MFASetupResult

type MFASetupResult struct {
	Secret          string
	ProvisioningURI string
}

MFASetupResult is the user-facing payload returned by MFASetup. Carries the freshly-generated secret + the otpauth:// URI so the frontend can render a QR code and surface the secret for manual entry. The secret is also persisted server-side as the pending secret, so a stateless client-side carrier (signed token) is not needed.

type MockEmailSender

type MockEmailSender struct {
	mock.Mock
}

MockEmailSender is a mock implementation of the email sender for testing

func (*MockEmailSender) SendPasswordResetEmail

func (m *MockEmailSender) SendPasswordResetEmail(ctx context.Context, email, resetURL string) error

func (*MockEmailSender) SendUserInviteEmail

func (m *MockEmailSender) SendUserInviteEmail(ctx context.Context, email, setupURL string) error

func (*MockEmailSender) SendWelcomeEmail

func (m *MockEmailSender) SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error

type MockStore

type MockStore struct {
	mock.Mock
}

MockStore is a mock implementation of the auth store for testing

func (*MockStore) AdminExists

func (m *MockStore) AdminExists(ctx context.Context) (bool, error)

func (*MockStore) CleanupExpiredSessions

func (m *MockStore) CleanupExpiredSessions(ctx context.Context) error

func (*MockStore) CountGroupMembers

func (m *MockStore) CountGroupMembers(ctx context.Context, groupID string) (int, error)

func (*MockStore) CreateAPIKey

func (m *MockStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error

API Key operations

func (*MockStore) CreateAdminIfNone

func (m *MockStore) CreateAdminIfNone(ctx context.Context, user *User) (bool, error)

func (*MockStore) CreateGroup

func (m *MockStore) CreateGroup(ctx context.Context, group *Group) error

func (*MockStore) CreateSession

func (m *MockStore) CreateSession(ctx context.Context, session *Session) error

func (*MockStore) CreateUser

func (m *MockStore) CreateUser(ctx context.Context, user *User) error

func (*MockStore) DeleteAPIKey

func (m *MockStore) DeleteAPIKey(ctx context.Context, keyID string) error

func (*MockStore) DeleteGroup

func (m *MockStore) DeleteGroup(ctx context.Context, groupID string) error

func (*MockStore) DeleteSession

func (m *MockStore) DeleteSession(ctx context.Context, token string) error

func (*MockStore) DeleteUser

func (m *MockStore) DeleteUser(ctx context.Context, userID string) error

func (*MockStore) DeleteUserSessions

func (m *MockStore) DeleteUserSessions(ctx context.Context, userID string) error

func (*MockStore) GetAPIKeyByHash

func (m *MockStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)

func (*MockStore) GetAPIKeyByID

func (m *MockStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error)

func (*MockStore) GetGroup

func (m *MockStore) GetGroup(ctx context.Context, groupID string) (*Group, error)

func (*MockStore) GetSession

func (m *MockStore) GetSession(ctx context.Context, token string) (*Session, error)

func (*MockStore) GetUserByEmail

func (m *MockStore) GetUserByEmail(ctx context.Context, email string) (*User, error)

func (*MockStore) GetUserByID

func (m *MockStore) GetUserByID(ctx context.Context, userID string) (*User, error)

func (*MockStore) GetUserByResetToken

func (m *MockStore) GetUserByResetToken(ctx context.Context, token string) (*User, error)

func (*MockStore) ListAPIKeysByUser

func (m *MockStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error)

func (*MockStore) ListGroups

func (m *MockStore) ListGroups(ctx context.Context) ([]Group, error)

func (*MockStore) ListUsers

func (m *MockStore) ListUsers(ctx context.Context) ([]User, error)

func (*MockStore) Ping

func (m *MockStore) Ping(ctx context.Context) error

func (*MockStore) UpdateAPIKey

func (m *MockStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error

func (*MockStore) UpdateAPIKeyLastUsed

func (m *MockStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error

func (*MockStore) UpdateGroup

func (m *MockStore) UpdateGroup(ctx context.Context, group *Group) error

func (*MockStore) UpdateUser

func (m *MockStore) UpdateUser(ctx context.Context, user *User) error

type PasswordResetConfirm

type PasswordResetConfirm struct {
	Token       string `json:"token"`
	NewPassword string `json:"new_password"`
}

PasswordResetConfirm completes a password reset

type PasswordResetRequest

type PasswordResetRequest struct {
	Email string `json:"email"`
}

PasswordResetRequest initiates a password reset

type Permission

type Permission struct {
	// Action: view, purchase, configure, admin
	Action string `json:"action" dynamodbav:"Action"`

	// Resource type: recommendations, plans, history, config, users
	Resource string `json:"resource" dynamodbav:"Resource"`

	// Constraints limit the permission to specific contexts
	Constraints *PermissionConstraints `json:"constraints,omitempty" dynamodbav:"Constraints"`
}

Permission defines what actions a group can perform

func DefaultAdminPermissions

func DefaultAdminPermissions() []Permission

DefaultAdminPermissions returns full admin permissions

func DefaultPurchaserPermissions

func DefaultPurchaserPermissions() []Permission

DefaultPurchaserPermissions returns the permissions for the system-managed Purchaser group (issue #923). The three execute/approve-any/retry-any verbs are carved out of the admin:* wildcard; a user must hold them explicitly (via this group or a custom group that includes them) to spend money.

func DefaultReadOnlyPermissions

func DefaultReadOnlyPermissions() []Permission

DefaultReadOnlyPermissions returns read-only permissions

func DefaultUserPermissions

func DefaultUserPermissions() []Permission

DefaultUserPermissions returns standard user permissions

type PermissionConstraints

type PermissionConstraints struct {
	// AccountIDs limits to specific AWS/Azure/GCP accounts
	AccountIDs []string `json:"account_ids,omitempty" dynamodbav:"AccountIDs"`

	// Providers limits to specific cloud providers (aws, azure, gcp)
	Providers []string `json:"providers,omitempty" dynamodbav:"Providers"`

	// Services limits to specific services (ec2, rds, elasticache, etc.)
	Services []string `json:"services,omitempty" dynamodbav:"Services"`

	// Regions limits to specific regions
	Regions []string `json:"regions,omitempty" dynamodbav:"Regions"`

	// MaxPurchaseAmount limits the maximum purchase amount
	MaxPurchaseAmount float64 `json:"max_purchase_amount,omitempty" dynamodbav:"MaxPurchaseAmount"`
}

PermissionConstraints limit permissions to specific accounts, providers, or services

type PostgresStore

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

PostgresStore implements StoreInterface using PostgreSQL

func NewPostgresStore

func NewPostgresStore(db DBConnection) *PostgresStore

NewPostgresStore creates a new PostgreSQL-backed auth store

func (*PostgresStore) AdminExists

func (s *PostgresStore) AdminExists(ctx context.Context) (bool, error)

AdminExists checks if any active Administrators-group member exists. This is the group-membership replacement for the former role = 'admin' check.

func (*PostgresStore) CleanupExpiredSessions

func (s *PostgresStore) CleanupExpiredSessions(ctx context.Context) error

CleanupExpiredSessions deletes expired sessions

func (*PostgresStore) CountGroupMembers

func (s *PostgresStore) CountGroupMembers(ctx context.Context, groupID string) (int, error)

CountGroupMembers returns the number of users whose group_ids contains groupID. Used to enforce last-administrator protection (issue #907).

func (*PostgresStore) CreateAPIKey

func (s *PostgresStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error

CreateAPIKey creates a new API key

func (*PostgresStore) CreateAdminIfNone

func (s *PostgresStore) CreateAdminIfNone(ctx context.Context, user *User) (bool, error)

CreateAdminIfNone atomically inserts user as the first admin in the system. Returns (true, nil) when the insert succeeded; (false, nil) when an admin already existed (TOCTOU race — both callers passed AdminExists, only one wins the insert); (false, ErrEmailInUse) when the email collides with an existing (non-admin) user; (false, err) for any other failure.

The conditional INSERT closes the bootstrap race without the users_one_admin partial unique index (dropped in migration 000050). Postgres guarantees atomicity of the SELECT … WHERE NOT EXISTS … INSERT in a single statement — no advisory lock or transaction is needed.

func (*PostgresStore) CreateGroup

func (s *PostgresStore) CreateGroup(ctx context.Context, group *Group) error

CreateGroup creates a new group

func (*PostgresStore) CreateSession

func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) error

CreateSession creates a new session

func (*PostgresStore) CreateUser

func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error

CreateUser creates a new user

func (*PostgresStore) DeleteAPIKey

func (s *PostgresStore) DeleteAPIKey(ctx context.Context, keyID string) error

DeleteAPIKey deletes an API key

func (*PostgresStore) DeleteGroup

func (s *PostgresStore) DeleteGroup(ctx context.Context, groupID string) error

DeleteGroup deletes a group

func (*PostgresStore) DeleteSession

func (s *PostgresStore) DeleteSession(ctx context.Context, token string) error

DeleteSession deletes a session

func (*PostgresStore) DeleteUser

func (s *PostgresStore) DeleteUser(ctx context.Context, userID string) error

DeleteUser deletes a user

func (*PostgresStore) DeleteUserSessions

func (s *PostgresStore) DeleteUserSessions(ctx context.Context, userID string) error

DeleteUserSessions deletes all sessions for a user

func (*PostgresStore) GetAPIKeyByHash

func (s *PostgresStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)

GetAPIKeyByHash retrieves an API key by hash

func (*PostgresStore) GetAPIKeyByID

func (s *PostgresStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error)

GetAPIKeyByID retrieves an API key by ID

func (*PostgresStore) GetGroup

func (s *PostgresStore) GetGroup(ctx context.Context, groupID string) (*Group, error)

GetGroup retrieves a group by ID

func (*PostgresStore) GetSession

func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, error)

GetSession retrieves a session by token

func (*PostgresStore) GetUserByEmail

func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*User, error)

GetUserByEmail retrieves a user by email

func (*PostgresStore) GetUserByID

func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, error)

GetUserByID retrieves a user by ID

func (*PostgresStore) GetUserByResetToken

func (s *PostgresStore) GetUserByResetToken(ctx context.Context, token string) (*User, error)

GetUserByResetToken retrieves a user by password reset token without filtering on expiry. Both callers (ResetTokenStatus and validateResetToken) perform their own expiry check on the returned row, so the SQL must surface expired rows; otherwise an expired token returns pgx.ErrNoRows and ResetTokenStatus misclassifies it as "used" instead of "expired" (QA bug 11.2).

func (*PostgresStore) ListAPIKeysByUser

func (s *PostgresStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error)

ListAPIKeysByUser lists all API keys for a user

func (*PostgresStore) ListGroups

func (s *PostgresStore) ListGroups(ctx context.Context) ([]Group, error)

ListGroups lists all groups

func (*PostgresStore) ListUsers

func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error)

ListUsers lists all users

func (*PostgresStore) Ping

func (s *PostgresStore) Ping(ctx context.Context) error

Ping checks the database connection health

func (*PostgresStore) UpdateAPIKey

func (s *PostgresStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error

UpdateAPIKey updates an API key

func (*PostgresStore) UpdateAPIKeyLastUsed

func (s *PostgresStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error

UpdateAPIKeyLastUsed atomically updates the last_used_at timestamp for an API key

func (*PostgresStore) UpdateGroup

func (s *PostgresStore) UpdateGroup(ctx context.Context, group *Group) error

UpdateGroup updates an existing group

func (*PostgresStore) UpdateUser

func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error

UpdateUser updates an existing user

type ResetTokenFlow

type ResetTokenFlow string

ResetTokenFlow describes whether the matched token belongs to an invite flow (user had Active = false at issue time, still false now) or a normal password-reset flow. The frontend uses this to swap "Set your password" vs "Reset your password" wording (issue #461).

const (
	// ResetTokenFlowReset is the default flow for active users.
	ResetTokenFlowReset ResetTokenFlow = "reset"
	// ResetTokenFlowInvite is the bootstrap flow for not-yet-active users.
	ResetTokenFlowInvite ResetTokenFlow = "invite"
)

type ResetTokenState

type ResetTokenState string

ResetTokenState describes the runtime state of a password-reset token. One of "valid", "expired", "used". "used" doubles as the fallback for tokens that never existed: the row is wiped on consumption (one-time use), so the store cannot reliably distinguish "consumed" from "never issued". Surfacing both as "used" matches the dominant real-world case (stale link from an old email) and lets the frontend branch on a single state.

const (
	// ResetTokenStateValid means the token matches an issued, unexpired row.
	ResetTokenStateValid ResetTokenState = "valid"
	// ResetTokenStateExpired means the token matches but its expiry has passed.
	ResetTokenStateExpired ResetTokenState = "expired"
	// ResetTokenStateUsed covers both consumed and never-issued tokens.
	ResetTokenStateUsed ResetTokenState = "used"
)

type Scanner

type Scanner interface {
	Scan(dest ...any) error
}

Scanner interface for both Row and Rows

type Service

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

Service handles authentication and authorization

func NewService

func NewService(cfg ServiceConfig) *Service

NewService creates a new auth service

func (*Service) BuildAuthContext

func (s *Service) BuildAuthContext(ctx context.Context, userID string) (*AuthContext, error)

BuildAuthContext builds a complete authorization context for a user. Permissions and allowed accounts are derived purely from the union of the user's group memberships; a user with no groups gets an empty context and is denied everything (fail closed).

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, userID string, req ChangePasswordRequest) error

ChangePassword allows a user to change their password

func (*Service) ChangePasswordAPI

func (s *Service) ChangePasswordAPI(ctx context.Context, userID, currentPassword, newPassword string) error

ChangePasswordAPI changes a user's password via the API

func (*Service) CheckAdminExists

func (s *Service) CheckAdminExists(ctx context.Context) (bool, error)

CheckAdminExists returns whether an admin user exists

func (*Service) CleanupExpiredSessions

func (s *Service) CleanupExpiredSessions(ctx context.Context) error

CleanupExpiredSessions removes expired sessions from the store

func (*Service) ComputeEffectivePermissions

func (s *Service) ComputeEffectivePermissions(ctx context.Context, apiKey *UserAPIKey, user *User) ([]Permission, error)

ComputeEffectivePermissions computes the intersection of API key permissions and user permissions This ensures an API key cannot grant more permissions than the user has.

Administrators-group members carry {admin, *}: with no key-specific permissions their full {admin, *} context is returned, and a scoped admin key's permissions all pass the HasPermission intersection below, so the group-derived path preserves the previous role == admin behaviour without a special case.

func (*Service) ConfirmPasswordReset

func (s *Service) ConfirmPasswordReset(ctx context.Context, req PasswordResetConfirm) error

ConfirmPasswordReset completes a password reset

func (*Service) CreateAPIKey

func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, permissions []Permission, expiresAt *time.Time) (string, *UserAPIKey, error)

CreateAPIKey creates a new user API key with scoped permissions Returns the full API key (shown only once), key info, and error

func (*Service) CreateAPIKeyAPI

func (s *Service) CreateAPIKeyAPI(ctx context.Context, userID string, req any) (any, error)

CreateAPIKeyAPI creates a new API key and returns API-friendly response

func (*Service) CreateGroup

func (s *Service) CreateGroup(ctx context.Context, group *Group, createdBy string) error

CreateGroup creates a new permission group

func (*Service) CreateGroupAPI

func (s *Service) CreateGroupAPI(ctx context.Context, reqInterface any) (any, error)

CreateGroupAPI creates a new group via the API

func (*Service) CreateUser

func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*CreateUserResult, error)

CreateUser creates a new user (admin only).

If req.Password is empty the user is created in the "invited" state: inactive, with an unguessable placeholder password hash that no client input can match, and a setup token mailed to req.Email. The recipient activates the account and chooses their own password by following the link, which lands on the existing ConfirmPasswordReset flow.

On an invite request the returned CreateUserResult always carries a non-nil InviteEmailSent so callers can distinguish "delivered" from "stored, but the user is currently unreachable". An invite-email send failure is reported via the result (not as an error) so the user row is still surfaced and the admin can react instead of seeing a 5xx.

func (*Service) CreateUserAPI

func (s *Service) CreateUserAPI(ctx context.Context, reqInterface any) (any, error)

CreateUserAPI creates a new user via the API

func (*Service) DeleteAPIKey

func (s *Service) DeleteAPIKey(ctx context.Context, userID, keyID string) error

DeleteAPIKey permanently deletes an API key

func (*Service) DeleteAPIKeyAPI

func (s *Service) DeleteAPIKeyAPI(ctx context.Context, userID, keyID string) error

DeleteAPIKeyAPI deletes an API key

func (*Service) DeleteGroup

func (s *Service) DeleteGroup(ctx context.Context, groupID string) error

DeleteGroup removes a permission group

func (*Service) DeleteUser

func (s *Service) DeleteUser(ctx context.Context, userID string) error

DeleteUser removes a user (requires manage-users permission). Refuses to delete the last remaining Administrators-group member so the deployment can never be locked out of admin functionality (issue #907).

func (*Service) GetAPIKeyByHash

func (s *Service) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)

GetAPIKeyByHash retrieves an API key by its hash (for authentication)

func (*Service) GetAuthContext

func (s *Service) GetAuthContext(ctx context.Context, userID string) (*AuthContext, error)

GetAuthContext is an alias for BuildAuthContext for backward compatibility

func (*Service) GetGroup

func (s *Service) GetGroup(ctx context.Context, groupID string) (*Group, error)

GetGroup returns a group by ID

func (*Service) GetGroupAPI

func (s *Service) GetGroupAPI(ctx context.Context, groupID string) (any, error)

GetGroupAPI returns a group by ID via the API

func (*Service) GetUser

func (s *Service) GetUser(ctx context.Context, userID string) (*User, error)

GetUser returns user info. Returns (nil, pgx.ErrNoRows) if the user does not exist.

func (*Service) GetUserPermissions

func (s *Service) GetUserPermissions(ctx context.Context, userID string) ([]Permission, error)

GetUserPermissions returns all permissions for a user. Authorization is derived purely from the union of the user's groups' permissions: there is no role-based fallback. A user with no groups therefore has no permissions and is denied everything (fail closed).

Any transient store error fetching a group is propagated immediately so callers fail closed with an error rather than silently receiving a partial permission set. A nil group (the store returns nil, nil for a deleted/ missing group) is skipped without error.

func (*Service) GetUserPermissionsAPI

func (s *Service) GetUserPermissionsAPI(ctx context.Context, userID string) (any, error)

GetUserPermissionsAPI returns the effective permission set for a user via the API. Calls GetUserPermissions (the same union path the server enforces with) and converts each Permission to an APIPermission for the wire format. The handler asserts the return value to []APIPermission.

func (*Service) HasPermission

func (s *Service) HasPermission(ctx context.Context, userID, action, resource string, constraints *PermissionConstraints) (bool, error)

HasPermission checks if a user has a specific permission

func (*Service) HasPermissionAPI

func (s *Service) HasPermissionAPI(ctx context.Context, userID, action, resource string) (bool, error)

HasPermissionAPI checks if a user has a specific permission via the API

func (*Service) ListGroups

func (s *Service) ListGroups(ctx context.Context) ([]Group, error)

ListGroups returns all groups

func (*Service) ListGroupsAPI

func (s *Service) ListGroupsAPI(ctx context.Context) (any, error)

ListGroupsAPI returns all groups via the API

func (*Service) ListUserAPIKeys

func (s *Service) ListUserAPIKeys(ctx context.Context, userID string) ([]*UserAPIKey, error)

ListUserAPIKeys retrieves all API keys for a user

func (*Service) ListUserAPIKeysAPI

func (s *Service) ListUserAPIKeysAPI(ctx context.Context, userID string) (any, error)

ListUserAPIKeysAPI lists all API keys for a user and returns API-friendly response

func (*Service) ListUsers

func (s *Service) ListUsers(ctx context.Context) ([]User, error)

ListUsers returns all users (admin only)

func (*Service) ListUsersAPI

func (s *Service) ListUsersAPI(ctx context.Context) (any, error)

ListUsersAPI returns all users via the API

func (*Service) Login

func (s *Service) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)

Login authenticates a user and creates a session

func (*Service) Logout

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

Logout invalidates a session

func (*Service) MFADisable

func (s *Service) MFADisable(ctx context.Context, userID, password, codeOrRecovery string) error

MFADisable turns off MFA for a user. Requires both the current password AND a fresh proof-of-possession (either a TOTP code or an unused recovery code). Defence-in-depth: a stolen session alone shouldn't disable MFA, and a stolen authenticator alone shouldn't either.

Idempotent: calling on an already-disabled user with the right password is a no-op (returns nil) so the UI can drive the button without an extra state-query round trip.

func (*Service) MFADisableAPI

func (s *Service) MFADisableAPI(ctx context.Context, userID, password, codeOrRecovery string) error

MFADisableAPI turns off MFA via the API.

func (*Service) MFAEnable

func (s *Service) MFAEnable(ctx context.Context, userID, code string) ([]string, error)

MFAEnable finalizes an MFA enrollment. Validates the supplied TOTP code against the pending secret, then promotes the pending secret to the active MFASecret and flips MFAEnabled = true. Generates + returns plaintext recovery codes; stores bcrypt hashes server-side. The plaintext is returned exactly once.

Errors out (without changing state) when:

  • no pending enrollment exists
  • the pending secret has expired
  • the supplied code doesn't match the pending secret

Idempotent in the sense that a second enable on an already-enabled user with no pending secret returns "no MFA enrollment in progress", not a silent re-enable with new recovery codes.

func (*Service) MFAEnableAPI

func (s *Service) MFAEnableAPI(ctx context.Context, userID, code string) ([]string, error)

MFAEnableAPI finalizes an enrollment via the API.

func (*Service) MFARegenerateRecoveryCodes

func (s *Service) MFARegenerateRecoveryCodes(ctx context.Context, userID, code string) ([]string, error)

MFARegenerateRecoveryCodes replaces all stored recovery codes with a fresh batch. Requires a fresh TOTP code (NOT a recovery code — because the user could otherwise drain the pool one code at a time and never see the regenerated batch). Returns the plaintext codes exactly once.

func (*Service) MFARegenerateRecoveryCodesAPI

func (s *Service) MFARegenerateRecoveryCodesAPI(ctx context.Context, userID, code string) ([]string, error)

MFARegenerateRecoveryCodesAPI replaces stored recovery codes via the API.

func (*Service) MFASetup

func (s *Service) MFASetup(ctx context.Context, userID, password string) (*MFASetupResult, error)

MFASetup begins an MFA enrollment for a user. The caller must re-verify the user's password (defence-in-depth against a session token being lifted from another tab). Returns the freshly-generated secret + provisioning URI; persists the secret in the user's pending fields with a short expiry. Does NOT flip MFAEnabled — that happens in MFAEnable after the user proves they have the secret loaded in their authenticator.

Safe to call repeatedly: each call overwrites the previous pending secret and resets the expiry. An abandoned enrollment expires harmlessly because the active MFASecret + MFAEnabled fields are untouched.

func (*Service) MFASetupAPI

func (s *Service) MFASetupAPI(ctx context.Context, userID, password string) (string, string, error)

MFASetupAPI starts an MFA enrollment via the API. Returns the freshly-generated secret + provisioning URI (the otpauth:// URI the frontend renders as a QR code). Wraps MFASetup; thin shim exists so the api package can refer to a stable signature without importing the auth package's internal MFASetupResult type.

func (*Service) Ping

func (s *Service) Ping(ctx context.Context) error

Ping checks the health of the auth store database connection

func (*Service) RequestPasswordReset

func (s *Service) RequestPasswordReset(ctx context.Context, email string) error

RequestPasswordReset initiates a password reset

func (*Service) ResetTokenStatus

func (s *Service) ResetTokenStatus(ctx context.Context, token string) (ResetTokenState, ResetTokenFlow, error)

ResetTokenStatus returns the state of a reset token without consuming it. The frontend calls this before rendering the reset-password form so it can show an "expired" or "already used" view instead of a form the user can never submit (issues #460, #461). For "used" / never- issued, flow defaults to "reset" since there is no user to inspect.

func (*Service) RevokeAPIKey

func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error

RevokeAPIKey deactivates an API key (soft delete)

func (*Service) RevokeAPIKeyAPI

func (s *Service) RevokeAPIKeyAPI(ctx context.Context, userID, keyID string) error

RevokeAPIKeyAPI revokes an API key

func (*Service) SetupAdmin

func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*LoginResponse, error)

SetupAdmin creates the first admin user using API key authentication. The bootstrap is race-safe in two layers: an upfront AdminExists() check (common case — fast path, no insert when an admin already exists) and an atomic CreateAdminIfNone() conditional insert (closes the TOCTOU window where two concurrent bootstrap callers both passed the existence check).

func (*Service) UpdateGroup

func (s *Service) UpdateGroup(ctx context.Context, group *Group) error

UpdateGroup updates a permission group

func (*Service) UpdateGroupAPI

func (s *Service) UpdateGroupAPI(ctx context.Context, groupID string, reqInterface any) (any, error)

UpdateGroupAPI updates a group via the API

func (*Service) UpdateLastUsed

func (s *Service) UpdateLastUsed(ctx context.Context, keyID string) error

UpdateLastUsed updates the last used timestamp for an API key atomically

func (*Service) UpdateUser

func (s *Service) UpdateUser(ctx context.Context, actorUserID, userID string, req UpdateUserRequest) (*User, error)

UpdateUser updates user details (requires manage-users permission).

actorUserID is the authenticated user performing the change (from the session, never client-supplied). It is used to enforce the self-escalation guard: a user may not add a group they are not already a member of unless they hold the manage-users permission. Pass "" for trusted internal callers (e.g. the stateless admin API key) that have already been authorised.

func (*Service) UpdateUserAPI

func (s *Service) UpdateUserAPI(ctx context.Context, actorUserID, userID string, reqInterface any) (any, error)

UpdateUserAPI updates a user via the API. actorUserID is the authenticated caller performing the change (from the session, never the request body); it is used by the service layer to enforce the self-escalation guard (#907).

func (*Service) UpdateUserProfile

func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email string, currentPassword string, newPassword string) error

UpdateUserProfile allows a user to update their own email and password

func (*Service) UserHasAdminCapability

func (s *Service) UserHasAdminCapability(ctx context.Context, userID string) (bool, error)

UserHasAdminCapability reports whether the user's effective (group-derived) permissions include the full-access {admin, *} capability, i.e. the user is a member of the Administrators group (or any group granted equivalent permission). This is the group-membership replacement for the old role == "admin" short-circuit. Fail closed: any lookup error returns (false, err) and callers must deny.

func (*Service) ValidateCSRFToken

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

ValidateCSRFToken validates the CSRF token for a session.

The expected CSRF token is derived as HMAC-SHA256(csrfKey, rawSessionToken), matching the token produced by createSession. Validation never reads the stored csrf_token column; it recomputes the MAC from the raw session token so a database read (SQLi, backup, replica) cannot yield a usable CSRF token.

func (*Service) ValidateSession

func (s *Service) ValidateSession(ctx context.Context, token string) (*Session, error)

ValidateSession checks if a session is valid and returns user info

func (*Service) ValidateUserAPIKey

func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserAPIKey, *User, error)

ValidateUserAPIKey validates an API key and returns the key info and associated user

func (*Service) ValidateUserAPIKeyAPI

func (s *Service) ValidateUserAPIKeyAPI(ctx context.Context, apiKey string) (*UserAPIKey, *User, error)

ValidateUserAPIKeyAPI validates a user API key and returns the key info and user This is the API-facing wrapper for ValidateUserAPIKey

type ServiceConfig

type ServiceConfig struct {
	Store            StoreInterface
	EmailSender      EmailSenderInterface
	SessionDuration  time.Duration
	DashboardURL     string
	OnPasswordChange func(ctx context.Context, userID, newPassword string)
	// CSRFKey is the server-side secret used to derive CSRF tokens as
	// HMAC-SHA256(CSRFKey, rawSessionToken). Must be 32 bytes for
	// 256-bit security. When empty, NewService generates a random key and
	// logs a warning; all existing sessions will require re-login on restart.
	CSRFKey []byte
}

ServiceConfig holds configuration for the auth service

type Session

type Session struct {
	Token     string    `json:"token" dynamodbav:"PK"`
	UserID    string    `json:"user_id" dynamodbav:"UserID"`
	Email     string    `json:"email" dynamodbav:"Email"`
	ExpiresAt time.Time `json:"expires_at" dynamodbav:"ExpiresAt"`
	CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"`
	UserAgent string    `json:"user_agent,omitempty" dynamodbav:"UserAgent"`
	IPAddress string    `json:"ip_address,omitempty" dynamodbav:"IPAddress"`
	CSRFToken string    `json:"csrf_token,omitempty" dynamodbav:"CSRFToken"`
}

Session represents an active user session

type SetupAdminRequest

type SetupAdminRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

SetupAdminRequest for first-time admin setup with API key

type StoreInterface

type StoreInterface interface {
	// User operations
	GetUserByID(ctx context.Context, userID string) (*User, error)
	GetUserByEmail(ctx context.Context, email string) (*User, error)
	CreateUser(ctx context.Context, user *User) error
	UpdateUser(ctx context.Context, user *User) error
	DeleteUser(ctx context.Context, userID string) error
	ListUsers(ctx context.Context) ([]User, error)
	GetUserByResetToken(ctx context.Context, token string) (*User, error)
	AdminExists(ctx context.Context) (bool, error)
	// CreateAdminIfNone atomically inserts user as the first admin in the
	// system. Returns (true, nil) on success, (false, nil) when an admin
	// already existed (TOCTOU race winner gets false), (false, ErrEmailInUse)
	// when the email collides with an existing non-admin user, or
	// (false, err) for any other failure. Used by SetupAdmin to close the
	// bootstrap race without relying on the users_one_admin partial unique
	// index (dropped in migration 000050).
	CreateAdminIfNone(ctx context.Context, user *User) (bool, error)

	// Group operations
	GetGroup(ctx context.Context, groupID string) (*Group, error)
	CreateGroup(ctx context.Context, group *Group) error
	UpdateGroup(ctx context.Context, group *Group) error
	DeleteGroup(ctx context.Context, groupID string) error
	ListGroups(ctx context.Context) ([]Group, error)
	// CountGroupMembers returns the number of users whose group_ids array
	// contains groupID. Used to enforce the last-administrator protection
	// (issue #907) and any future per-group membership invariants.
	CountGroupMembers(ctx context.Context, groupID string) (int, error)

	// Session operations
	CreateSession(ctx context.Context, session *Session) error
	GetSession(ctx context.Context, token string) (*Session, error)
	DeleteSession(ctx context.Context, token string) error
	DeleteUserSessions(ctx context.Context, userID string) error
	CleanupExpiredSessions(ctx context.Context) error

	// API Key operations
	CreateAPIKey(ctx context.Context, key *UserAPIKey) error
	GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error)
	GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)
	ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error)
	UpdateAPIKey(ctx context.Context, key *UserAPIKey) error
	UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error
	DeleteAPIKey(ctx context.Context, keyID string) error

	// Health check
	Ping(ctx context.Context) error
}

StoreInterface defines the methods required for auth storage

type UpdateUserRequest

type UpdateUserRequest struct {
	Email    *string  `json:"email,omitempty"`
	GroupIDs []string `json:"group_ids,omitempty"`
	Active   *bool    `json:"active,omitempty"`
}

UpdateUserRequest for updating user details.

Email is a pointer so callers can distinguish "not sending email" (nil) from "explicitly setting email to a new value". This matters because the service layer applies email changes via updateUserEmail, which performs format validation and uniqueness checks that must NOT run on no-op updates that only touch groups/active.

GroupIDs is nil when the caller is not changing group membership; a non-nil (including empty) slice replaces the membership and must be non-empty.

type User

type User struct {
	ID                  string     `json:"id" dynamodbav:"PK"`
	Email               string     `json:"email" dynamodbav:"Email"`
	PasswordHash        string     `json:"-" dynamodbav:"PasswordHash"`
	Salt                string     `json:"-" dynamodbav:"Salt"`
	GroupIDs            []string   `json:"group_ids,omitempty" dynamodbav:"GroupIDs"`
	CreatedAt           time.Time  `json:"created_at" dynamodbav:"CreatedAt"`
	UpdatedAt           time.Time  `json:"updated_at" dynamodbav:"UpdatedAt"`
	LastLoginAt         *time.Time `json:"last_login_at,omitempty" dynamodbav:"LastLoginAt"`
	PasswordResetToken  string     `json:"-" dynamodbav:"PasswordResetToken,omitempty"`
	PasswordResetExpiry *time.Time `json:"-" dynamodbav:"PasswordResetExpiry,omitempty"`
	Active              bool       `json:"active" dynamodbav:"Active"`
	MFAEnabled          bool       `json:"mfa_enabled" dynamodbav:"MFAEnabled"`
	MFASecret           string     `json:"-" dynamodbav:"MFASecret,omitempty"`
	// MFA enrollment carrier fields (issue #497). Populated by
	// MFASetup and consumed by MFAEnable; both cleared on successful
	// enable / disable. Persisting the pending secret here (instead
	// of in a signed token returned to the client) keeps the wire
	// shape simple and avoids introducing a new HMAC signing key.
	// An abandoned enrollment expires harmlessly because the active
	// MFASecret + MFAEnabled fields stay untouched until enable
	// succeeds.
	MFAPendingSecret          string     `json:"-" dynamodbav:"MFAPendingSecret,omitempty"`
	MFAPendingSecretExpiresAt *time.Time `json:"-" dynamodbav:"MFAPendingSecretExpiresAt,omitempty"`
	// MFARecoveryCodes holds bcrypt hashes of single-use recovery
	// codes generated at enable / regenerate time. The matching hash
	// is removed from the slice when consumed during login or disable.
	MFARecoveryCodes []string `json:"-" dynamodbav:"MFARecoveryCodes,omitempty"`
	// Account lockout fields for brute-force protection
	FailedLoginAttempts int        `json:"-" dynamodbav:"FailedLoginAttempts,omitempty"`
	LockedUntil         *time.Time `json:"-" dynamodbav:"LockedUntil,omitempty"`
	// Password history for preventing reuse (stores up to 5 previous password hashes)
	PasswordHistory []string `json:"-" dynamodbav:"PasswordHistory,omitempty"`
}

User represents a user account

type UserAPIKey

type UserAPIKey struct {
	ID          string       `json:"id" dynamodbav:"PK"`                             // UUID string
	UserID      string       `json:"user_id" dynamodbav:"UserID"`                    // User who owns this key
	Name        string       `json:"name" dynamodbav:"Name"`                         // Human-readable name
	KeyPrefix   string       `json:"key_prefix" dynamodbav:"KeyPrefix"`              // First 8 chars for display
	KeyHash     string       `json:"-" dynamodbav:"KeyHash"`                         // SHA-256 hash of the full key
	Permissions []Permission `json:"permissions,omitempty" dynamodbav:"Permissions"` // Scoped permissions
	ExpiresAt   *time.Time   `json:"expires_at,omitempty" dynamodbav:"ExpiresAt"`
	CreatedAt   time.Time    `json:"created_at" dynamodbav:"CreatedAt"`
	LastUsedAt  *time.Time   `json:"last_used_at,omitempty" dynamodbav:"LastUsedAt"`
	IsActive    bool         `json:"is_active" dynamodbav:"IsActive"`
}

UserAPIKey represents a personal API key for a user with scoped permissions

type UserInfo

type UserInfo struct {
	ID         string   `json:"id"`
	Email      string   `json:"email"`
	Groups     []string `json:"groups,omitempty"`
	MFAEnabled bool     `json:"mfa_enabled"`
}

UserInfo is the public user info returned to clients

Jump to

Keyboard shortcuts

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