Files
llmgw/internal/store/users.go
T
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
A person was in exactly one project, chosen at approval, and could not
use the gateway before that. People belong to several teams and to
none, so:

- memberships: a person is a member of any number of organizations,
  teams and projects, or of none; a member of a team or organization
  may use every project in it. users join/leave, and the people page
  adds and takes out from either side. Approval no longer needs a
  project.
- personal keys: a key may have an owner and no project. /profile asks
  what a new key spends against; keys create --for does the same.
  auth.personal sets default limits for personal use or turns it off.
- limits for people: --scope user holds a person across every key,
  project and the chat page; --per-member on an org, team or project
  gives each member their own share there. Resolution folds the tree,
  each per-member limit for the key's owner, and the owner's own, each
  in its own pool; the quotas page shows and sets both.
- the chat page asks what to spend against and checks it per message.
- usage records who spent it, so a person's budget survives a restart,
  personal use and chat included; restore no longer drops rows with no
  project.

Leaving a project revokes the keys made there unless another membership
still reaches it. A personal key keeps no threads or notes, which are
per project, and is refused rather than dropped into the anonymous pool.

Migration 0010 turns each person's project into a membership, adds
usage.user_id and backfills it, and makes api_keys.project_id nullable.
SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign
keys off, which only works outside a transaction, so migrations marked
for it run on one connection with foreign_key_check before commit.
Tested on a populated SQLite store with claims, and on a copy of the
cluster's PostgreSQL store: 5 people became members, 17 keys and 3
claims intact, 145 of 577 usage rows and all 69 chat rows attributed.
The PostgreSQL store tests, including new membership cases, pass on 16.

users move is gone; join and leave replace it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:03:46 +02:00

281 lines
9.2 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"strings"
"time"
)
// 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 see the cluster dashboard. It is not a role
// system: one bit, and everything that changes the cluster stays on the CLI.
IsAdmin bool
}
// 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`
// 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 err != nil && strings.Contains(err.Error(), "UNIQUE") {
return ErrEmailTaken
}
return err
}
// 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 {
res, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ? WHERE id = ?`, admin, 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); 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
}