auth.ldap checks a username and password against the domain controllers over LDAPS or StartTLS: found with a service account, checked by binding as the person. Groups, nested ones included, give staff roles and places in the tree. Accounts are tied to the objectGUID; the leader reads the directory every 15 minutes, suspends whom it disabled or removed and reinstates them after, and refuses a sync that would suspend most. Entra ID sends no groups for someone in more than 200, only where to fetch them; that was read as none and took an administrator's role away. It is now left as it was, and logged. An account made by SCIM or OIDC can be reinstated again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
363 lines
12 KiB
Go
363 lines
12 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 {
|
|
// An account nobody approved is approved, not set active: that is what
|
|
// records who let it in. One an identity provider, SCIM or a directory
|
|
// made — active from the start, its password kept there ("!oidc",
|
|
// "!scim", "!ldap"), with no approval to record — is set active again
|
|
// when its provider reinstates it; refusing that left such accounts
|
|
// suspended for good.
|
|
if state == UserActive {
|
|
var approved sql.NullInt64
|
|
var hash string
|
|
if err := s.db.QueryRowContext(ctx, `SELECT approved_at, password_hash FROM users WHERE id = ?`, id).
|
|
Scan(&approved, &hash); err == nil && !approved.Valid && !strings.HasPrefix(hash, "!") {
|
|
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
|
|
}
|