Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
356 lines
11 KiB
Go
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
|
|
}
|