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 PermissionsAllowForConstraintSets(permissions []Permission, action, resource string, ...) (bool, error)
- func TestCSRFKey() []byte
- type APICreateAPIKeyRequest
- type APICreateAPIKeyResponse
- type APICreateGroupRequest
- type APICreateUserRequest
- type APICreateUserResponse
- type APIGroup
- type APIKeyInfo
- type APIKeysUsageStatsResponse
- type APIKeysUsageStatsTopKey
- type APIListAPIKeysResponse
- type APIPermission
- type APIPermissionConstraint
- type APIUpdateGroupRequest
- type APIUpdateUserRequest
- type APIUser
- type AccountScope
- 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) RecordAPIKeyUsage(ctx context.Context, keyID string, delta int64) 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) RecordAPIKeyUsage(ctx context.Context, keyID string, delta int64) 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, actorUserID string, 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) GetAPIKeysUsageStatsAPI(ctx context.Context, userID string) (any, 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) HasAPIKeyPermissionAPI(ctx context.Context, apiKey, action, resource string) (userID, keyID string, allowed bool, err error)
- func (s *Service) HasAPIKeyPermissionForConstraintsAPI(ctx context.Context, keyID, userID, action, resource string, ...) (bool, 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) HasPermissionForConstraintsAPI(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) RecordUsage(ctx context.Context, keyID string, delta int64) error
- func (s *Service) RecordUsageAsync(keyID string)
- 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) ResolveAllowedAccounts(ctx context.Context, userID string) ([]string, 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, actorUserID, 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, email, currentPassword, newPassword 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 canceling 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). // // Default grants (four-eyes policy, issue #1407): // * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers // both verbs. // * RoleUser — NO default grant (issue #1407). Four-eyes principle: // the submitter of a purchase must not approve it by default. // approve-own must be explicitly added to a custom group for // organizations that deliberately permit self-approval. // * RoleReadOnly — neither verb. Read-only users cannot approve. // // approve-any: seeded by the Purchaser group (migration 000059); // not a default non-admin grant. Add it to a custom group's // Permissions to enable broad approve rights without escalating // to admin. // approve-own: no system-managed group holds this by default // (issue #1407); add to a custom group when self-approval is an // explicit policy for that group. // // 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 authorizes 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" // ActionSellOwn / ActionSellAny gate the "Sell on Marketplace" button on // Standard RI purchase history rows (issue #292). Mirror image of the // cancel-{own,any} / retry-{own,any} / approve-{own,any} family: // // * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers // both verbs. // * RoleUser — DefaultUserPermissions() adds sell-own:purchases so // every user can list RIs they purchased themselves. // * RoleReadOnly — neither verb. // // sell-any has no default non-admin grant; the constant exists so a // custom operator group can list any Standard RI without admin escalation. ActionSellOwn = "sell-own" ActionSellAny = "sell-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 AdminAPIKeyActorID = "admin-api-key"
AdminAPIKeyActorID is the sentinel actor identifier carried by the stateless admin API-key session. The key is an infrastructure credential with no backing user row, so a group-derived permission lookup cannot resolve it. The ceiling treats it as holding exactly {admin, *} -- what it is authorized as everywhere else -- which subjects it to the same carve-out as a human admin: it can seed ordinary groups but cannot grant the money-spending verbs (issue #1550's third vector).
internal/api's apiKeyAdminUserID aliases this constant; keep them equal.
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 DefaultRIExchangerGroupID = "00000000-0000-5000-8000-000000000008"
DefaultRIExchangerGroupID is the fixed UUID of the RI Exchanger group seeded by migration 000096. It holds execute:ri-exchange, carved out of the admin:* wildcard by issue #1644. 000008 is the next free id in the seeded namespace (000005 Standard Users, 000006 Read-Only Users, 000007 Purchaser).
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") // ErrPermissionCeiling is returned by a group write that would grant a // permission the acting user does not themselves hold (or holds only at // a narrower constraint scope), and by the fail-closed paths where the // acting user's own permissions cannot be resolved at all. Mapped to 403 // (issue #1550). ErrPermissionCeiling = errors.New("permission ceiling exceeded") // ErrPermissionNotGrantable is returned by a group write that would ADD // one of the money-spending verbs carved out of the admin:* wildcard // (adminCarvedOuts). Those are reserved for separation of duties and are // provisioned by migration, never through the API. Mapped to 403 // (issues #923, #1550). ErrPermissionNotGrantable = errors.New("permission not grantable") // ErrInvalidPermission is returned when a group write carries a // permission entry with a blank action or resource. A blank resource is // malformed input, NOT a request for the "*" wildcard: the group-edit // form renders an empty stored resource as the selected "All (*)" option // and saves it back as view:*, and the same widening is reachable // directly through the API because nothing validated the list. Mapped to // 400 (issues #1730, #1550). ErrInvalidPermission = errors.New("invalid permission") // ErrSystemManagedGroup is returned when a write targets one of the // seeded, system-managed groups. Their contents are owned by migrations; // an API edit that reshapes them can silently disable a whole capability // tenant-wide. Mapped to 403 (issue #1629). ErrSystemManagedGroup = errors.New("system-managed group cannot be modified") // 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 PermissionsAllowForConstraintSets ¶
func PermissionsAllowForConstraintSets(permissions []Permission, action, resource string, constraintSets []PermissionConstraints) (bool, error)
PermissionsAllowForConstraintSets is the decision half of HasPermissionForConstraintsAPI with the permission fetch removed: it reports whether an already-resolved permission set grants action on resource for EVERY request-derived constraint set.
Exported so a caller that already holds the effective permission set answers from this code rather than from a re-implementation of it. The api package's MockAuthService is the caller that motivated it: it modeled the permission set faithfully but discarded the constraint arguments entirely, so it answered "allowed" for requests on every one of the five constraint dimensions that this function denies, and a handler test asserting a constraint refusal was measuring the mock (issue #1762).
Fail closed: an empty constraintSets slice is a caller bug, not a grant.
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 {
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Name string `json:"name"`
Permissions []Permission `json:"permissions,omitempty"`
}
APICreateAPIKeyRequest represents the API request to create an API key.
type APICreateAPIKeyResponse ¶
type APICreateAPIKeyResponse struct {
Info *APIKeyInfo `json:"info"`
APIKey string `json:"api_key"` //nolint:gosec // G117: intentional one-time raw API-key response -- the key is returned to its owner exactly once on creation and is never re-stored or re-serialized
KeyID string `json:"key_id"`
}
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"` //nolint:gosec // G117: intentional credential field -- password supplied by caller for user creation; not re-stored downstream
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"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Permissions []APIPermission `json:"permissions"`
AllowedAccounts []string `json:"allowed_accounts"`
}
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 {
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// RequestCountWindowStart is when the current request_count_window
// began; nil means there is no current window, either because the key
// has never recorded a request or because its last window has closed.
// Exposed so consumers know exactly which period RequestCountWindow
// covers instead of assuming a true trailing "last 24h".
RequestCountWindowStart *time.Time `json:"request_count_window_start,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Permissions []Permission `json:"permissions,omitempty"`
IsActive bool `json:"is_active"`
// Usage counters (issue #340/#344 deferred sub-task). RequestCountWindow
// is a fixed/tumbling window count, not a true rolling 24h total, and is
// zero once that window has closed -- see effectiveWindowUsage,
// PostgresStore.RecordAPIKeyUsage and RequestCountWindowStart above.
//
// RequestCountTotal is a pointer because "unknown" and "zero" are
// different answers: a key that was already in use before migration 000094
// added the counter has no recoverable lifetime figure, and reporting 0
// for it would be a fabricated number. nil means unknown; see
// effectiveLifetimeUsage.
RequestCountTotal *int64 `json:"request_count_total"`
RequestCountWindow int64 `json:"request_count_window"`
}
APIKeyInfo represents public API key information (without sensitive data).
type APIKeysUsageStatsResponse ¶
type APIKeysUsageStatsResponse struct {
TotalActive int `json:"total_active"`
TotalRequestsWindow int64 `json:"total_requests_window"`
TotalRequestsLifetime int64 `json:"total_requests_lifetime"`
LifetimePartial bool `json:"lifetime_partial"`
TopKeys []APIKeysUsageStatsTopKey `json:"top_keys"`
}
APIKeysUsageStatsResponse is the summary payload for the API keys section header (issue #340/#344 deferred sub-task). Scoped to the calling user's own keys -- same scope as ListUserAPIKeysAPI.
TotalRequestsWindow sums each key's fixed-window counter (see PostgresStore.RecordAPIKeyUsage) across the keys whose window is still open; it is NOT a true trailing-24h total, and the windows it sums are not aligned with one another.
TotalActive counts the keys the authentication path would accept, so a key that is unrevoked but past its expiry is not counted.
TotalRequestsLifetime sums only the keys whose lifetime count is known. LifetimePartial reports whether any key was left out because its usage predates migration 000094 (see effectiveLifetimeUsage), so the UI can show the total as a lower bound instead of presenting an undercount as exact.
type APIKeysUsageStatsTopKey ¶
type APIKeysUsageStatsTopKey struct {
ID string `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
RequestCountWindow int64 `json:"request_count_window"`
}
APIKeysUsageStatsTopKey is one entry in the top-keys list returned by GetAPIKeysUsageStatsAPI. Trimmed to identifier + window count so the UI doesn't have to re-derive anything from the full APIKeyInfo blob.
type APIListAPIKeysResponse ¶
type APIListAPIKeysResponse struct {
APIKeys []*APIKeyInfo `json:"api_keys"`
}
APIListAPIKeysResponse represents the API response for listing API keys.
type APIPermission ¶
type APIPermission struct {
Constraints *APIPermissionConstraint `json:"constraints,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
}
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"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
LastLogin string `json:"last_login,omitempty"`
Groups []string `json:"groups"`
MFAEnabled bool `json:"mfa_enabled"`
}
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 AccountScope ¶
type AccountScope struct {
// Accounts is the explicit allow-list, meaningful only when
// Unrestricted is false. Entries match by cloud account ID or by
// display name, mirroring MatchesAccount.
Accounts []string
// Unrestricted grants access to every account. Set it only where
// unlimited access has been positively established.
Unrestricted bool
}
AccountScope is the set of cloud accounts a principal may access.
The zero value MUST deny. This is a requirement, not an observation. ¶
AccountScope{} is {Unrestricted: false, Accounts: nil} -- restricted to nothing, granting access to no account at all. That is deliberate and load-bearing: a forgotten initialisation, a value returned on an error path, or a field a later change fails to populate all fail CLOSED.
Do NOT invert the flag to `Restricted bool` for readability. That reads marginally better and is the entire bug this type exists to remove: it would make the zero value grant everything, so absence would once again mean unrestricted -- with a compiler now blessing it. Issue #1748 is precisely that failure in its untyped form: `[]string` used both for "no restriction configured" and for "we could not work out your scope", so every resolution failure silently granted access to every account.
Unrestricted must therefore always be set POSITIVELY by a caller that has established the principal genuinely has unlimited access -- the stateless admin API key, or a group carrying the "*" wildcard.
func ScopeForAccounts ¶
func ScopeForAccounts(accounts []string) AccountScope
ScopeForAccounts returns a scope limited to the given accounts, treating every entry as a LITERAL account identifier.
Two asymmetries with the legacy []string convention, and both invert its meaning rather than merely differing from it:
- An empty list grants NOTHING here; under the legacy convention empty meant "all accounts".
- "*" is a literal identifier here, NOT a wildcard. ScopeForAccounts(["*"]) matches an account whose ID or name is the single character "*" and denies everything else, where ScopeFromLegacyList(["*"]) is unrestricted.
The second matters for the obvious future call. Passing a group's stored list straight through -- ScopeForAccounts(group.AllowedAccounts) -- silently produces a scope that DENIES EVERYTHING for any wildcard-carrying group, and all seven seeded groups ship allowed_accounts = ARRAY['*']. Use ScopeFromLegacyList for a value that came out of the resolver, and this constructor only for a list you have already decided is literal.
func ScopeFromLegacyList ¶
func ScopeFromLegacyList(accounts []string) AccountScope
ScopeFromLegacyList converts a resolved []string using the historical convention where empty or a "*" entry means unrestricted.
ScopeFromLegacyList(nil) and ScopeFromLegacyList([]) both return UnrestrictedScope(). That is deliberate and it is also the dangerous part, so be precise about what makes it safe.
This function CANNOT distinguish "no restriction configured" from "we could not work out this principal's scope" -- they are the same empty value, which is issue #1748 in its entirety. It therefore carries no safety of its own. It is safe only because Service.ResolveAllowedAccounts has ALREADY refused every input that would make empty mean the wrong thing:
- a store error, or a missing user row -> error, never reaches here
- every group unresolvable -> error
- SOME group unresolvable AND the surviving union empty -> error
That third condition is the one to keep in view. An earlier version of the resolver refused only total failure, and under it this function WOULD have converted a widened empty union into UnrestrictedScope() -- carrying the bug forward inside the new type, with the compiler blessing it. The guarantee this depends on is not "the resolver returns successfully"; it is "the resolver refuses any empty union that a skipped group could have caused".
Do not call this on a []string from any other source. If a new producer appears, give it the same guarantee first or build the AccountScope directly with ScopeForAccounts / UnrestrictedScope.
func UnrestrictedScope ¶
func UnrestrictedScope() AccountScope
UnrestrictedScope returns a scope granting every account. Use it only at a site that has positively established unlimited access; never as a fallback or a default.
func (AccountScope) Allows ¶
func (s AccountScope) Allows(accountID, accountName string) bool
Allows reports whether the scope permits the given cloud account, matched by internal ID or display name. Pass "" for the name when unavailable.
A restricted scope with no accounts allows nothing, which is what makes the zero value deny.
func (AccountScope) AllowsAll ¶
func (s AccountScope) AllowsAll() bool
AllowsAll reports whether the scope is unrestricted. Prefer Allows; this exists for the few sites that must branch on unrestricted-ness itself, such as skipping a filter entirely.
func (AccountScope) FilterIDs ¶
func (s AccountScope) FilterIDs(ids []string) []string
FilterIDs returns the subset of ids the scope permits. An unrestricted scope returns ids unchanged.
func (AccountScope) String ¶
func (s AccountScope) String() string
String renders the scope for error messages and logs.
type AuthContext ¶
type AuthContext struct {
User *User
Groups []*Group
AllowedAccounts []string // Computed from all groups (union)
Permissions []Permission // Computed from group memberships
// SkippedGroups counts memberships that could not be resolved (deleted or
// unreadable), counted where the skip happens.
//
// len(User.GroupIDs) - len(Groups) would give the same answer, since Groups
// is appended once per ID with no dedup and duplicate IDs therefore produce
// duplicate entries. Counting explicitly states the intent rather than
// relying on two lengths staying aligned (issue #1748).
SkippedGroups int
}
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 {
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Name string `json:"name"`
Permissions []Permission `json:"permissions,omitempty"`
}
CreateAPIKeyRequest for creating a new user API key.
type CreateAPIKeyResponse ¶
type CreateAPIKeyResponse struct {
Info *UserAPIKey `json:"info"`
APIKey string `json:"api_key"` //nolint:gosec // G117: intentional credential field in request/response struct -- value is supplied by the authenticated caller or returned once at creation; it is not re-stored or re-serialized downstream
KeyID string `json:"key_id"`
}
CreateAPIKeyResponse returns the newly created API key (only shown once).
type CreateUserRequest ¶
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"` //nolint:gosec // G117: intentional credential field in request/response struct -- value is supplied by the authenticated caller or returned once at creation; it is not re-stored or re-serialized downstream
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)
Begin(ctx context.Context) (pgx.Tx, 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 {
CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"`
UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"`
ID string `json:"id" dynamodbav:"PK"`
Name string `json:"name" dynamodbav:"Name"`
Description string `json:"description,omitempty" dynamodbav:"Description"`
CreatedBy string `json:"created_by" dynamodbav:"CreatedBy"`
Permissions []Permission `json:"permissions" dynamodbav:"Permissions"`
AllowedAccounts []string `json:"allowed_accounts,omitempty" dynamodbav:"AllowedAccounts"`
SystemManaged bool `json:"system_managed,omitempty" dynamodbav:"SystemManaged"`
}
Group represents a permission group.
type LoginRequest ¶
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"` //nolint:gosec // G117: intentional credential field in request/response struct -- value is supplied by the authenticated caller or returned once at creation; it is not re-stored or re-serialized downstream
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 //nolint:gosec // G117: intentional one-time MFA secret response -- returned to the caller exactly once during enrollment setup; persisted separately and not re-serialized
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 ¶
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) RecordAPIKeyUsage ¶
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 {
Constraints *PermissionConstraints `json:"constraints,omitempty" dynamodbav:"Constraints"`
Action string `json:"action" dynamodbav:"Action"`
Resource string `json:"resource" dynamodbav:"Resource"`
}
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), but a single INSERT … WHERE NOT EXISTS statement is NOT race-free on its own: under READ COMMITTED each statement's snapshot is taken at statement start, so two concurrent calls can both see "no admin" and both insert (reproduced by TestIntegration_CreateAdminIfNone_ConcurrentBootstrapOnce). To close that window the insert runs in a transaction that first takes the transaction-scoped advisory lock shared with the min-one-admin trigger (migration 000065): the second caller blocks until the first commits, and its INSERT statement then takes a fresh snapshot that sees the committed admin, so the NOT EXISTS guard suppresses the duplicate. The lock auto-releases at COMMIT or ROLLBACK.
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) RecordAPIKeyUsage ¶
RecordAPIKeyUsage atomically updates last_used_at, adds delta to request_count_total, and adds-or-resets request_count_window based on whether the current window is still within apiKeyUsageWindow of its start.
delta is the number of requests being recorded in this flush. The auth hot path coalesces concurrent requests for the same key into a single write (see Service.RecordUsageAsync), so delta is frequently greater than 1 and must be added rather than treated as a single increment.
request_count_window is a FIXED/TUMBLING window counter, not a true trailing-24h rolling count: the single UPDATE does the window decision inline --
- if window_start IS NULL OR (NOW() - window_start) >= apiKeyUsageWindow, start a fresh window (window_start = NOW(), count = delta) -- this DISCARDS whatever count the previous window had, even if some of those requests happened within the preceding window-length of NOW().
- otherwise add delta to the existing count.
The window length is passed in from apiKeyUsageWindow rather than written as a SQL literal so the write path and the API read path (which zeroes an expired window, see effectiveWindowUsage) cannot drift apart.
An idle key's stale count lingers on the row unchanged until its next request; the read path is responsible for not reporting it. Callers that need the actual period covered should read request_count_window_start rather than assuming "requests in the last 24h".
Doing it in one statement keeps the update atomic per row, so two concurrent calls can't both see "stale window" and reset in parallel -- pgx serializes updates to the same row.
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. Retained for backwards compatibility with callers that only need the timestamp update -- production code routes through RecordAPIKeyUsage, which additionally maintains the usage counters from migration 000094.
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 NewService ¶
func NewService(cfg ServiceConfig) *Service
NewService creates a new auth service.
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 behavior 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.
req may be any struct whose JSON representation is compatible with APICreateAPIKeyRequest (name, expires_at, permissions). The handler lives in a different package (api) and passes api.CreateAPIKeyRequest, which carries the same JSON fields but is a distinct Go type. A direct type assertion would always fail and return a 500; JSON re-encoding handles any caller-package type transparently (issue #1440).
func (*Service) CreateGroup ¶
CreateGroup creates a new permission group.
func (*Service) CreateGroupAPI ¶
func (s *Service) CreateGroupAPI(ctx context.Context, actorUserID string, reqInterface any) (any, error)
CreateGroupAPI creates a new group via the API. actorUserID is the acting principal (a user ID, or AdminAPIKeyActorID for the stateless admin API key); the requested permissions are checked against that principal's own effective set before anything is written (issue #1550).
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.
Deleting a system-managed group is refused: it is the same defect class as the #1629 permission wipe, reached through a different verb. Dropping the seeded Purchaser group destroys the only holder of the money verbs carved out of admin:*, which no admin can then re-grant (see checkGrantCeiling), so the purchase path would be dead tenant-wide until someone re-ran the migration by hand.
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) GetAPIKeysUsageStatsAPI ¶
GetAPIKeysUsageStatsAPI computes section-level usage stats for the calling user's own API keys. Aggregated in-process so we don't need a separate DB round-trip -- ListUserAPIKeys already returns the per-key counters from migration 000094.
The "top keys" list is sorted by effective window count descending, with total-lifetime as the tiebreaker so a long-running idle key doesn't outrank an active one with the same window count. Up to apiKeyUsageTopKeysLimit entries are surfaced; keys with zero window activity are omitted rather than shown as an uninformative "top key".
Window counts run through effectiveWindowUsage first: the raw column keeps its last value until the key is used again, so summing it verbatim would report requests from windows that closed arbitrarily long ago as current activity.
TotalActive uses validateAPIKeyStatus, the same predicate the authentication path applies, so a key that is unrevoked but expired is not counted as active here while the keys table below the summary card shows it as "Expired".
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) HasAPIKeyPermissionAPI ¶
func (s *Service) HasAPIKeyPermissionAPI(ctx context.Context, apiKey, action, resource string) (userID, keyID string, allowed bool, err error)
HasAPIKeyPermissionAPI validates a user API key and checks the requested action/resource against the key's effective permissions: the intersection of the key's scoped permissions with the owning user's group-derived permissions (ComputeEffectivePermissions). A key created without explicit permissions inherits the owner's full permission set. Returns the owning user's ID, the key's database ID, and whether the permission is held. The key ID is threaded to callers so they can pass it to HasAPIKeyPermissionForConstraintsAPI without a redundant DB lookup.
Fail closed: any validation failure (unknown, revoked, or expired key, inactive owner) or permission-lookup error returns a non-nil error and callers must deny access.
func (*Service) HasAPIKeyPermissionForConstraintsAPI ¶
func (s *Service) HasAPIKeyPermissionForConstraintsAPI(ctx context.Context, keyID, userID, action, resource string, constraintSets []PermissionConstraints) (bool, error)
HasAPIKeyPermissionForConstraintsAPI checks request-derived permission constraint sets against a user API key's effective permissions (the intersection of the key's own permissions with the owning user's group-derived permissions). Callers must have already confirmed the key grants action/resource via HasAPIKeyPermissionAPI; this enforces the Constraints dimension (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) at execution time for user-API-key sessions (adversarial-review F2, issue #1141 extension).
Fail closed: an empty constraintSets slice is a caller bug; any DB lookup failure returns an error and callers must deny.
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) HasPermissionForConstraintsAPI ¶
func (s *Service) HasPermissionForConstraintsAPI(ctx context.Context, userID, action, resource string, constraintSets []PermissionConstraints) (bool, error)
HasPermissionForConstraintsAPI checks that the user holds action on resource for EVERY request-derived constraint set, so per-permission Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) configured on a group permission are enforced at execution time instead of being silently ignored (SEC-01, issue #1141). A single effective-permission fetch covers all sets; each set must be granted by at least one permission (different sets may be satisfied by different permissions, matching the union semantics of group grants).
Fail closed: an empty constraintSets slice is a caller bug, not a grant - it returns an explicit error rather than allowing. Checked here as well as in PermissionsAllowForConstraintSets so a caller bug never reaches the store, which the "empty constraint sets fail loud" case asserts by leaving the store mock with no registered expectation.
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). Defense-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 (defense-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) RecordUsage ¶
RecordUsage updates last_used_at and adds delta to both the lifetime counter and the fixed-window request counter for the key. delta is the number of requests being recorded, which is greater than 1 whenever concurrent requests were coalesced into one flush. See PostgresStore.RecordAPIKeyUsage for the atomic SQL and the window's tumbling-reset semantics.
func (*Service) RecordUsageAsync ¶
RecordUsageAsync books one request against keyID and flushes the pending count to the store off the authentication hot path.
Callers must invoke this exactly once per inbound request, not once per credential validation -- a single request validates the same key several times over. The API layer owns that guarantee (see Handler.validateSecurityContext).
The count is accumulated in memory first and the flush writes the whole accumulated delta, because singleflight.Group collapses concurrent flushes for the same key into one DB write. Incrementing by a fixed 1 inside the flush would therefore drop every request that arrived while a write was in flight -- a systematic undercount that grows with the key's request rate, i.e. worst exactly on the busy keys the usage stats exist to surface.
singleflight is still what bounds the write rate: at most one in-flight DB write per keyID at any moment, so a flood of requests on one key cannot amplify into a flood of database writes.
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) ResolveAllowedAccounts ¶
ResolveAllowedAccounts returns the cloud accounts a user may access, and FAILS CLOSED when that scope cannot be established (issue #1748).
The subtlety: an empty result means UNRESTRICTED (IsUnrestrictedAccess), a deliberate backward-compat default so a group with no allowed_accounts configured grants full access. But collectGroupsAndAccounts skips a group it cannot load, so a skipped group can leave an empty union that reads as "every account".
DROPPING A GROUP CAN WIDEN ACCESS. This is the part that is easy to get wrong, and an earlier version of this guard did: the union of [] and ["acct-A"] is RESTRICTED, so losing the group carrying ["acct-A"] collapses it to [] -- unrestricted. Partial resolution is therefore NOT safely "narrower"; it is only narrower when the surviving groups still contribute entries. Reproduced by execution with a two-group actor (one granting update:groups with no allowed_accounts, one carrying the restriction): losing the restricting group turned a scope of [acct-A] into all accounts.
A single-group configuration cannot exhibit this, which is why a six-case single-group verification found nothing.
The guard is therefore: refuse when the union is empty AND at least one group was skipped. That targets exactly the widening. It deliberately does NOT refuse on any unresolved group: that would reverse the intentional "group was deleted; skip it rather than failing the entire request" behavior and lock out a user with one stale membership everywhere until an admin cleaned up -- trading a conditional security hole for an unconditional availability regression on a path this widely consumed.
A restricted principal keeps working through a skipped group, because a non-empty union cannot have been widened by the loss.
The emptiness test is len(AllowedAccounts) == 0, deliberately NOT IsUnrestrictedAccess. The latter is also true for a union containing "*", and such a principal was ALREADY maximally wide at baseline -- no lost group can widen them further, so refusing them buys nothing and costs availability. All seven seeded groups ship allowed_accounts = ARRAY['*'], so using IsUnrestrictedAccess here 500'd every account-scoped endpoint for any member of a seeded group who also had one stale membership. Only an EMPTY union can have been widened by a loss.
The skip count comes from collectGroupsAndAccounts, counted where the skip happens. len(User.GroupIDs) - len(Groups) would in fact give the same answer -- Groups is appended once per ID with no dedup, so duplicate IDs produce duplicate entries and the counts stay aligned -- but counting at the point of skipping states the intent directly instead of inferring it from two lengths that happen to line up.
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, actorUserID, groupID string, reqInterface any) (any, error)
UpdateGroupAPI updates a group via the API. It refuses to touch a system-managed group at all, and checks any requested permission list against the acting principal's own effective permissions before writing (issues #1550, #1629). actorUserID is a user ID, or AdminAPIKeyActorID for the stateless admin API key.
func (*Service) UpdateLastUsed ¶
UpdateLastUsed updates only the last used timestamp for an API key. Retained for backwards compatibility; new code paths should use RecordUsage so the request_count_* counters stay current.
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 authorized.
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, email, currentPassword, 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.
Validation is deliberately side-effect free: it books no usage. A single HTTP request validates the same credential several times (authentication resolves the principal, then every permission check re-validates, and the multi-verb gates re-validate once per verb), so booking here would count validations rather than requests and inflate every usage number by a per-endpoint factor. Usage is booked exactly once per request by the API layer -- see Handler.validateSecurityContext, which calls RecordUsageAsync on the one code path guaranteed to run once per request.
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
OnPasswordChange func(ctx context.Context, userID, newPassword string)
DashboardURL string
CSRFKey []byte
SessionDuration time.Duration
}
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"` //nolint:gosec // G117: intentional credential field in request/response struct -- value is supplied by the authenticated caller or returned once at creation; it is not re-stored or re-serialized downstream
}
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
// RecordAPIKeyUsage atomically updates last_used_at and adds delta to
// request_count_total + request_count_window. delta is the number of
// requests in this flush (greater than 1 when concurrent requests were
// coalesced) and must be positive. request_count_window is a
// fixed/tumbling window count (not a true trailing-24h rolling count):
// it resets (along with request_count_window_start) when the existing
// window is older than apiKeyUsageWindow. Stale windows are zeroed on
// read, not on write -- see effectiveWindowUsage. See migration 000094
// for the column shape.
RecordAPIKeyUsage(ctx context.Context, keyID string, delta int64) 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"`
Active *bool `json:"active,omitempty"`
GroupIDs []string `json:"group_ids,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 {
UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"`
CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"`
LockedUntil *time.Time `json:"-" dynamodbav:"LockedUntil,omitempty"`
MFAPendingSecretExpiresAt *time.Time `json:"-" dynamodbav:"MFAPendingSecretExpiresAt,omitempty"`
PasswordResetExpiry *time.Time `json:"-" dynamodbav:"PasswordResetExpiry,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" dynamodbav:"LastLoginAt"`
MFASecret string `json:"-" dynamodbav:"MFASecret,omitempty"`
PasswordResetToken string `json:"-" dynamodbav:"PasswordResetToken,omitempty"`
Salt string `json:"-" dynamodbav:"Salt"`
ID string `json:"id" dynamodbav:"PK"`
MFAPendingSecret string `json:"-" dynamodbav:"MFAPendingSecret,omitempty"`
PasswordHash string `json:"-" dynamodbav:"PasswordHash"`
Email string `json:"email" dynamodbav:"Email"`
GroupIDs []string `json:"group_ids,omitempty" dynamodbav:"GroupIDs"`
MFARecoveryCodes []string `json:"-" dynamodbav:"MFARecoveryCodes,omitempty"`
PasswordHistory []string `json:"-" dynamodbav:"PasswordHistory,omitempty"`
FailedLoginAttempts int `json:"-" dynamodbav:"FailedLoginAttempts,omitempty"`
Active bool `json:"active" dynamodbav:"Active"`
MFAEnabled bool `json:"mfa_enabled" dynamodbav:"MFAEnabled"`
}
User represents a user account.
type UserAPIKey ¶
type UserAPIKey struct {
CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"`
ExpiresAt *time.Time `json:"expires_at,omitempty" dynamodbav:"ExpiresAt"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" dynamodbav:"LastUsedAt"`
ID string `json:"id" dynamodbav:"PK"`
UserID string `json:"user_id" dynamodbav:"UserID"`
Name string `json:"name" dynamodbav:"Name"`
KeyPrefix string `json:"key_prefix" dynamodbav:"KeyPrefix"`
KeyHash string `json:"-" dynamodbav:"KeyHash"`
Permissions []Permission `json:"permissions,omitempty" dynamodbav:"Permissions"`
IsActive bool `json:"is_active" dynamodbav:"IsActive"`
// Usage counters (issue #340/#344 deferred sub-task -- migration 000094).
// Both default to 0 for legacy rows. RequestCountWindow is a
// FIXED/TUMBLING window count, not a true trailing-24h rolling count:
// the store's RecordAPIKeyUsage path restarts it (and
// RequestCountWindowStart) once the existing window is older than
// apiKeyUsageWindow, so a request made just before that reset is dropped
// from the count as soon as the next request starts a new window.
//
// These are the RAW column values: because the reset only happens on the
// key's next request, a key that went idle still carries its closed
// window's count here. The API layer zeroes an expired window before
// reporting it (see effectiveWindowUsage) -- do not surface these fields
// to callers without going through it. RequestCountWindowStart is
// exposed so API consumers can see exactly which period the count
// covers instead of assuming "last 24h".
RequestCountTotal int64 `json:"request_count_total" dynamodbav:"RequestCountTotal"`
RequestCountWindow int64 `json:"request_count_window" dynamodbav:"RequestCountWindow"`
RequestCountWindowStart *time.Time `json:"request_count_window_start,omitempty" dynamodbav:"RequestCountWindowStart,omitempty"`
}
UserAPIKey represents a personal API key for a user with scoped permissions.