Documentation
¶
Overview ¶
Package users owns the users + user_roles tables. CRUD never exposes the password hash; role assignment is gated by the roles table FK.
Implements identity.Lookups via PrimaryRoleFor so the identity binder can translate a session into auth.Identity.
Spec: specs/system/user-management.spec.yaml.
Index ¶
- Variables
- type CreateParams
- type CustomRoleParams
- type PermissionValidator
- type ProfileUpdate
- type Role
- type Service
- func (s *Service) AccountStatusFor(ctx context.Context, userID uuid.UUID) (identity.AccountStatus, error)
- func (s *Service) AdminResetPassword(ctx context.Context, id uuid.UUID, newPassword string) error
- func (s *Service) AssignRole(ctx context.Context, userID uuid.UUID, role auth.RoleID, grantedBy *uuid.UUID) error
- func (s *Service) CreateCustomRole(ctx context.Context, p CustomRoleParams, validator PermissionValidator) (Role, []string, error)
- func (s *Service) CreateFederatedUser(ctx context.Context, username, email string, role auth.RoleID) (User, error)
- func (s *Service) CreateUser(ctx context.Context, p CreateParams) (User, error)
- func (s *Service) Disable(ctx context.Context, id uuid.UUID) error
- func (s *Service) DisableUser(ctx context.Context, id uuid.UUID) (User, error)
- func (s *Service) Enable(ctx context.Context, id uuid.UUID) (transitioned bool, err error)
- func (s *Service) EnableUser(ctx context.Context, id uuid.UUID) (u User, transitioned bool, err error)
- func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
- func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, error)
- func (s *Service) ListUsers(ctx context.Context) ([]User, error)
- func (s *Service) PrimaryRoleFor(ctx context.Context, userID uuid.UUID) (auth.RoleID, error)
- func (s *Service) RoleForUser(ctx context.Context, userID uuid.UUID) (auth.RoleID, error)
- func (s *Service) RolePermissions(ctx context.Context, roleID string) ([]string, bool, error)
- func (s *Service) RolesForUser(ctx context.Context, userID uuid.UUID) ([]auth.RoleID, error)
- func (s *Service) SoftDelete(ctx context.Context, id uuid.UUID) error
- func (s *Service) UnassignRole(ctx context.Context, userID uuid.UUID, role auth.RoleID) error
- func (s *Service) UpdatePassword(ctx context.Context, id uuid.UUID, newPassword string) error
- func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (User, error)
- func (s *Service) UseTxSource(b identity.TxBeginner)
- func (s *Service) VerifyUserPassword(ctx context.Context, username, password string) (User, error)
- type User
Constants ¶
This section is empty.
Variables ¶
var ( ErrRoleIDTaken = errors.New("users: role id collides with a built-in or existing custom role") ErrUnknownPermission = errors.New("users: role grants permission not in the registry") ErrCustomRoleEmpty = errors.New("users: custom role must grant at least one permission") // ErrRoleExceedsGrant is returned when the caller tries to author a custom // role granting a permission they do not themselves hold. ErrRoleExceedsGrant = errors.New("users: custom role grants a permission the creator does not hold") )
CustomRole-related errors.
var ( ErrUserNotFound = errors.New("users: not found") ErrUnknownRole = errors.New("users: role does not exist") // ErrUserHasNoRoles is identity.ErrNoRoles, so the binder can tell a // confirmed "no roles" from a lookup that failed. ErrUserHasNoRoles = identity.ErrNoRoles // ErrUserDisabled is returned when an operation targets a disabled // account, or (for the login path) when a disabled user authenticates. ErrUserDisabled = errors.New("users: account is disabled") // ErrCannotDisableSelf guards an admin from disabling their own account // (lockout prevention). ErrCannotDisableSelf = errors.New("users: cannot disable your own account") // ErrEmailTaken is returned by UpdateProfile when the requested email is // already used by another active user (the sign-in identity must be // unique). Maps to HTTP 409. ErrEmailTaken = errors.New("users: email already in use") // ErrInvalidProfile is returned for a malformed profile update (e.g. an // empty email). Maps to HTTP 400. ErrInvalidProfile = errors.New("users: invalid profile update") )
Service errors. Returned from the CRUD + role-mgmt API.
Functions ¶
This section is empty.
Types ¶
type CreateParams ¶
type CreateParams struct {
Username string
Email string
Password string
AdminPolicy bool // true → AdminPolicy at password validation; false → DefaultPolicy
}
CreateParams is the input to CreateUser. Plaintext password is hashed and validated inside Create; never persists outside the hash. The AdminPolicy flag selects which password-strength policy is applied at creation; callers who know the user will hold the admin role set it true (the create-admin CLI does this). Role assignment happens separately via AssignRole.
type CustomRoleParams ¶
type CustomRoleParams struct {
ID string
Description string
Permissions []string
CreatedBy uuid.UUID
// CallerHolds reports whether the CREATOR holds a permission. Supplied by
// the handler from the caller's identity so this package need not import
// internal/auth (which would cycle). A nil CallerHolds skips the
// subset check and is intended ONLY for trusted internal seeding; every
// request-driven path must set it.
CallerHolds func(perm string) bool
}
CustomRoleParams is the input to CreateCustomRole.
type PermissionValidator ¶
PermissionValidator returns true if a permission id is registered. Decoupled so the users package doesn't import internal/auth (which would create a cycle: auth → users → auth via tests).
type ProfileUpdate ¶ added in v0.4.0
type ProfileUpdate struct {
Email *string
FullName *string
DisplayName *string
JobTitle *string
Timezone *string
Phone *string
}
ProfileUpdate is a partial self-profile edit: a nil field is left unchanged, a non-nil field replaces the stored value (an empty string clears it, except Email which may not be empty).
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the user/role CRUD entry point. Construct via NewService.
func NewService ¶
func NewService(pool *pgxpool.Pool, corpus identity.BreachCorpus) *Service
NewService binds a Service to a DB pool. The breach corpus is optional — production deployments wire it in. Tests typically pass nil or a small NewMemoryBreachCorpus fixture.
func (*Service) AccountStatusFor ¶ added in v0.8.0
func (s *Service) AccountStatusFor(ctx context.Context, userID uuid.UUID) (identity.AccountStatus, error)
AccountStatusFor is the identity.Lookups account-state adapter. It is the binders' independent protection: a credential issued while an account was disabled is never revoked, so revocation alone cannot refuse it. Spec C-31.
func (*Service) AdminResetPassword ¶
AdminResetPassword sets a user's password on an administrator's authority: unlike the self-service password change it does NOT require the current password. The new password still runs through the role-aware policy + breach screen (via UpdatePassword). The target's active sessions are then revoked so they must re-authenticate with the new password.
Spec api-users (admin reset-password).
func (*Service) AssignRole ¶
func (s *Service) AssignRole(ctx context.Context, userID uuid.UUID, role auth.RoleID, grantedBy *uuid.UUID) error
AssignRole inserts a user_roles row. Role must exist; FK enforcement is mandatory (spec C-04). Idempotent — re-assigning an existing role is a no-op.
Spec AC-08.
func (*Service) CreateCustomRole ¶
func (s *Service) CreateCustomRole(ctx context.Context, p CustomRoleParams, validator PermissionValidator) (Role, []string, error)
CreateCustomRole inserts a new row in roles with is_built_in=false. validator must accept every permission in p.Permissions; unknown permissions return ErrUnknownPermission with the offending ids recoverable from the returned []string.
Spec api-users AC-11, AC-12, C-03, C-04.
func (*Service) CreateFederatedUser ¶
func (s *Service) CreateFederatedUser(ctx context.Context, username, email string, role auth.RoleID) (User, error)
CreateFederatedUser provisions a user authenticated by an external IdP (SSO). The account has NO usable password: a random 32-byte secret is hashed and discarded, so local password login can never succeed — the user authenticates only through the identity provider. The supplied role is assigned in the same call. Username/email uniqueness is enforced by the DB; a collision with an existing active user surfaces as an error (the caller maps it) rather than silently merging accounts.
Spec system-sso AC-08.
func (*Service) CreateUser ¶
CreateUser hashes + validates the password, inserts the row. Returns the safe User shape — password hash never leaves the package.
Spec AC-01, AC-02, AC-03.
func (*Service) Disable ¶
Disable marks an account disabled (disabled_at = now). A disabled user cannot authenticate: the login path rejects them and disabling revokes their active sessions so the cutoff is immediate. Idempotent: disabling an already-disabled user refreshes the timestamp. ErrUserNotFound for unknown or soft-deleted users.
Spec api-users (disable/enable).
func (*Service) DisableUser ¶ added in v0.8.0
DisableUser is Disable returning the disabled user as the transaction read it, before its confirmed commit. The admin handler answers with it, so a committed disable is never reported through a later read.
func (*Service) Enable ¶
Enable clears the disabled flag. A real disabled-to-enabled transition revokes every interactive credential the user holds, so the user signs in again: nothing that existed while the account was disabled, including a credential no revocation ever reached, authenticates afterwards. Enable on an account that is not disabled is a no-op that revokes nothing and signs nobody out. Service-account tokens are never touched. ErrUserNotFound for unknown or soft-deleted users.
transitioned reports what the locked transaction did: true only when it changed disabled_at from set to null and committed the revocation. The caller records it on the audit event; it must not infer it from a later read, which another enable or disable could already have changed.
Spec api-users C-07, C-08; system-auth-identity C-34, C-36.
func (*Service) EnableUser ¶ added in v0.8.0
func (s *Service) EnableUser(ctx context.Context, id uuid.UUID) (u User, transitioned bool, err error)
EnableUser is Enable returning the user as the locked transaction read it, before its confirmed commit, for the same reason as DisableUser.
func (*Service) GetUserByID ¶
GetUserByID returns the user when active; ErrUserNotFound for unknown or soft-deleted IDs.
Spec AC-04.
func (*Service) GetUserByUsername ¶
GetUserByUsername returns the user when active; ErrUserNotFound for unknown or soft-deleted usernames.
Spec AC-05.
func (*Service) ListUsers ¶
ListUsers returns all active users. Slice A keeps the result flat — cursor pagination lands when scan volumes make it necessary.
func (*Service) PrimaryRoleFor ¶
PrimaryRoleFor returns the highest-precedence role assigned to the user. Implements the identity.Lookups interface so the binder can populate auth.Identity from a session.
Spec AC-11, C-06.
func (*Service) RoleForUser ¶
RoleForUser is the identity.Lookups adapter. Direct synonym for PrimaryRoleFor with the same signature shape.
func (*Service) RolePermissions ¶ added in v0.7.0
RolePermissions returns the permissions a role confers, and whether the role exists. Built-in roles are answered from the registry by the caller; this reads the roles table, which is where a CUSTOM role's permission set lives.
It backs the anti-escalation guard (auth.RoleGrantsWithinResolved). Without it the guard can only see built-in roles, and its only safe response to a custom role is to deny, which would break legitimate custom-role assignment.
It returns three states. A missing row is (nil, false, nil): provably absent. A query failure is (nil, false, err): indeterminate, and the guard denies. Collapsing the two would either turn a client typo into a 403 or let a database fault open the guard.
func (*Service) RolesForUser ¶
RolesForUser returns the active role ids assigned to the user. Deleted users return an empty slice (not an error) per spec AC-09.
func (*Service) SoftDelete ¶
SoftDelete sets deleted_at. The user becomes invisible to lookups per spec C-05. Username/email can be reused after delete via the partial-unique-index pattern from migration 0005.
Spec AC-07.
func (*Service) UnassignRole ¶
UnassignRole removes the link. Idempotent — second call returns nil (no "wasn't assigned" error).
Spec AC-10.
func (*Service) UpdatePassword ¶
UpdatePassword re-runs the policy validator and on success updates password_hash + bumps last_password_change_at.
Policy selection is derived from the user's primary role at change time: admin role → AdminPolicy (15-char minimum), any other role (or no role) → DefaultPolicy. Replaces the legacy users.is_admin column which had drift-prone semantics.
Spec AC-06.
func (*Service) UpdateProfile ¶ added in v0.4.0
UpdateProfile applies a partial profile edit for the user's own account (PATCH /auth/me). Email is the sign-in identity: it is trimmed, must be non-empty, and must be unique among active users — ErrEmailTaken (409) otherwise. Username, role, and password are not editable here. Returns the updated user.
func (*Service) UseTxSource ¶ added in v0.8.0
func (s *Service) UseTxSource(b identity.TxBeginner)
UseTxSource replaces the transaction source for the locked account transactions. It exists so tests can force a durable or non-durable unknown commit; production never calls it.
func (*Service) VerifyUserPassword ¶
VerifyUserPassword is the login-path helper. Looks up the hash and runs identity.VerifyPassword. Returns ErrUserNotFound if the user is deleted or unknown; identity's own error on bad password. Never returns the hash to the caller.
type User ¶
type User struct {
ID uuid.UUID
Username string
Email string
LastPasswordChangeAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
// DisabledAt is non-nil when the account is disabled (cannot
// authenticate). Distinct from a soft-delete: a disabled account is
// recoverable via Enable.
DisabledAt *time.Time
// Roles holds the role IDs assigned to the user (from user_roles).
// Populated by ListUsers via an aggregate; other lookups (login path,
// GetUserByID) leave it nil since they do not need the membership join.
Roles []string
// Self-service profile fields (migration 0050). Free text, may be
// empty. Populated by the queryOne lookups (GetUserByID / by-username);
// the login-path scan leaves them empty since login does not need them.
FullName string
DisplayName string
JobTitle string
Timezone string
Phone string
}
User is the safe shape returned by every read API. PasswordHash is intentionally NOT a field on this struct — spec C-01 says reads must never return it. Use the lower-level repository if you need it for password verification at login.
Admin status is derived from user_roles (presence of the "admin" role); no separate is_admin flag exists on the wire or the table. Callers needing to render "is this user an admin?" should consult RolesForUser or PrimaryRoleFor.