125 lines
4.7 KiB
Go
125 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// AdminConsoleUser is one named panel operator. It deliberately never carries
|
|
// the password hash outside authentication: everything that renders or returns
|
|
// a user uses this shape, so a hash cannot leak into an API response by
|
|
// someone adding a field to a JSON struct.
|
|
type AdminConsoleUser struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Permissions []string `json:"permissions"`
|
|
Enabled bool `json:"enabled"`
|
|
TokenEpoch int32 `json:"token_epoch"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
}
|
|
|
|
// adminConsoleCredential is the authentication-only view: the hash plus the
|
|
// few fields a login decision needs. Kept unexported and separate from
|
|
// AdminConsoleUser so the hash has exactly one reason to be read.
|
|
type adminConsoleCredential struct {
|
|
ID int64
|
|
Username string
|
|
PasswordHash string
|
|
Permissions []string
|
|
Enabled bool
|
|
TokenEpoch int32
|
|
}
|
|
|
|
// errAdminUserNotFound is returned instead of pgx.ErrNoRows so callers can
|
|
// treat "no such operator" without importing pgx.
|
|
var errAdminUserNotFound = errors.New("admin console user not found")
|
|
|
|
const adminConsoleUserColumns = `id, username, permissions, enabled, token_epoch, created_at, updated_at, last_login_at`
|
|
|
|
// AdminConsoleCredentialByUsername loads the authentication view for a login
|
|
// attempt. The lookup is case-insensitive to match the unique index, so an
|
|
// operator cannot be shadowed by a differently-cased duplicate.
|
|
func (s *readStore) AdminConsoleCredentialByUsername(ctx context.Context, username string) (adminConsoleCredential, error) {
|
|
var out adminConsoleCredential
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, username, password_hash, permissions, enabled, token_epoch
|
|
FROM admin_console_users
|
|
WHERE lower(username) = lower($1)`, strings.TrimSpace(username)).Scan(
|
|
&out.ID, &out.Username, &out.PasswordHash, &out.Permissions, &out.Enabled, &out.TokenEpoch)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return adminConsoleCredential{}, errAdminUserNotFound
|
|
}
|
|
if err != nil {
|
|
return adminConsoleCredential{}, fmt.Errorf("load admin console credential: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AdminConsoleSessionState re-reads the two things a live session depends on.
|
|
// requireAuthAPI calls it per request so that disabling an operator, editing
|
|
// their rights or changing their password takes effect immediately rather than
|
|
// whenever their signed cookie happens to expire.
|
|
func (s *readStore) AdminConsoleSessionState(ctx context.Context, id int64) (enabled bool, epoch int32, permissions []string, err error) {
|
|
err = s.pool.QueryRow(ctx, `
|
|
SELECT enabled, token_epoch, permissions FROM admin_console_users WHERE id = $1`, id).
|
|
Scan(&enabled, &epoch, &permissions)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, 0, nil, errAdminUserNotFound
|
|
}
|
|
if err != nil {
|
|
return false, 0, nil, fmt.Errorf("load admin console session state: %w", err)
|
|
}
|
|
return enabled, epoch, permissions, nil
|
|
}
|
|
|
|
// ListAdminConsoleUsers returns every operator, newest last so the list reads
|
|
// like the order they were added.
|
|
func (s *readStore) ListAdminConsoleUsers(ctx context.Context) ([]AdminConsoleUser, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+adminConsoleUserColumns+` FROM admin_console_users ORDER BY id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list admin console users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []AdminConsoleUser{}
|
|
for rows.Next() {
|
|
var u AdminConsoleUser
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled,
|
|
&u.TokenEpoch, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt); err != nil {
|
|
return nil, fmt.Errorf("scan admin console user: %w", err)
|
|
}
|
|
if u.Permissions == nil {
|
|
u.Permissions = []string{}
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate admin console users: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CountEnabledAdminConsoleUsersWith reports how many enabled operators hold a
|
|
// given permission, counting the '*' wildcard as holding everything. It exists
|
|
// for the last-administrator guard: the panel refuses the edit that would
|
|
// leave nobody able to manage operators.
|
|
func (s *readStore) CountEnabledAdminConsoleUsersWith(ctx context.Context, permission string, excludeID int64) (int, error) {
|
|
var n int
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT count(*)::int FROM admin_console_users
|
|
WHERE enabled
|
|
AND id <> $2
|
|
AND (permissions @> ARRAY[$1]::text[] OR permissions @> ARRAY['*']::text[])`,
|
|
permission, excludeID).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("count admin console users with permission: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|