policies.gno
6.42 Kb · 233 lines
1// Package policies is the Remix Lab permission block. A Policy maps named
2// operations to an eligibility rule evaluated against a membership reader.
3//
4// Each operation is defined once with a floor: the most permissive rule it
5// may ever be set to. Administrative operations get a floor of RuleReviewer
6// or RuleOwner, so reconfiguring a policy can never hand admin powers to
7// ordinary members or the public.
8//
9// Operations with an owner floor (typically policy changes and owner
10// appointment) can never be disabled: setting them to RuleNobody would lock
11// the owners out permanently.
12//
13// Policy changes affect future operations only. Ballot eligibility follows
14// the electorate frozen when the ballot opened, not the current policy.
15package policies
16
17import (
18 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/core/v1"
19 "gno.land/p/nt/avl/v0"
20)
21
22// Rule is an eligibility requirement. Rules are ordered from most to least
23// permissive.
24type Rule int
25
26const (
27 RulePublic Rule = iota
28 RuleMember
29 RuleReviewer
30 RuleOwner
31 RuleNobody // operation disabled
32)
33
34func (r Rule) String() string {
35 switch r {
36 case RulePublic:
37 return "public"
38 case RuleMember:
39 return "member"
40 case RuleReviewer:
41 return "reviewer"
42 case RuleOwner:
43 return "owner"
44 case RuleNobody:
45 return "nobody"
46 }
47 return "unknown"
48}
49
50// ParseRule converts the canonical rule name to a Rule.
51func ParseRule(s string) (Rule, error) {
52 for r := RulePublic; r <= RuleNobody; r++ {
53 if r.String() == s {
54 return r, nil
55 }
56 }
57 return RuleNobody, core.Errorf(core.ErrInvalidInput, "unknown rule")
58}
59
60// MaxOperations bounds the number of operations one policy defines.
61const MaxOperations = 64
62
63type entry struct {
64 rule Rule
65 floor Rule
66}
67
68// RuleView is an inert description of one operation's rule.
69type RuleView struct {
70 Op string
71 Rule Rule
72 Floor Rule
73}
74
75// Policy evaluates named operations. Hold it in an unexported host-realm
76// variable; its Set method changes authorization.
77type Policy struct {
78 members core.MembershipReader
79 ops *avl.Tree // op -> *entry
80}
81
82// New creates a policy reading roles from members. members may be the
83// host's own membership set or an adapter to a reviewed shared realm.
84func New(members core.MembershipReader) *Policy {
85 if members == nil {
86 panic(core.Errorf(core.ErrInvalidInput, "nil membership reader").Error())
87 }
88 return &Policy{members: members, ops: avl.NewTree()}
89}
90
91// Define registers op with an initial rule and a floor. It panics on invalid
92// input because host realms call it from init with constant arguments.
93func (p *Policy) Define(op string, rule, floor Rule) {
94 if err := checkOp(op); err != nil {
95 panic(err.Error())
96 }
97 if !floor.valid() || !rule.valid() {
98 panic(core.Errorf(core.ErrInvalidInput, "invalid rule").Error())
99 }
100 if rule < floor {
101 panic(core.Errorf(core.ErrInvalidInput, op+": rule below floor").Error())
102 }
103 if floor >= RuleOwner && rule == RuleNobody {
104 panic(core.Errorf(core.ErrInvalidInput, op+": cannot be disabled").Error())
105 }
106 if p.ops.Has(op) {
107 panic(core.Errorf(core.ErrConflict, op+": already defined").Error())
108 }
109 if p.ops.Size() >= MaxOperations {
110 panic(core.Errorf(core.ErrLimitExceeded, "too many operations").Error())
111 }
112 p.ops.Set(op, &entry{rule: rule, floor: floor})
113}
114
115// Set changes the rule for a defined op. The host must have authorized the
116// actor (typically through a "policy.set" operation with an owner floor).
117func (p *Policy) Set(op string, rule Rule) error {
118 e := p.get(op)
119 if e == nil {
120 return core.Errorf(core.ErrNotFound, "unknown operation")
121 }
122 if !rule.valid() {
123 return core.Errorf(core.ErrInvalidInput, "invalid rule")
124 }
125 if rule < e.floor {
126 return core.Errorf(core.ErrInvalidInput, op+": rule below floor "+e.floor.String())
127 }
128 if e.floor >= RuleOwner && rule == RuleNobody {
129 return core.Errorf(core.ErrInvalidInput, op+": cannot be disabled")
130 }
131 e.rule = rule
132 return nil
133}
134
135// Rule returns the current rule for op.
136func (p *Policy) Rule(op string) (Rule, bool) {
137 e := p.get(op)
138 if e == nil {
139 return RuleNobody, false
140 }
141 return e.rule, true
142}
143
144// Authorize implements core.AuthorizationPolicy. Unknown operations are
145// denied.
146func (p *Policy) Authorize(op string, actor address) error {
147 e := p.get(op)
148 if e == nil {
149 return core.Errorf(core.ErrUnauthorized, "unknown operation "+op)
150 }
151 if allowed(e.rule, p.members.RoleOf(actor)) {
152 return nil
153 }
154 if e.rule == RuleNobody {
155 return core.Errorf(core.ErrUnauthorized, op+" is disabled")
156 }
157 return core.Errorf(core.ErrUnauthorized, op+" requires "+e.rule.String())
158}
159
160// Allowed reports whether actor may perform op, without building an error.
161func (p *Policy) Allowed(op string, actor address) bool {
162 e := p.get(op)
163 return e != nil && allowed(e.rule, p.members.RoleOf(actor))
164}
165
166// List returns every operation's rule, ordered by operation name.
167func (p *Policy) List() []RuleView {
168 var out []RuleView
169 p.ops.Iterate("", "", func(k string, v any) bool {
170 e := v.(*entry)
171 out = append(out, RuleView{Op: k, Rule: e.rule, Floor: e.floor})
172 return false
173 })
174 return out
175}
176
177func (p *Policy) get(op string) *entry {
178 v := p.ops.Get(op)
179 if v == nil {
180 return nil
181 }
182 return v.(*entry)
183}
184
185// allowed treats a role outside the defined range as no role, so a faulty
186// MembershipReader can never grant more than owner.
187func allowed(rule Rule, role core.Role) bool {
188 if !role.Valid() {
189 return rule == RulePublic
190 }
191 switch rule {
192 case RulePublic:
193 return true
194 case RuleMember:
195 return role.AtLeast(core.RoleMember)
196 case RuleReviewer:
197 return role.AtLeast(core.RoleReviewer)
198 case RuleOwner:
199 return role.AtLeast(core.RoleOwner)
200 }
201 return false
202}
203
204func (r Rule) valid() bool { return r >= RulePublic && r <= RuleNobody }
205
206// checkOp accepts lowercase dotted identifiers such as "poll.open": each
207// dot- or underscore-separated part starts with a letter and continues with
208// letters or digits.
209func checkOp(op string) error {
210 if len(op) == 0 || len(op) > 48 {
211 return core.Errorf(core.ErrInvalidInput, "operation name length")
212 }
213 partStart := true
214 for _, c := range op {
215 lower := c >= 'a' && c <= 'z'
216 digit := c >= '0' && c <= '9'
217 switch {
218 case c == '.' || c == '_':
219 if partStart {
220 return core.Errorf(core.ErrInvalidInput, "invalid operation name")
221 }
222 partStart = true
223 case lower || (digit && !partStart):
224 partStart = false
225 default:
226 return core.Errorf(core.ErrInvalidInput, "invalid operation name")
227 }
228 }
229 if partStart {
230 return core.Errorf(core.ErrInvalidInput, "invalid operation name")
231 }
232 return nil
233}