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// Five 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 must not be the Owner — nobody at any rank manages an
29// Owner, in either PeerGrants mode. Ownership moves only by
30// Transfer.
31// 5. target must be within actor's reach (see reaches). By default
32// that is strictly below: Admin manages Member only, Owner manages
33// Admin and Member, and two Admins may never act on one another.
34// Built with PeerGrants, equal rank is within reach too, so an
35// Admin manages other Admins.
36func MayActOn(actor, target *Member) error {
37 if err := mayManage(actor); err != nil {
38 return err
39 }
40 if target == nil {
41 return fmt.Errorf("%w: no target", ErrForbidden)
42 }
43 if actor.ID == target.ID {
44 return fmt.Errorf("%w: cannot act on self", ErrForbidden)
45 }
46 if target.Role == RoleOwner {
47 return fmt.Errorf("%w: nobody acts on the owner", ErrForbidden)
48 }
49 if !reaches(actor.Role, target.Role) {
50 return fmt.Errorf("%w: target's rank is beyond actor's reach", ErrForbidden)
51 }
52 return nil
53}
54
55// reaches is the ONE rank comparison behind both halves of authority:
56// who an actor may manage (MayActOn) and what role they may grant
57// (checkInviteRole). Keeping them on one predicate is what stops the
58// two drifting apart — an actor who could grant a rank they could not
59// then manage would mint someone beyond their own reach, and one who
60// could manage a rank they could not grant would be left demoting
61// people they could never promote back.
62//
63// Strictly below by default; at or below when built with PeerGrants.
64// It says nothing about RoleOwner — both callers refuse that
65// outright, before asking.
66func reaches(actor, other Role) bool {
67 if PeerGrants {
68 return rank(other) <= rank(actor)
69 }
70 return rank(other) < rank(actor)
71}
72
73// mayManage is rules 1 and 2 of MayActOn on their own: the authority
74// FLOOR, with no target in it. It answers "is this actor allowed to
75// manage anything at all" — which is the question Invite and Revoke
76// ask (they have no target Member), and the question the store's other
77// mutations must ask FIRST, before any invariant check that names the
78// target.
79//
80// That ordering is what keeps ErrLastOwner from becoming a membership
81// oracle. Deactivate and SetRole refuse an Owner target with
82// ErrLastOwner rather than ErrForbidden, because "the owner cannot be
83// removed" is true for every actor and ErrForbidden would imply some
84// higher rank could do it. But a plain Member must still be refused
85// for lacking authority, not told which row is the Owner's — so the
86// floor is checked first and the target-shaped invariant second.
87func mayManage(actor *Member) error {
88 if actor == nil || !actor.Active() {
89 return fmt.Errorf("%w: actor is not an active member", ErrForbidden)
90 }
91 if !actor.Role.AtLeast(RoleAdmin) {
92 return fmt.Errorf("%w: actor must be at least admin", ErrForbidden)
93 }
94 return nil
95}
96