Two things that were only ever confusing, and one of them stopped a command working on the cluster. The store type was named SQLite and kept the name when PostgreSQL was added, so OpenPostgres returned a *SQLite and all seventy-odd methods on the production store were spelled after the database it was not running on. Nothing broke; people did. Earlier today I added three methods to *SQLite for a table that on this cluster only ever lives in PostgreSQL, and the name did not once look wrong. It is a field, which is all it ever was. `llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a deployment whose store is PostgreSQL every one of them failed with "unable to open database file" while the right answer sat in auth.store in the config file beside them; the conclusion an operator draws is that the tool is broken, and they are not far off. They now ask the server's own configuration before falling back, and the error names which of the three sources chose the address — the failure was never the path, it was which one picked it. The single-machine default still applies last, so a deployment with no config file keeps working with no flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
9.7 KiB
Go
297 lines
9.7 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 places it in a project.
|
|
//
|
|
// The project is required: an approved account with nowhere to put its keys
|
|
// would pass every check and then fail at the first key it tried to make.
|
|
func (s *DB) ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error {
|
|
if projectID == "" {
|
|
return errors.New("approving an account needs a project for its keys")
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE users SET state = ?, project_id = ?, approved_at = ?, approved_by = ?
|
|
WHERE id = ? AND state = ?`,
|
|
string(UserActive), projectID, millis(at), by, id, string(UserPending))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetUserState suspends or reinstates an account.
|
|
func (s *DB) SetUserState(ctx context.Context, id string, state UserState) error {
|
|
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.
|
|
func (s *DB) CreateUserKey(ctx context.Context, userID string, k Key) error {
|
|
if len(k.Hash) == 0 {
|
|
return errors.New("store: a key needs a hash")
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO api_keys (`+keyColumns+`, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
k.ID, k.Hash, k.Prefix, k.Name, k.ProjectID,
|
|
nullString(k.Band), nullString(k.RoutingProfile),
|
|
millis(k.CreatedAt), nullMillis(k.ExpiresAt), nullMillis(k.RevokedAt),
|
|
nullMillis(k.LastUsedAt), userID)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ResolveProject returns the limits an account inherits from its project.
|
|
//
|
|
// The same walk as Resolve, minus the key. A person signed in to the chat page
|
|
// has no key in their hand — the key was shown once and is theirs to keep — so
|
|
// their identity there is their project, and it must be resolved the same way
|
|
// or the page would be a hole in the limits everything else obeys.
|
|
func (s *DB) ResolveProject(ctx context.Context, projectID string) (Effective, error) {
|
|
var e Effective
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT p.id, t.id, o.id
|
|
FROM projects p
|
|
JOIN teams t ON t.id = p.team_id
|
|
JOIN organizations o ON o.id = t.org_id
|
|
WHERE p.id = ?`, projectID)
|
|
if err := row.Scan(&e.ProjectID, &e.TeamID, &e.OrgID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Effective{}, ErrNotFound
|
|
}
|
|
return Effective{}, err
|
|
}
|
|
|
|
// Widest first, so a narrower scope can only tighten what it inherits.
|
|
for _, step := range []struct {
|
|
scope Scope
|
|
id string
|
|
}{{ScopeOrg, e.OrgID}, {ScopeTeam, e.TeamID}, {ScopeProject, e.ProjectID}} {
|
|
l, err := s.LimitsFor(ctx, step.scope, step.id)
|
|
if errors.Is(err, ErrNotFound) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return Effective{}, err
|
|
}
|
|
e.tighten(l)
|
|
}
|
|
return e, nil
|
|
}
|