Documentation
¶
Overview ¶
Package auth provides user authentication and authorization.
Index ¶
- Constants
- Variables
- func DeriveCSRFKey(masterSecret []byte) ([]byte, error)
- func DeriveTestCSRFToken(rawSessionToken string) string
- func IsUnrestrictedAccess(allowed []string) bool
- func MatchesAccount(allowed []string, accountID, accountName string) bool
- func TestCSRFKey() []byte
- type APICreateAPIKeyRequest
- type APICreateAPIKeyResponse
- type APICreateGroupRequest
- type APICreateUserRequest
- type APICreateUserResponse
- type APIGroup
- type APIKeyInfo
- type APIListAPIKeysResponse
- type APIPermission
- type APIPermissionConstraint
- type APIUpdateGroupRequest
- type APIUpdateUserRequest
- type APIUser
- type AuthContext
- type ChangePasswordRequest
- type CreateAPIKeyRequest
- type CreateAPIKeyResponse
- type CreateUserRequest
- type CreateUserResult
- type DBConnection
- type EmailSenderInterface
- type Group
- type LoginRequest
- type LoginResponse
- type MFASetupResult
- type MockEmailSender
- func (m *MockEmailSender) SendPasswordResetEmail(ctx context.Context, email, resetURL string) error
- func (m *MockEmailSender) SendUserInviteEmail(ctx context.Context, email, setupURL string) error
- func (m *MockEmailSender) SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error
- type MockStore
- func (m *MockStore) AdminExists(ctx context.Context) (bool, error)
- func (m *MockStore) CleanupExpiredSessions(ctx context.Context) error
- func (m *MockStore) CountGroupMembers(ctx context.Context, groupID string) (int, error)
- func (m *MockStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error
- func (m *MockStore) CreateAdminIfNone(ctx context.Context, user *User) (bool, error)
- func (m *MockStore) CreateGroup(ctx context.Context, group *Group) error
- func (m *MockStore) CreateSession(ctx context.Context, session *Session) error
- func (m *MockStore) CreateUser(ctx context.Context, user *User) error
- func (m *MockStore) DeleteAPIKey(ctx context.Context, keyID string) error
- func (m *MockStore) DeleteGroup(ctx context.Context, groupID string) error
- func (m *MockStore) DeleteSession(ctx context.Context, token string) error
- func (m *MockStore) DeleteUser(ctx context.Context, userID string) error
- func (m *MockStore) DeleteUserSessions(ctx context.Context, userID string) error
- func (m *MockStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)
- func (m *MockStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error)
- func (m *MockStore) GetGroup(ctx context.Context, groupID string) (*Group, error)
- func (m *MockStore) GetSession(ctx context.Context, token string) (*Session, error)
- func (m *MockStore) GetUserByEmail(ctx context.Context, email string) (*User, error)
- func (m *MockStore) GetUserByID(ctx context.Context, userID string) (*User, error)
- func (m *MockStore) GetUserByResetToken(ctx context.Context, token string) (*User, error)
- func (m *MockStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error)
- func (m *MockStore) ListGroups(ctx context.Context) ([]Group, error)
- func (m *MockStore) ListUsers(ctx context.Context) ([]User, error)
- func (m *MockStore) Ping(ctx context.Context) error
- func (m *MockStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error
- func (m *MockStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error
- func (m *MockStore) UpdateGroup(ctx context.Context, group *Group) error
- func (m *MockStore) UpdateUser(ctx context.Context, user *User) error
- type PasswordResetConfirm
- type PasswordResetRequest
- type Permission
- type PermissionConstraints
- type PostgresStore
- func (s *PostgresStore) AdminExists(ctx context.Context) (bool, error)
- func (s *PostgresStore) CleanupExpiredSessions(ctx context.Context) error
- func (s *PostgresStore) CountGroupMembers(ctx context.Context, groupID string) (int, error)
- func (s *PostgresStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error
- func (s *PostgresStore) CreateAdminIfNone(ctx context.Context, user *User) (bool, error)
- func (s *PostgresStore) CreateGroup(ctx context.Context, group *Group) error
- func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) error
- func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error
- func (s *PostgresStore) DeleteAPIKey(ctx context.Context, keyID string) error
- func (s *PostgresStore) DeleteGroup(ctx context.Context, groupID string) error
- func (s *PostgresStore) DeleteSession(ctx context.Context, token string) error
- func (s *PostgresStore) DeleteUser(ctx context.Context, userID string) error
- func (s *PostgresStore) DeleteUserSessions(ctx context.Context, userID string) error
- func (s *PostgresStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)
- func (s *PostgresStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error)
- func (s *PostgresStore) GetGroup(ctx context.Context, groupID string) (*Group, error)
- func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, error)
- func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*User, error)
- func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, error)
- func (s *PostgresStore) GetUserByResetToken(ctx context.Context, token string) (*User, error)
- func (s *PostgresStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error)
- func (s *PostgresStore) ListGroups(ctx context.Context) ([]Group, error)
- func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error)
- func (s *PostgresStore) Ping(ctx context.Context) error
- func (s *PostgresStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error
- func (s *PostgresStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error
- func (s *PostgresStore) UpdateGroup(ctx context.Context, group *Group) error
- func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error
- type ResetTokenFlow
- type ResetTokenState
- type Scanner
- type Service
- func (s *Service) BuildAuthContext(ctx context.Context, userID string) (*AuthContext, error)
- func (s *Service) ChangePassword(ctx context.Context, userID string, req ChangePasswordRequest) error
- func (s *Service) ChangePasswordAPI(ctx context.Context, userID, currentPassword, newPassword string) error
- func (s *Service) CheckAdminExists(ctx context.Context) (bool, error)
- func (s *Service) CleanupExpiredSessions(ctx context.Context) error
- func (s *Service) ComputeEffectivePermissions(ctx context.Context, apiKey *UserAPIKey, user *User) ([]Permission, error)
- func (s *Service) ConfirmPasswordReset(ctx context.Context, req PasswordResetConfirm) error
- func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, permissions []Permission, ...) (string, *UserAPIKey, error)
- func (s *Service) CreateAPIKeyAPI(ctx context.Context, userID string, req any) (any, error)
- func (s *Service) CreateGroup(ctx context.Context, group *Group, createdBy string) error
- func (s *Service) CreateGroupAPI(ctx context.Context, reqInterface any) (any, error)
- func (s *Service) CreateUser(ctx context.Context, req CreateUserRequest) (*CreateUserResult, error)
- func (s *Service) CreateUserAPI(ctx context.Context, reqInterface any) (any, error)
- func (s *Service) DeleteAPIKey(ctx context.Context, userID, keyID string) error
- func (s *Service) DeleteAPIKeyAPI(ctx context.Context, userID, keyID string) error
- func (s *Service) DeleteGroup(ctx context.Context, groupID string) error
- func (s *Service) DeleteUser(ctx context.Context, userID string) error
- func (s *Service) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error)
- func (s *Service) GetAuthContext(ctx context.Context, userID string) (*AuthContext, error)
- func (s *Service) GetGroup(ctx context.Context, groupID string) (*Group, error)
- func (s *Service) GetGroupAPI(ctx context.Context, groupID string) (any, error)
- func (s *Service) GetUser(ctx context.Context, userID string) (*User, error)
- func (s *Service) GetUserPermissions(ctx context.Context, userID string) ([]Permission, error)
- func (s *Service) GetUserPermissionsAPI(ctx context.Context, userID string) (any, error)
- func (s *Service) HasPermission(ctx context.Context, userID, action, resource string, ...) (bool, error)
- func (s *Service) HasPermissionAPI(ctx context.Context, userID, action, resource string) (bool, error)
- func (s *Service) ListGroups(ctx context.Context) ([]Group, error)
- func (s *Service) ListGroupsAPI(ctx context.Context) (any, error)
- func (s *Service) ListUserAPIKeys(ctx context.Context, userID string) ([]*UserAPIKey, error)
- func (s *Service) ListUserAPIKeysAPI(ctx context.Context, userID string) (any, error)
- func (s *Service) ListUsers(ctx context.Context) ([]User, error)
- func (s *Service) ListUsersAPI(ctx context.Context) (any, error)
- func (s *Service) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)
- func (s *Service) Logout(ctx context.Context, token string) error
- func (s *Service) MFADisable(ctx context.Context, userID, password, codeOrRecovery string) error
- func (s *Service) MFADisableAPI(ctx context.Context, userID, password, codeOrRecovery string) error
- func (s *Service) MFAEnable(ctx context.Context, userID, code string) ([]string, error)
- func (s *Service) MFAEnableAPI(ctx context.Context, userID, code string) ([]string, error)
- func (s *Service) MFARegenerateRecoveryCodes(ctx context.Context, userID, code string) ([]string, error)
- func (s *Service) MFARegenerateRecoveryCodesAPI(ctx context.Context, userID, code string) ([]string, error)
- func (s *Service) MFASetup(ctx context.Context, userID, password string) (*MFASetupResult, error)
- func (s *Service) MFASetupAPI(ctx context.Context, userID, password string) (string, string, error)
- func (s *Service) Ping(ctx context.Context) error
- func (s *Service) RequestPasswordReset(ctx context.Context, email string) error
- func (s *Service) ResetTokenStatus(ctx context.Context, token string) (ResetTokenState, ResetTokenFlow, error)
- func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error
- func (s *Service) RevokeAPIKeyAPI(ctx context.Context, userID, keyID string) error
- func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*LoginResponse, error)
- func (s *Service) UpdateGroup(ctx context.Context, group *Group) error
- func (s *Service) UpdateGroupAPI(ctx context.Context, groupID string, reqInterface any) (any, error)
- func (s *Service) UpdateLastUsed(ctx context.Context, keyID string) error
- func (s *Service) UpdateUser(ctx context.Context, actorUserID, userID string, req UpdateUserRequest) (*User, error)
- func (s *Service) UpdateUserAPI(ctx context.Context, actorUserID, userID string, reqInterface any) (any, error)
- func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email string, currentPassword string, ...) error
- func (s *Service) UserHasAdminCapability(ctx context.Context, userID string) (bool, error)
- func (s *Service) ValidateCSRFToken(ctx context.Context, sessionToken, csrfToken string) error
- func (s *Service) ValidateSession(ctx context.Context, token string) (*Session, error)
- func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserAPIKey, *User, error)
- func (s *Service) ValidateUserAPIKeyAPI(ctx context.Context, apiKey string) (*UserAPIKey, *User, error)
- type ServiceConfig
- type Session
- type SetupAdminRequest
- type StoreInterface
- type UpdateUserRequest
- type User
- type UserAPIKey
- type UserInfo
Constants ¶
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
const ( RoleAdmin = "admin" RoleUser = "user" RoleReadOnly = "readonly" )
Predefined roles
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
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
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.
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).
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
MockStore is a mock implementation of the auth store for testing
func (*MockStore) CleanupExpiredSessions ¶
func (*MockStore) CountGroupMembers ¶
func (*MockStore) CreateAPIKey ¶
func (m *MockStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error
API Key operations
func (*MockStore) CreateAdminIfNone ¶
func (*MockStore) CreateGroup ¶
func (*MockStore) CreateSession ¶
func (*MockStore) CreateUser ¶
func (*MockStore) DeleteAPIKey ¶
func (*MockStore) DeleteGroup ¶
func (*MockStore) DeleteSession ¶
func (*MockStore) DeleteUser ¶
func (*MockStore) DeleteUserSessions ¶
func (*MockStore) GetAPIKeyByHash ¶
func (*MockStore) GetAPIKeyByID ¶
func (*MockStore) GetSession ¶
func (*MockStore) GetUserByEmail ¶
func (*MockStore) GetUserByID ¶
func (*MockStore) GetUserByResetToken ¶
func (*MockStore) ListAPIKeysByUser ¶
func (*MockStore) UpdateAPIKey ¶
func (m *MockStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error
func (*MockStore) UpdateAPIKeyLastUsed ¶
func (*MockStore) UpdateGroup ¶
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 ¶
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 ¶
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) GetSession ¶
GetSession retrieves a session by token
func (*PostgresStore) GetUserByEmail ¶
GetUserByEmail retrieves a user by email
func (*PostgresStore) GetUserByID ¶
GetUserByID retrieves a user by ID
func (*PostgresStore) GetUserByResetToken ¶
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 Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service handles authentication and authorization
func (*Service) BuildAuthContext ¶
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 ¶
CheckAdminExists returns whether an admin user exists
func (*Service) CleanupExpiredSessions ¶
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 ¶
CreateAPIKeyAPI creates a new API key and returns API-friendly response
func (*Service) CreateGroup ¶
CreateGroup creates a new permission group
func (*Service) CreateGroupAPI ¶
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 ¶
CreateUserAPI creates a new user via the API
func (*Service) DeleteAPIKey ¶
DeleteAPIKey permanently deletes an API key
func (*Service) DeleteAPIKeyAPI ¶
DeleteAPIKeyAPI deletes an API key
func (*Service) DeleteGroup ¶
DeleteGroup removes a permission group
func (*Service) DeleteUser ¶
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 ¶
GetAPIKeyByHash retrieves an API key by its hash (for authentication)
func (*Service) GetAuthContext ¶
GetAuthContext is an alias for BuildAuthContext for backward compatibility
func (*Service) GetGroupAPI ¶
GetGroupAPI returns a group by ID via the API
func (*Service) GetUser ¶
GetUser returns user info. Returns (nil, pgx.ErrNoRows) if the user does not exist.
func (*Service) GetUserPermissions ¶
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 ¶
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 ¶
ListGroups returns all groups
func (*Service) ListGroupsAPI ¶
ListGroupsAPI returns all groups via the API
func (*Service) ListUserAPIKeys ¶
ListUserAPIKeys retrieves all API keys for a user
func (*Service) ListUserAPIKeysAPI ¶
ListUserAPIKeysAPI lists all API keys for a user and returns API-friendly response
func (*Service) ListUsersAPI ¶
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) MFADisable ¶
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 ¶
MFADisableAPI turns off MFA via the API.
func (*Service) MFAEnable ¶
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 ¶
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 ¶
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 ¶
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) RequestPasswordReset ¶
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 ¶
RevokeAPIKey deactivates an API key (soft delete)
func (*Service) RevokeAPIKeyAPI ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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