gitmote / internal / meta / users.go · history · branches
8338 bytes · raw
package meta
import (
"context"
"database/sql"
"time"
)
// User is a forge account. IsAdmin marks a global administrator (see
// storage.md) — distinct from a per-repo admin ACL.
type User struct {
ID int64
Handle string
IsAdmin bool
CreatedAt time.Time
}
// Token is a stored personal access token's metadata. Neither the raw token nor
// its verifier is exposed here — this is what the UI lists.
type Token struct {
ID int64
UserID int64
Label string
CreatedAt time.Time
LastUsed *time.Time
}
// TokenAuth is a token's stored verifier plus its owner and constraints,
// returned to the auth layer for constant-time verification and the scope/expiry
// checks (see internal/auth). ExpiresAt/RepoScope are nil when unconstrained.
type TokenAuth struct {
TokenID int64
Verifier string
User User
ExpiresAt *time.Time
RepoScope *int64
ReadOnly bool
}
// CreateUser inserts a regular (non-admin) user.
func (m *Metadata) CreateUser(ctx context.Context, handle string) (*User, error) {
return m.createUser(ctx, handle, false)
}
// CreateAdmin inserts a global administrator (users.is_admin = 1).
func (m *Metadata) CreateAdmin(ctx context.Context, handle string) (*User, error) {
return m.createUser(ctx, handle, true)
}
func (m *Metadata) createUser(ctx context.Context, handle string, isAdmin bool) (*User, error) {
created := now()
res, err := m.db.ExecContext(ctx,
`INSERT INTO users (handle, is_admin, created_at) VALUES (?, ?, ?)`,
handle, boolToInt(isAdmin), created)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return &User{ID: id, Handle: handle, IsAdmin: isAdmin, CreatedAt: parseTime(created)}, nil
}
// GetUser returns the user with the given handle, or ErrNotFound.
func (m *Metadata) GetUser(ctx context.Context, handle string) (*User, error) {
var (
u User
isAdmin int64
ts string
)
err := m.db.QueryRowContext(ctx,
`SELECT id, handle, is_admin, created_at FROM users WHERE handle = ?`, handle).
Scan(&u.ID, &u.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.CreatedAt = parseTime(ts)
return &u, nil
}
// GetUserByID returns the user with the given id, or ErrNotFound. The web UI
// resolves the session's user this way on every request, so a deleted or
// demoted account loses access immediately.
func (m *Metadata) GetUserByID(ctx context.Context, id int64) (*User, error) {
var (
u User
isAdmin int64
ts string
)
err := m.db.QueryRowContext(ctx,
`SELECT id, handle, is_admin, created_at FROM users WHERE id = ?`, id).
Scan(&u.ID, &u.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.CreatedAt = parseTime(ts)
return &u, nil
}
// ListUsers returns all users ordered by handle.
func (m *Metadata) ListUsers(ctx context.Context) ([]User, error) {
rows, err := m.db.QueryContext(ctx,
`SELECT id, handle, is_admin, created_at FROM users ORDER BY handle`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var (
u User
isAdmin int64
ts string
)
if err := rows.Scan(&u.ID, &u.Handle, &isAdmin, &ts); err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.CreatedAt = parseTime(ts)
users = append(users, u)
}
return users, rows.Err()
}
// AdminExists reports whether any global administrator exists — the signal
// bootstrap uses to refuse clobbering an already-initialized instance.
func (m *Metadata) AdminExists(ctx context.Context) (bool, error) {
var exists int64
err := m.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE is_admin = 1)`).Scan(&exists)
if err != nil {
return false, err
}
return exists != 0, nil
}
// CreateToken stores a personal access token for a user. selector is the token's
// public lookup key; verifier is the hash of its secret half (see internal/auth
// for the token format). Neither the raw token nor the secret is persisted.
func (m *Metadata) CreateToken(ctx context.Context, userID int64, selector, verifier, label string) (*Token, error) {
created := now()
res, err := m.db.ExecContext(ctx,
`INSERT INTO tokens (user_id, selector, verifier, label, created_at) VALUES (?, ?, ?, ?, ?)`,
userID, selector, verifier, label, created)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
}
// CreateScopedToken stores a token carrying optional constraints: repoScope (nil
// = all the owner's repos), readOnly (deny push), and expiresAt (zero = never).
// It backs the CI clone credential and any expiring/scoped PAT.
func (m *Metadata) CreateScopedToken(ctx context.Context, userID int64, selector, verifier, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (*Token, error) {
created := now()
var exp *string
if !expiresAt.IsZero() {
s := expiresAt.UTC().Format(time.RFC3339Nano)
exp = &s
}
res, err := m.db.ExecContext(ctx,
`INSERT INTO tokens (user_id, selector, verifier, label, created_at, expires_at, repo_scope, read_only)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
userID, selector, verifier, label, created, exp, repoScope, boolToInt(readOnly))
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
}
// TokenBySelector returns the verifier, owner, and constraints of the token with
// the given selector, or ErrNotFound. The auth layer compares the verifier in
// constant time and enforces the constraints; this method deliberately does not
// touch last_used (see TouchToken).
func (m *Metadata) TokenBySelector(ctx context.Context, selector string) (*TokenAuth, error) {
var (
ta TokenAuth
isAdmin int64
ts string
expiresAt sql.NullString
repoScope sql.NullInt64
readOnly int64
)
err := m.db.QueryRowContext(ctx,
`SELECT t.id, t.verifier, t.expires_at, t.repo_scope, t.read_only,
u.id, u.handle, u.is_admin, u.created_at
FROM tokens t JOIN users u ON u.id = t.user_id
WHERE t.selector = ?`, selector).
Scan(&ta.TokenID, &ta.Verifier, &expiresAt, &repoScope, &readOnly,
&ta.User.ID, &ta.User.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if expiresAt.Valid {
exp := parseTime(expiresAt.String)
ta.ExpiresAt = &exp
}
if repoScope.Valid {
ta.RepoScope = &repoScope.Int64
}
ta.ReadOnly = readOnly != 0
ta.User.IsAdmin = isAdmin != 0
ta.User.CreatedAt = parseTime(ts)
return &ta, nil
}
// TouchToken stamps a token's last_used with the current time. Called after a
// successful verification.
func (m *Metadata) TouchToken(ctx context.Context, tokenID int64) error {
_, err := m.db.ExecContext(ctx,
`UPDATE tokens SET last_used = ? WHERE id = ?`, now(), tokenID)
return err
}
// DeleteToken revokes a token by id. It is idempotent — deleting an absent
// token is not an error, so a double revoke from the UI is harmless.
func (m *Metadata) DeleteToken(ctx context.Context, tokenID int64) error {
_, err := m.db.ExecContext(ctx, `DELETE FROM tokens WHERE id = ?`, tokenID)
return err
}
// ListTokens returns a user's tokens (metadata only, never the hash) ordered by
// creation time.
func (m *Metadata) ListTokens(ctx context.Context, userID int64) ([]Token, error) {
rows, err := m.db.QueryContext(ctx,
`SELECT id, user_id, label, created_at, last_used
FROM tokens WHERE user_id = ? ORDER BY created_at, id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []Token
for rows.Next() {
var (
t Token
label sql.NullString
created string
lastUsed sql.NullString
)
if err := rows.Scan(&t.ID, &t.UserID, &label, &created, &lastUsed); err != nil {
return nil, err
}
t.Label = label.String
t.CreatedAt = parseTime(created)
if lastUsed.Valid {
lu := parseTime(lastUsed.String)
t.LastUsed = &lu
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}