// 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) // List returns every user, ordered by user_id (stable). It is the admin // Users page's read (SPEC-010 §5). List(ctx context.Context) ([]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") )