users

package
v0.8.4 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

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

Constants

This section is empty.

Variables

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

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

type PermissionValidator func(perm string) bool

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 Role

type Role struct {
	ID          string
	Description string
	IsBuiltIn   bool
	Permissions []string
}

Role is the on-wire shape for built-in and custom roles.

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

func (s *Service) AdminResetPassword(ctx context.Context, id uuid.UUID, newPassword string) error

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

func (s *Service) CreateUser(ctx context.Context, p CreateParams) (User, error)

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

func (s *Service) Disable(ctx context.Context, id uuid.UUID) error

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

func (s *Service) DisableUser(ctx context.Context, id uuid.UUID) (User, error)

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

func (s *Service) Enable(ctx context.Context, id uuid.UUID) (transitioned bool, err error)

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

func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error)

GetUserByID returns the user when active; ErrUserNotFound for unknown or soft-deleted IDs.

Spec AC-04.

func (*Service) GetUserByUsername

func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, error)

GetUserByUsername returns the user when active; ErrUserNotFound for unknown or soft-deleted usernames.

Spec AC-05.

func (*Service) ListUsers

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

ListUsers returns all active users. Slice A keeps the result flat — cursor pagination lands when scan volumes make it necessary.

func (*Service) PrimaryRoleFor

func (s *Service) PrimaryRoleFor(ctx context.Context, userID uuid.UUID) (auth.RoleID, error)

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

func (s *Service) RoleForUser(ctx context.Context, userID uuid.UUID) (auth.RoleID, error)

RoleForUser is the identity.Lookups adapter. Direct synonym for PrimaryRoleFor with the same signature shape.

func (*Service) RolePermissions added in v0.7.0

func (s *Service) RolePermissions(ctx context.Context, roleID string) ([]string, bool, error)

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

func (s *Service) RolesForUser(ctx context.Context, userID uuid.UUID) ([]auth.RoleID, error)

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

func (s *Service) SoftDelete(ctx context.Context, id uuid.UUID) error

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

func (s *Service) UnassignRole(ctx context.Context, userID uuid.UUID, role auth.RoleID) error

UnassignRole removes the link. Idempotent — second call returns nil (no "wasn't assigned" error).

Spec AC-10.

func (*Service) UpdatePassword

func (s *Service) UpdatePassword(ctx context.Context, id uuid.UUID, newPassword string) error

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

func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (User, error)

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

func (s *Service) VerifyUserPassword(ctx context.Context, username, password string) (User, error)

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.

Jump to

Keyboard shortcuts

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