CI / test (push) Successful in 1m13s
So someone can try a model without setting up a client, and — more often — tell whether a problem is the gateway or their code. The page carries a session, and the server turns that into the identity an API key would have produced, resolved from the person's project. The request then goes down the ordinary path by being handed to the same handler /v1 uses: admission, model allowlist, rate limits, budget, accounting. There is exactly one request path, so there is no second place for its rules to drift — and without that, a playground is a hole in every limit the rest of the gateway enforces. The allowlist test posts a forbidden model and expects 403 for that reason. The body is rebuilt rather than forwarded, so the page cannot set fields it has no business setting: a stream:false that would hang the reader, a max_tokens large enough to occupy a replica for an hour. One turn is capped at 512 tokens. The picker offers what is actually served and allowed, not what is catalogued, because offering a model with no replica produces a 503 the reader cannot interpret. Inline vanilla JavaScript, for the same reason the charts are hand-drawn SVG. It streams, because on CPU inference ten seconds is normal and a chat that hangs and then dumps a paragraph feels broken. Replies are written with textContent throughout: model output and user input have no business being parsed as markup. Its POSTs carry the CSRF token in a header, which a cross-site form cannot set at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
9.8 KiB
Go
297 lines
9.8 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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 *SQLite) 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
|
|
}
|