The live instance had no way to add a passkey or a user. Two core pages now
make the signed-in experience usable (SPEC-010):
* the ACCOUNT page (GET /{GUID}/account) shows the user's factors (passkey
count · TOTP enrolled), an "Add a passkey" button that LINKS to the
existing D-041 ceremony screen — the WebAuthn flow is never duplicated —
and a "Don't ask again" control writing users.passkey_opt_out;
* the admin USERS page (GET /{GUID}/users) lists every user (name · email ·
role · manager · identity status) and creates one: only roles below the
creator (D-019), the manager defaulting to the creator, then users.Create
-> identities.Mint -> the /{GUID} invite is EMAILED and shown for
copy/paste.
Both ride the door as core dispatch modules, so the door still gates them
(session + role + CSRF). The entrypoint requires admin for the users
functions; account stays at the team floor.
Additive support: auth.Identities.TOTPEnrolled, users.Users.List,
server.Deps.Credentials (the banner's "has a passkey?" read) and the
CoreDispatchNames guard now covers account/users.
281 lines
7.8 KiB
Go
281 lines
7.8 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
|
)
|
|
|
|
// Store is the SQLite-backed Users service.
|
|
type Store struct {
|
|
db *sql.DB
|
|
audit audit.Audit
|
|
now func() time.Time
|
|
}
|
|
|
|
// New returns a Users service over db. aud may be nil (tests) — audit is then
|
|
// skipped rather than panicking.
|
|
func New(db *sql.DB, aud audit.Audit) *Store {
|
|
return &Store{db: db, audit: aud, now: time.Now}
|
|
}
|
|
|
|
var _ Users = (*Store)(nil)
|
|
|
|
// Create implements Users.Create (SPEC-004 §5, AC-6, D-019).
|
|
func (s *Store) Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID, error) {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
displayName = strings.TrimSpace(displayName)
|
|
if email == "" {
|
|
return 0, ErrEmailRequired
|
|
}
|
|
if displayName == "" {
|
|
return 0, ErrNameRequired
|
|
}
|
|
if !role.Valid() {
|
|
return 0, ErrInvalidRole
|
|
}
|
|
|
|
var manager Optional[UserID]
|
|
switch {
|
|
case creatorID == 0:
|
|
// Bootstrap (D-021/D-022): the first user IS the top admin.
|
|
n, err := s.Count(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n != 0 {
|
|
return 0, ErrBootstrapClosed
|
|
}
|
|
if role != RoleAdmin {
|
|
return 0, ErrBootstrapAdmin
|
|
}
|
|
manager = None[UserID]()
|
|
default:
|
|
creator, err := s.Get(ctx, creatorID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// One admin only — user 1 already exists (AC-6).
|
|
if role == RoleAdmin {
|
|
return 0, ErrOneAdmin
|
|
}
|
|
// Creation authority: a role strictly above the new user's (D-019).
|
|
if !creator.Role.Above(role) {
|
|
return 0, fmt.Errorf("%w: %s cannot create %s", ErrRoleNotAllowed, creator.Role, role)
|
|
}
|
|
manager = Some(creatorID)
|
|
}
|
|
|
|
createdAt := store.FormatTime(s.now())
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO users (email, display_name, role, manager_user_id, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
email, displayName, string(role), nullableID(manager), StatusActive, createdAt,
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return 0, fmt.Errorf("%w: %s", ErrDuplicateEmail, email)
|
|
}
|
|
return 0, fmt.Errorf("users: insert: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("users: last insert id: %w", err)
|
|
}
|
|
|
|
s.record(ctx, audit.Event{
|
|
ActorUserID: creatorID,
|
|
Action: "user.create",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("email=%s role=%s", email, role),
|
|
})
|
|
return id, nil
|
|
}
|
|
|
|
// Get implements Users.Get.
|
|
func (s *Store) Get(ctx context.Context, id int64) (User, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
|
FROM users WHERE user_id = ?`, id)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// List implements Users.List — every user, ordered by user_id so the page is
|
|
// deterministic (never map iteration, SPEC-010 §5).
|
|
func (s *Store) List(ctx context.Context) ([]User, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
|
FROM users ORDER BY user_id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("users: list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []User
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("users: iterate: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetRole implements Users.SetRole. The role ripples down (D-034); the
|
|
// one-admin invariant is preserved (AC-6) and user 1 is protected (A14).
|
|
func (s *Store) SetRole(ctx context.Context, id int64, role Role) error {
|
|
if !role.Valid() {
|
|
return ErrInvalidRole
|
|
}
|
|
switch {
|
|
case id == 1 && role != RoleAdmin:
|
|
return ErrTopAdmin
|
|
case id != 1 && role == RoleAdmin:
|
|
return ErrOneAdmin
|
|
}
|
|
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE users SET role = ? WHERE user_id = ?`, string(role), id); err != nil {
|
|
return fmt.Errorf("users: update role: %w", err)
|
|
}
|
|
// The actor of a role change is whoever the door authenticated, not the
|
|
// user being changed — it comes from the audit.Meta on ctx (R-1/R-3).
|
|
s.record(ctx, audit.Event{
|
|
Action: "user.set_role",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("role=%s", role),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Manager implements Users.Manager — the chain link above id.
|
|
func (s *Store) Manager(ctx context.Context, id int64) (Optional[UserID], error) {
|
|
var m sql.NullInt64
|
|
err := s.db.QueryRow(`SELECT manager_user_id FROM users WHERE user_id = ?`, id).Scan(&m)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return None[UserID](), ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return None[UserID](), fmt.Errorf("users: manager lookup: %w", err)
|
|
}
|
|
if m.Valid {
|
|
return Some(m.Int64), nil
|
|
}
|
|
return None[UserID](), nil
|
|
}
|
|
|
|
// Count returns the number of users. It answers "is the instance in setup
|
|
// mode?" (SPEC-005 §3: no user exists).
|
|
func (s *Store) Count(ctx context.Context) (int, error) {
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("users: count: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ByEmail returns the user with the given email (recovery lookup, SPEC-007).
|
|
// The match is case-insensitive.
|
|
func (s *Store) ByEmail(ctx context.Context, email string) (User, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
|
FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email)))
|
|
return scanUser(row)
|
|
}
|
|
|
|
// PasskeyOptOut implements Users.PasskeyOptOut (D-041): the user's stored
|
|
// preference about the post-login passkey nudge.
|
|
func (s *Store) PasskeyOptOut(ctx context.Context, id int64) (bool, error) {
|
|
var optOut int
|
|
err := s.db.QueryRow(`SELECT passkey_opt_out FROM users WHERE user_id = ?`, id).Scan(&optOut)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("users: passkey opt-out lookup: %w", err)
|
|
}
|
|
return optOut != 0, nil
|
|
}
|
|
|
|
// SetPasskeyOptOut implements Users.SetPasskeyOptOut (D-041). It is a plain
|
|
// preference write — no one-admin or role rule applies — and is audited (A11).
|
|
// The actor comes from the context (ruling R-1); the user is the subject.
|
|
func (s *Store) SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error {
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
v := 0
|
|
if optOut {
|
|
v = 1
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE users SET passkey_opt_out = ? WHERE user_id = ?`, v, id); err != nil {
|
|
return fmt.Errorf("users: set passkey opt-out: %w", err)
|
|
}
|
|
s.record(ctx, audit.Event{
|
|
Action: "user.passkey_opt_out",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("passkey_opt_out=%t", optOut),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// rowScanner is satisfied by *sql.Row and *sql.Rows.
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanUser(row rowScanner) (User, error) {
|
|
var (
|
|
u User
|
|
role string
|
|
manager sql.NullInt64
|
|
optOut int
|
|
created string
|
|
)
|
|
err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &role, &manager, &optOut, &u.Status, &created)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("users: scan: %w", err)
|
|
}
|
|
u.Role = Role(role)
|
|
u.PasskeyOptOut = optOut != 0
|
|
if manager.Valid {
|
|
u.Manager = Some(manager.Int64)
|
|
}
|
|
if u.CreatedAt, err = store.ParseTime(created); err != nil {
|
|
return User{}, fmt.Errorf("users: parse created_at: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func nullableID(o Optional[UserID]) any {
|
|
if !o.Set {
|
|
return nil
|
|
}
|
|
return o.Value
|
|
}
|
|
|
|
func (s *Store) record(ctx context.Context, e audit.Event) {
|
|
if s.audit != nil {
|
|
s.audit.Record(ctx, e)
|
|
}
|
|
}
|
|
|
|
// isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure.
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
|
}
|