rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear
2
3import (
4 "errors"
5 "fmt"
6)
7
8// ErrForbidden is the sentinel every MayActOn refusal wraps. Callers
9// that only need the yes/no answer use errors.Is; the wrapped text
10// carries the reason for logs and error pages.
11var ErrForbidden = errors.New("idear: forbidden")
12
13// MayActOn reports whether actor may manage target — change target's
14// role, deactivate, or reactivate them. It is a pure function so the
15// full role matrix can be exhausted in a table test with no database
16// and no HTTP server.
17//
18// Four rules, in order, each closing a specific hole:
19//
20// 1. actor must be non-nil and active — a deactivated admin keeps
21// their row (removal is never a delete) but loses every privilege
22// the row once carried.
23// 2. actor must be at least Admin — a Member can see the roster but
24// never act on it.
25// 3. actor and target must be different people, compared by ID — an
26// Owner deactivating or demoting themselves is exactly how an
27// instance ends up with no one able to administer it.
28// 4. target's rank must be strictly below actor's — Admin manages
29// Member only, Owner manages Admin and Member, and nobody at any
30// rank manages an Owner. Equal rank is refused, not just higher
31// rank: two Owners or two Admins may never act on one another.
32func MayActOn(actor, target *Member) error {
33 if err := mayManage(actor); err != nil {
34 return err
35 }
36 if target == nil {
37 return fmt.Errorf("%w: no target", ErrForbidden)
38 }
39 if actor.ID == target.ID {
40 return fmt.Errorf("%w: cannot act on self", ErrForbidden)
41 }
42 if rank(target.Role) >= rank(actor.Role) {
43 return fmt.Errorf("%w: target's rank is not below actor's", ErrForbidden)
44 }
45 return nil
46}
47
48// mayManage is rules 1 and 2 of MayActOn on their own: the authority
49// FLOOR, with no target in it. It answers "is this actor allowed to
50// manage anything at all" — which is the question Invite and Revoke
51// ask (they have no target Member), and the question the store's other
52// mutations must ask FIRST, before any invariant check that names the
53// target.
54//
55// That ordering is what keeps ErrLastOwner from becoming a membership
56// oracle. Deactivate and SetRole refuse an Owner target with
57// ErrLastOwner rather than ErrForbidden, because "the owner cannot be
58// removed" is true for every actor and ErrForbidden would imply some
59// higher rank could do it. But a plain Member must still be refused
60// for lacking authority, not told which row is the Owner's — so the
61// floor is checked first and the target-shaped invariant second.
62func mayManage(actor *Member) error {
63 if actor == nil || !actor.Active() {
64 return fmt.Errorf("%w: actor is not an active member", ErrForbidden)
65 }
66 if !actor.Role.AtLeast(RoleAdmin) {
67 return fmt.Errorf("%w: actor must be at least admin", ErrForbidden)
68 }
69 return nil
70}
71