Files
llmgw/internal/store/users.go
T
2026-10-04 16:30:56 +02:00

356 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// UserState is where an account is in its life.
type UserState string
// The account states. A new account is pending until an operator approves it,
// which is what keeps a public signup form from being a way in.
const (
UserPending UserState = "pending"
UserActive UserState = "active"
UserSuspended UserState = "suspended"
)
// ErrEmailTaken says an address already has an account.
//
// It is returned to callers that need to know — the CLI — and deliberately not
// surfaced to a signup form, where it would confirm which addresses have
// accounts here.
var ErrEmailTaken = errors.New("that address already has an account")
// User is a person.
type User struct {
ID string
Email string
Name string
PasswordHash string
State UserState
// ProjectID is assigned at approval and decides which limits the person's
// keys inherit. Empty while pending.
ProjectID string
CreatedAt time.Time
ApprovedAt time.Time
ApprovedBy string
LastLoginAt time.Time
// IsAdmin lets this account administer: every page under /admin, and
// every change there.
IsAdmin bool
// Role is a lesser part in running the gateway: RoleViewer looks at
// every administration page, RoleOperator also runs the cluster from them
// (models, the mirror, nodes), RoleBilling sees usage and quotas. Empty
// for everybody else.
Role string
}
// Staff roles.
const (
RoleViewer = "viewer"
RoleOperator = "operator"
RoleBilling = "billing"
RoleAdmin = "admin"
)
// StaffRoles are the roles SetStaffRole takes; "" takes them all away.
var StaffRoles = []string{RoleViewer, RoleOperator, RoleBilling, RoleAdmin}
// Staff reports whether a person has any part in running the gateway.
func (u User) Staff() bool { return u.IsAdmin || u.Role != "" }
// StaffRole is the role a person holds, admin included.
func (u User) StaffRole() string {
if u.IsAdmin {
return RoleAdmin
}
return u.Role
}
// CanSignIn reports whether this account may open a session.
func (u User) CanSignIn() bool { return u.State == UserActive }
// NormalizeEmail is applied at every boundary, so two accounts cannot differ
// only in case — which would be two accounts to the database and one to every
// human who looks at them.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
const userColumns = `id, email, name, password_hash, state, COALESCE(project_id, ''),
created_at, approved_at, approved_by, last_login_at, is_admin, staff_role`
// CreateUser records a new, unapproved account.
func (s *DB) CreateUser(ctx context.Context, u User) error {
u.Email = NormalizeEmail(u.Email)
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, name, password_hash, state, project_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Email, u.Name, u.PasswordHash, string(u.State),
nullString(u.ProjectID), millis(u.CreatedAt))
if isUniqueViolation(err) {
return ErrEmailTaken
}
return err
}
// isUniqueViolation reports whether err is a row refused for a value another
// row already has. Each database says it its own way: SQLite in words
// ("UNIQUE constraint failed"), PostgreSQL by code, 23505, in a message with
// "unique" in lower case — which a check for the upper-case word missed, so a
// second sign-up with an address on PostgreSQL was "something went wrong".
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
var pe *pgconn.PgError
if errors.As(err, &pe) {
return pe.Code == "23505"
}
m := err.Error()
return strings.Contains(m, "UNIQUE constraint failed") || strings.Contains(m, "SQLSTATE 23505")
}
// UserByEmail finds an account by its address.
func (s *DB) UserByEmail(ctx context.Context, email string) (User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email)))
}
// UserByID finds an account.
func (s *DB) UserByID(ctx context.Context, id string) (User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE id = ?`, id))
}
// Users lists accounts, optionally filtered by state.
func (s *DB) Users(ctx context.Context, state UserState) ([]User, error) {
query := `SELECT ` + userColumns + ` FROM users`
var args []any
if state != "" {
query += ` WHERE state = ?`
args = append(args, string(state))
}
query += ` ORDER BY created_at`
return queryAll(ctx, s.db, query, args, func(rows *sql.Rows) (User, error) {
return s.scanUser(rows)
})
}
// ApproveUser activates an account, and puts it in a project when one is given.
//
// A project is not required: a person need not belong to anything to use the
// gateway on their own, bounded by their personal limits. Memberships can be
// added at approval or at any time after.
func (s *DB) ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error {
return s.inTx(ctx, func(c conn) error {
res, err := c.ExecContext(ctx,
`UPDATE users SET state = ?, approved_at = ?, approved_by = ? WHERE id = ? AND state = ?`,
string(UserActive), millis(at), by, id, string(UserPending))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if projectID == "" {
return nil
}
_, err = c.ExecContext(ctx, `
INSERT INTO memberships (user_id, scope_type, scope_id, role, added_at, added_by)
VALUES (?, 'project', ?, 'member', ?, ?)
ON CONFLICT (user_id, scope_type, scope_id) DO NOTHING`,
id, projectID, millis(at), nullString(by))
return err
})
}
// SetUserState suspends or reinstates an account.
//
// Only an account that was once approved can be reinstated. Otherwise a
// signup suspended while pending and then reinstated would become active
// without anybody approving it.
func (s *DB) SetUserState(ctx context.Context, id string, state UserState) error {
if state == UserActive {
var approved sql.NullInt64
if err := s.db.QueryRowContext(ctx, `SELECT approved_at FROM users WHERE id = ?`, id).
Scan(&approved); err == nil && !approved.Valid {
return errors.New("this account was never approved; approve it instead")
}
}
res, err := s.db.ExecContext(ctx, `UPDATE users SET state = ? WHERE id = ?`, string(state), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetAdmin grants or withdraws access to the cluster dashboard.
//
// Withdrawing does not close their sessions: the page is read-only, so the
// worst a stale session buys is a few more seconds of looking. Suspending the
// account is what closes sessions, and that is the lever for someone who should
// not be here at all.
func (s *DB) SetAdmin(ctx context.Context, id string, admin bool) error {
// An integer, not a bool: the column is an integer on both dialects,
// and PostgreSQL refuses a bool for it where SQLite took one — so making
// an administrator worked in every test and failed on a fresh install.
flag := 0
if admin {
flag = 1
}
res, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ? WHERE id = ?`, flag, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetStaffRole gives a person a part in running the gateway — viewer,
// operator, billing or admin — or, with "", none.
func (s *DB) SetStaffRole(ctx context.Context, id, role string) error {
admin, rest := 0, role
switch role {
case RoleAdmin:
admin, rest = 1, ""
case "", RoleViewer, RoleOperator, RoleBilling:
default:
return fmt.Errorf("role %q is not one of viewer, operator, billing, admin", role)
}
res, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ?, staff_role = ? WHERE id = ?`, admin, rest, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchLogin records a successful sign-in.
func (s *DB) TouchLogin(ctx context.Context, id string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`,
millis(at), id)
return err
}
// KeysForUser lists the keys a person made for themselves.
func (s *DB) KeysForUser(ctx context.Context, userID string) ([]Key, error) {
return queryAll(ctx, s.db,
`SELECT `+keyColumns+` FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
[]any{userID}, func(rows *sql.Rows) (Key, error) { return scanKey(rows) })
}
// CreateUserKey issues a key owned by a person.
//
// With a project, the key is used there and the person must be able to use
// that project; without one it is personal, bounded by the person's own limits.
func (s *DB) CreateUserKey(ctx context.Context, userID string, k Key) error {
if k.ProjectID != "" {
ok, err := s.CanUseProject(ctx, userID, k.ProjectID)
if err != nil {
return err
}
if !ok {
return ErrNotMember
}
}
k.UserID = userID
return s.CreateKey(ctx, k)
}
// RevokeUserKey revokes a key only if it belongs to this person.
//
// The ownership check is in the statement rather than in a prior read: two
// queries would leave a window where a key changes hands between them, and more
// importantly it would be one forgotten check away from letting anyone revoke
// anyone's key by id.
func (s *DB) RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ?
WHERE id = ? AND user_id = ? AND revoked_at IS NULL`,
millis(at), keyID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *DB) scanUser(sc scanner) (User, error) {
var u User
var state string
var created int64
var approved, lastLogin sql.NullInt64
var approvedBy sql.NullString
if err := sc.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin, &u.Role); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, err
}
u.State = UserState(state)
u.CreatedAt = fromMillis(created)
u.ApprovedAt = fromNullMillis(approved)
u.ApprovedBy = approvedBy.String
u.LastLoginAt = fromNullMillis(lastLogin)
return u, nil
}
// SetPassword replaces an account's password hash.
//
// It does not close sessions; that is the caller's decision, because the two
// reasons to change a password want different things. Someone rotating theirs
// wants their other devices signed out. An operator resetting a forgotten one
// wants the same, but also has nobody to keep signed in.
func (s *DB) SetPassword(ctx context.Context, userID, hash string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`,
hash, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// AllProjects lists every project with the path that identifies it.
//
// For the approval form, which has to offer somewhere to put a new account's
// keys. Walking the tree in the page would mean three round trips per render.
func (s *DB) AllProjects(ctx context.Context) ([]ProjectPath, error) {
return queryAll(ctx, s.db, `
SELECT p.id, o.name || ' / ' || t.name || ' / ' || p.name
FROM projects p
JOIN teams t ON t.id = p.team_id
JOIN organizations o ON o.id = t.org_id
ORDER BY 2`, nil, func(rows *sql.Rows) (ProjectPath, error) {
var p ProjectPath
return p, rows.Scan(&p.ID, &p.Path)
})
}
// ProjectPath is a project and where it sits in the tenancy tree.
type ProjectPath struct {
ID, Path string
}