- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
168 lines
5.9 KiB
Go
168 lines
5.9 KiB
Go
// Package users is MyDay's user service: the stable person record, the role
|
|
// ladder, and the manager chain (PROJECT_PLAN.md §7, D-020/D-024/D-034).
|
|
//
|
|
// The user is the stable identity anchor; credentials hang off a rotating
|
|
// identity owned by the auth package (D-008). This package never touches
|
|
// credentials.
|
|
//
|
|
// Spec: docs/specs/spec-004-identity-sessions.md §3, §5.
|
|
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Role is a user's single tier in the ladder admin > director > manager > team
|
|
// (D-034: one role per user, no ACL — the role ripples down).
|
|
type Role string
|
|
|
|
const (
|
|
RoleAdmin Role = "admin"
|
|
RoleDirector Role = "director"
|
|
RoleManager Role = "manager"
|
|
RoleTeam Role = "team"
|
|
)
|
|
|
|
// roleRank orders the ladder. A higher number implies every lower role.
|
|
var roleRank = map[Role]int{
|
|
RoleTeam: 1,
|
|
RoleManager: 2,
|
|
RoleDirector: 3,
|
|
RoleAdmin: 4,
|
|
}
|
|
|
|
// Valid reports whether r is one of the four defined roles.
|
|
func (r Role) Valid() bool {
|
|
_, ok := roleRank[r]
|
|
return ok
|
|
}
|
|
|
|
// Rank returns the ladder position (higher = more authority); 0 for invalid.
|
|
func (r Role) Rank() int { return roleRank[r] }
|
|
|
|
// Above reports whether r is strictly higher than other on the ladder.
|
|
func (r Role) Above(other Role) bool { return r.Rank() > other.Rank() }
|
|
|
|
// String renders the role.
|
|
func (r Role) String() string { return string(r) }
|
|
|
|
// ParseRole validates a stored/entered role string.
|
|
func ParseRole(s string) (Role, error) {
|
|
r := Role(s)
|
|
if !r.Valid() {
|
|
return "", fmt.Errorf("%w: %q", ErrInvalidRole, s)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Optional carries a value that may be absent (Go has no nullable generics).
|
|
// It models the manager chain: the top admin has NO manager (D-021).
|
|
type Optional[T any] struct {
|
|
Value T
|
|
Set bool
|
|
}
|
|
|
|
// Some returns a set Optional.
|
|
func Some[T any](v T) Optional[T] { return Optional[T]{Value: v, Set: true} }
|
|
|
|
// None returns an unset Optional.
|
|
func None[T any]() Optional[T] { return Optional[T]{} }
|
|
|
|
// UserID is the internal, stable person id. The first user is 1 (D-013).
|
|
type UserID = int64
|
|
|
|
// User statuses. A disabled user keeps their record and history but may not act.
|
|
const (
|
|
StatusActive = "active"
|
|
StatusDisabled = "disabled"
|
|
)
|
|
|
|
// CanApprove reports whether approver may decide a recovery request for
|
|
// requester (SPEC-007 §4, ruling R-7). The authority is the CHAIN, not the
|
|
// ladder: the approver must be an ANCESTOR of the requester — on their manager
|
|
// line, upward — or the admin. The requester may never approve their own
|
|
// request (AC-2).
|
|
//
|
|
// approverIsAncestor is computed by the caller, which owns the chain lookup
|
|
// (users.Users.Manager); this predicate stays pure. A role merely *above* the
|
|
// requester is NOT sufficient — that was R-7's correction: an unrelated
|
|
// director must not be able to recover a team member outside their subtree.
|
|
func CanApprove(approver, requester User, approverIsAncestor bool) bool {
|
|
if approver.ID == requester.ID {
|
|
return false
|
|
}
|
|
if approver.Role == RoleAdmin {
|
|
return true // the admin is the root of the chain and approves anyone (§4)
|
|
}
|
|
return approverIsAncestor
|
|
}
|
|
|
|
// User is the stable person record (SPEC-004 §4 `users`).
|
|
type User struct {
|
|
ID UserID
|
|
Email string
|
|
DisplayName string
|
|
Role Role
|
|
// Manager is the chain link: who approves this user (D-020). Unset only
|
|
// for the top admin.
|
|
Manager Optional[UserID]
|
|
// PasskeyOptOut records that the user declined the passkey nudge (D-041).
|
|
// It is a USER preference (not a credential), so it survives identity
|
|
// rotation (D-008): an opted-out user is never re-nudged after recovery.
|
|
PasskeyOptOut bool
|
|
Status string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Users is the service contract (SPEC-004 §5).
|
|
//
|
|
// Per ruling R-1 every method takes the request context: the door attaches
|
|
// audit.Meta (actor · client IP · correlation) before calling, and each
|
|
// mutation writes its audit row through it (A11).
|
|
type Users interface {
|
|
// Create creates a user below the creator on the chain.
|
|
//
|
|
// * creatorID != 0 — the creator must hold a role strictly ABOVE role
|
|
// (D-019/§7.3); the new user's manager defaults to the creator. A
|
|
// second admin is always rejected (AC-6).
|
|
// * creatorID == 0 — bootstrap: allowed ONLY while no user exists, and
|
|
// it must create the top admin (user 1, role admin, manager NULL).
|
|
Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error)
|
|
|
|
// Get returns one user.
|
|
Get(ctx context.Context, id int64) (User, error)
|
|
|
|
// SetRole changes a user's role (ripple-down model, D-034). It preserves
|
|
// the one-admin invariant: only user 1 may be admin, and user 1 may not be
|
|
// demoted (A14).
|
|
SetRole(ctx context.Context, id int64, role Role) error
|
|
|
|
// Manager returns the user above id in the chain.
|
|
Manager(ctx context.Context, id int64) (Optional[UserID], error)
|
|
|
|
// PasskeyOptOut reports whether the user has declined the passkey nudge
|
|
// (D-041). The marker is a USER preference, so it survives identity
|
|
// rotation.
|
|
PasskeyOptOut(ctx context.Context, id int64) (bool, error)
|
|
|
|
// SetPasskeyOptOut records the user's choice about the passkey nudge
|
|
// (D-041) and audits it (A11).
|
|
SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error
|
|
}
|
|
|
|
// Sentinel errors.
|
|
var (
|
|
ErrNotFound = errors.New("users: user not found")
|
|
ErrInvalidRole = errors.New("users: invalid role")
|
|
ErrRoleNotAllowed = errors.New("users: creator's role is not above the new user's role")
|
|
ErrOneAdmin = errors.New("users: exactly one admin is allowed")
|
|
ErrTopAdmin = errors.New("users: the top admin (user 1) cannot be demoted")
|
|
ErrDuplicateEmail = errors.New("users: email already in use")
|
|
ErrEmailRequired = errors.New("users: email is required")
|
|
ErrNameRequired = errors.New("users: display name is required")
|
|
ErrBootstrapClosed = errors.New("users: bootstrap is only allowed while no users exist")
|
|
ErrBootstrapAdmin = errors.New("users: the bootstrap user must be the admin")
|
|
)
|