package idear import ( "errors" "fmt" ) // ErrForbidden is the sentinel every MayActOn refusal wraps. Callers // that only need the yes/no answer use errors.Is; the wrapped text // carries the reason for logs and error pages. var ErrForbidden = errors.New("idear: forbidden") // MayActOn reports whether actor may manage target — change target's // role, deactivate, or reactivate them. It is a pure function so the // full role matrix can be exhausted in a table test with no database // and no HTTP server. // // Four rules, in order, each closing a specific hole: // // 1. actor must be non-nil and active — a deactivated admin keeps // their row (removal is never a delete) but loses every privilege // the row once carried. // 2. actor must be at least Admin — a Member can see the roster but // never act on it. // 3. actor and target must be different people, compared by ID — an // Owner deactivating or demoting themselves is exactly how an // instance ends up with no one able to administer it. // 4. target's rank must be strictly below actor's — Admin manages // Member only, Owner manages Admin and Member, and nobody at any // rank manages an Owner. Equal rank is refused, not just higher // rank: two Owners or two Admins may never act on one another. func MayActOn(actor, target *Member) error { if err := mayManage(actor); err != nil { return err } if target == nil { return fmt.Errorf("%w: no target", ErrForbidden) } if actor.ID == target.ID { return fmt.Errorf("%w: cannot act on self", ErrForbidden) } if rank(target.Role) >= rank(actor.Role) { return fmt.Errorf("%w: target's rank is not below actor's", ErrForbidden) } return nil } // mayManage is rules 1 and 2 of MayActOn on their own: the authority // FLOOR, with no target in it. It answers "is this actor allowed to // manage anything at all" — which is the question Invite and Revoke // ask (they have no target Member), and the question the store's other // mutations must ask FIRST, before any invariant check that names the // target. // // That ordering is what keeps ErrLastOwner from becoming a membership // oracle. Deactivate and SetRole refuse an Owner target with // ErrLastOwner rather than ErrForbidden, because "the owner cannot be // removed" is true for every actor and ErrForbidden would imply some // higher rank could do it. But a plain Member must still be refused // for lacking authority, not told which row is the Owner's — so the // floor is checked first and the target-shaped invariant second. func mayManage(actor *Member) error { if actor == nil || !actor.Active() { return fmt.Errorf("%w: actor is not an active member", ErrForbidden) } if !actor.Role.AtLeast(RoleAdmin) { return fmt.Errorf("%w: actor must be at least admin", ErrForbidden) } return nil }