permission_registry.gno
17.65 Kb · 526 lines
1package permission_registry
2
3import (
4 "chain/runtime/unsafe"
5 "sort"
6 "strconv"
7 "strings"
8 "time"
9)
10
11const (
12 // MaxResources bounds total registry state. Raised from the upstream
13 // 200 as part of the R1 remediation: with a per-admin quota now
14 // carrying the anti-monopoly duty, the global cap is a pure state
15 // bound rather than the sole defense against namespace exhaustion.
16 MaxResources = 1000
17
18 // MaxResourcesPerAdmin bounds how many resources one address may hold
19 // at once. R1 (audit 2026-09-21): the upstream design had only a
20 // global cap on a permissionless shared registry, so one unprivileged
21 // key could occupy every slot for ~200 cheap transactions and
22 // permanently deny the registry to every other tenant. DeleteResource
23 // is admin-only, so the squat was irreversible.
24 MaxResourcesPerAdmin = 20
25
26 MaxPermissionsPerResource = 50
27 MaxHoldersPerPermission = 200
28 MaxNameLen = 64
29
30 // ReservationPeriod is how long a deleted resource name stays
31 // reserved. Finite (re-audit 2026-09-02): eternal tombstones let an
32 // attacker cycle create/delete to lock the namespace forever.
33 ReservationPeriod = int64(90 * 24 * 3600) // 90 days
34
35 // Render bounds (Y3, audit 2026-09-21). Render walks the whole
36 // registry and is reachable by any viewer through gnoweb and
37 // vm/qrender, so its cost is borne by third parties rather than by
38 // whoever grew the state. Uncapped, the declared limits allowed
39 // 1000*50*200 rendered holder entries. The full data stays available
40 // through ListResources / GetPermissions / Has, which are bounded per
41 // call by construction.
42 MaxRenderResources = 20
43 MaxRenderPermissions = 8
44 MaxRenderHolders = 10
45)
46
47// Reservation holds a deleted resource name for its former admin AND
48// its original creator, and expires.
49type Reservation struct {
50 Admin address
51 Creator address
52 Expires time.Time
53}
54
55// resources maps a resource name to the admin address that controls it.
56var resources map[string]address
57
58// resourceNames tracks the insertion-ordered list of resource names so
59// ListResources can iterate without scanning the map.
60var resourceNames []string
61
62// permissions maps resource -> permission -> address -> granted.
63// Three-level nesting gives O(1) lookup for Has().
64var permissions map[string]map[string]map[address]bool
65
66// permList tracks the ordered list of distinct permission names per
67// resource, so GetPermissions can iterate without scanning the full map.
68var permList map[string][]string
69
70// retired maps a deleted resource name to its time-bounded
71// reservation. Consumers authorize against resource names, so a
72// freed name must not be claimable by an attacker while integrators
73// may still reference it — but the hold expires so tombstones cannot
74// lock the namespace forever.
75var retired map[string]*Reservation
76
77// resourceCreators records each resource's ORIGINAL creator,
78// immutable through admin transfers, so a hostile admin-transferee
79// cannot permanently strand a name against the project that made it.
80var resourceCreators map[string]address
81
82// adminResources counts the live resources each address administers, so
83// MaxResourcesPerAdmin can be enforced in O(1). Entries are removed when
84// the count reaches zero so the map tracks live admins only (R1).
85var adminResources map[address]int
86
87// pendingAdmins holds nominated-but-not-yet-accepted admins, keyed by
88// resource name. Admin handoff is two-step (Y4): nominating is
89// reversible, only the nominee's own acceptance is final.
90var pendingAdmins map[string]address
91
92func init() {
93 resources = make(map[string]address)
94 resourceNames = []string{}
95 permissions = make(map[string]map[string]map[address]bool)
96 permList = make(map[string][]string)
97 retired = make(map[string]*Reservation)
98 resourceCreators = make(map[string]address)
99 adminResources = make(map[address]int)
100 pendingAdmins = make(map[string]address)
101}
102
103// mustBeAdmin panics if who is not the admin of the resource.
104//
105// Y1 (audit 2026-09-21): identity is now passed in from the crossing
106// entrypoint's own `cur.Previous().Address()` rather than recomputed by
107// a non-crossing helper via unsafe.PreviousRealm(). The stack-walking
108// form returned the correct address on every path this realm actually
109// exposes, but nothing in the type system tied it to the immediate
110// caller — a future non-crossing exported helper calling it would have
111// silently resolved its importer's caller instead of its importer
112// (security.md Class 2). Threading `cur` makes the binding structural.
113func mustBeAdmin(who address, resource string) {
114 admin, exists := resources[resource]
115 if !exists {
116 panic("resource not found: " + resource)
117 }
118 if who != admin {
119 panic("permission denied: caller is not admin of " + resource)
120 }
121}
122
123// rejectStraySend aborts a transaction that attaches coins. This realm
124// has no banker, no payable path and no withdrawal function, so coins
125// attached to any entrypoint would be permanently stranded at the realm
126// address (Y5, security.md § operational).
127func rejectStraySend(cur realm) {
128 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
129 panic("this realm does not accept coins")
130 }
131}
132
133// releaseAdminSlot decrements an admin's live-resource count, deleting
134// the entry when it reaches zero so adminResources never accumulates
135// zero-valued keys.
136func releaseAdminSlot(a address) {
137 adminResources[a]--
138 if adminResources[a] <= 0 {
139 delete(adminResources, a)
140 }
141}
142
143// isValidName restricts resource and permission names to lowercase
144// alphanumeric with underscores. Beyond hygiene this is a security
145// property: names appear in composite trust decisions and rendered
146// output, so no delimiter or markdown character may enter one.
147func isValidName(name string) bool {
148 if name == "" || len(name) > MaxNameLen {
149 return false
150 }
151 for _, c := range name {
152 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
153 return false
154 }
155 }
156 return true
157}
158
159func nameRuleText(kind string) string {
160 return kind + " name must be 1-" + strconv.Itoa(MaxNameLen) +
161 " chars, lowercase alphanumeric with underscores only"
162}
163
164// ---------- write operations ----------
165
166// CreateResource registers a new named resource. The caller becomes its
167// admin and is the only address that can grant or revoke permissions on
168// it. A deleted resource name stays reserved for its former admin and
169// its original creator until the reservation expires.
170//
171// Each address may administer at most MaxResourcesPerAdmin resources at
172// once, and the registry holds at most MaxResources in total.
173func CreateResource(cur realm, resourceName string) {
174 rejectStraySend(cur)
175 who := cur.Previous().Address()
176
177 if !isValidName(resourceName) {
178 panic(nameRuleText("resource"))
179 }
180 if _, exists := resources[resourceName]; exists {
181 panic("resource already exists: " + resourceName)
182 }
183
184 creator := who
185 if res, wasRetired := retired[resourceName]; wasRetired {
186 if time.Now().After(res.Expires) {
187 // The hold lapsed: drop the tombstone now rather than leaving
188 // it for the success path below, so a lapsed reservation is
189 // reclaimed even if this call goes on to abort.
190 delete(retired, resourceName)
191 } else {
192 if who != res.Admin && who != res.Creator {
193 panic("resource name is reserved for its former admin: " + resourceName)
194 }
195 // round-3 audit: an in-window re-create must PRESERVE the
196 // original creator — otherwise a hostile admin-transferee could
197 // delete and instantly re-create, erasing the original project's
198 // reclaim right for every future cycle
199 if res.Creator != "" {
200 creator = res.Creator
201 }
202 }
203 }
204 if len(resources) >= MaxResources {
205 panic("global resource limit reached")
206 }
207 if adminResources[who] >= MaxResourcesPerAdmin {
208 panic("per-admin resource limit reached")
209 }
210
211 resources[resourceName] = who
212 adminResources[who]++
213 resourceCreators[resourceName] = creator
214 resourceNames = append(resourceNames, resourceName)
215 permissions[resourceName] = make(map[string]map[address]bool)
216 permList[resourceName] = []string{}
217 delete(retired, resourceName)
218}
219
220// DeleteResource removes a resource and every permission under it. Only
221// the resource admin can call this. The name stays reserved for the
222// caller and for the original creator: nobody else can re-create it and
223// inherit its consumers until the reservation expires.
224func DeleteResource(cur realm, resourceName string) {
225 rejectStraySend(cur)
226 who := cur.Previous().Address()
227 mustBeAdmin(who, resourceName)
228
229 retired[resourceName] = &Reservation{
230 Admin: who,
231 Creator: resourceCreators[resourceName],
232 Expires: time.Now().Add(time.Duration(ReservationPeriod) * time.Second),
233 }
234 releaseAdminSlot(who)
235 delete(resources, resourceName)
236 delete(resourceCreators, resourceName)
237 delete(permissions, resourceName)
238 delete(permList, resourceName)
239 // a nomination cannot outlive the resource it was made against
240 delete(pendingAdmins, resourceName)
241 for i, n := range resourceNames {
242 if n == resourceName {
243 resourceNames = append(resourceNames[:i], resourceNames[i+1:]...)
244 break
245 }
246 }
247}
248
249// Grant gives an address a named permission on a resource. Only the
250// resource admin can call this. Panics if the permission is already
251// granted to avoid silent no-ops.
252func Grant(cur realm, resourceName, permission string, addr address) {
253 rejectStraySend(cur)
254 mustBeAdmin(cur.Previous().Address(), resourceName)
255 if !isValidName(permission) {
256 panic(nameRuleText("permission"))
257 }
258 if addr == "" || !addr.IsValid() {
259 panic("invalid address: " + string(addr))
260 }
261
262 perms := permissions[resourceName]
263 if perms[permission] == nil {
264 if len(permList[resourceName]) >= MaxPermissionsPerResource {
265 panic("permission limit reached for resource " + resourceName)
266 }
267 perms[permission] = make(map[address]bool)
268 permList[resourceName] = append(permList[resourceName], permission)
269 }
270
271 if perms[permission][addr] {
272 panic("permission already granted: " + permission + " to " + string(addr))
273 }
274 if len(perms[permission]) >= MaxHoldersPerPermission {
275 panic("holder limit reached for permission " + permission)
276 }
277 perms[permission][addr] = true
278}
279
280// Revoke removes a permission from an address. Only the resource admin
281// can call this. Panics if the permission was not granted. A permission
282// left with no holders is pruned from the resource's permission list.
283func Revoke(cur realm, resourceName, permission string, addr address) {
284 rejectStraySend(cur)
285 mustBeAdmin(cur.Previous().Address(), resourceName)
286
287 perms := permissions[resourceName]
288 if perms[permission] == nil || !perms[permission][addr] {
289 panic("permission not granted: " + permission + " to " + string(addr))
290 }
291 delete(perms[permission], addr)
292
293 if len(perms[permission]) == 0 {
294 delete(perms, permission)
295 for i, p := range permList[resourceName] {
296 if p == permission {
297 permList[resourceName] = append(permList[resourceName][:i], permList[resourceName][i+1:]...)
298 break
299 }
300 }
301 }
302}
303
304// TransferAdmin nominates a new admin for a resource. Only the current
305// admin can call this, and the handoff does NOT take effect until the
306// nominee calls AcceptAdmin.
307//
308// Y4 (audit 2026-09-21): the upstream one-step transfer made a
309// well-formed-but-unowned destination permanently fatal. address.IsValid
310// only checks bech32 form, so a mistyped address passed the check and
311// left the resource with an admin nobody controls — it could never again
312// be granted on, revoked from, transferred or deleted, and its slot was
313// lost from both the global cap and the former admin's quota forever.
314// Nomination is reversible; only the nominee's consent is final.
315func TransferAdmin(cur realm, resourceName string, newAdmin address) {
316 rejectStraySend(cur)
317 who := cur.Previous().Address()
318 mustBeAdmin(who, resourceName)
319 if newAdmin == "" || !newAdmin.IsValid() {
320 panic("invalid new admin address: " + string(newAdmin))
321 }
322 if newAdmin == who {
323 panic("new admin is already the admin of " + resourceName)
324 }
325 pendingAdmins[resourceName] = newAdmin
326}
327
328// CancelAdminTransfer withdraws a pending nomination. Only the current
329// admin can call this.
330func CancelAdminTransfer(cur realm, resourceName string) {
331 rejectStraySend(cur)
332 mustBeAdmin(cur.Previous().Address(), resourceName)
333 if _, ok := pendingAdmins[resourceName]; !ok {
334 panic("no pending admin transfer for " + resourceName)
335 }
336 delete(pendingAdmins, resourceName)
337}
338
339// AcceptAdmin completes a pending handoff; only the nominee may call it.
340// The nominee's quota is checked HERE — at consent time — so a
341// nomination can never push an account past MaxResourcesPerAdmin without
342// that account agreeing to carry the resource.
343func AcceptAdmin(cur realm, resourceName string) {
344 rejectStraySend(cur)
345 who := cur.Previous().Address()
346
347 nominee, pending := pendingAdmins[resourceName]
348 if !pending {
349 panic("no pending admin transfer for " + resourceName)
350 }
351 if who != nominee {
352 panic("caller is not the pending admin of " + resourceName)
353 }
354 former, exists := resources[resourceName]
355 if !exists {
356 panic("resource not found: " + resourceName)
357 }
358 if adminResources[who] >= MaxResourcesPerAdmin {
359 panic("accepting would exceed the per-admin resource limit")
360 }
361
362 releaseAdminSlot(former)
363 adminResources[who]++
364 resources[resourceName] = who
365 delete(pendingAdmins, resourceName)
366}
367
368// ---------- read-only queries ----------
369
370// Has returns true if addr holds the named permission on the resource.
371// Returns false (never panics) for unknown resources or permissions.
372//
373// INTEGRATOR CONTRACT (Y7): Has takes the subject address explicitly and
374// performs NO caller authentication — it answers "does this address hold
375// this permission", not "may my caller do this". A consuming realm must
376// derive addr from its own crossing entrypoint's cur.Previous().Address()
377// and pass it in. Deriving it inside a non-crossing helper via
378// unsafe.PreviousRealm() resolves the consumer's own caller's caller and
379// is a Class-2 designation-forgery bug in the consumer.
380func Has(resourceName, permission string, addr address) bool {
381 perms, exists := permissions[resourceName]
382 if !exists {
383 return false
384 }
385 holders := perms[permission]
386 if holders == nil {
387 return false
388 }
389 return holders[addr]
390}
391
392// GetPermissions returns all permission names granted to addr on a
393// resource, as a comma-separated string. Returns "none" if the address
394// has no permissions.
395func GetPermissions(resourceName string, addr address) string {
396 perms, exists := permissions[resourceName]
397 if !exists {
398 return "none"
399 }
400
401 var result []string
402 for _, perm := range permList[resourceName] {
403 holders := perms[perm]
404 if holders != nil && holders[addr] {
405 result = append(result, perm)
406 }
407 }
408 if len(result) == 0 {
409 return "none"
410 }
411 return strings.Join(result, ", ")
412}
413
414// ListResources returns all registered resource names as a comma-separated
415// string in registration order. Returns "none" if no resources exist.
416func ListResources() string {
417 if len(resourceNames) == 0 {
418 return "none"
419 }
420 return strings.Join(resourceNames, ", ")
421}
422
423// GetAdmin returns the admin address of a resource.
424func GetAdmin(resourceName string) string {
425 admin, exists := resources[resourceName]
426 if !exists {
427 return "not found"
428 }
429 return string(admin)
430}
431
432// GetPendingAdmin returns the nominated admin awaiting acceptance for a
433// resource, or "none".
434func GetPendingAdmin(resourceName string) string {
435 a, ok := pendingAdmins[resourceName]
436 if !ok {
437 return "none"
438 }
439 return string(a)
440}
441
442// ---------- render ----------
443
444// Render returns a markdown overview. Never panics. Output is bounded
445// by MaxRenderResources / MaxRenderPermissions / MaxRenderHolders (Y3);
446// truncated sections name the query to use for complete data.
447func Render(path string) string {
448 if resources == nil || len(resourceNames) == 0 {
449 return "# Permission Registry\n\nNo resources registered.\n"
450 }
451
452 var b strings.Builder
453 b.WriteString("# Permission Registry\n\n")
454 b.WriteString("Registered resources: " + strconv.Itoa(len(resourceNames)) + "\n\n")
455
456 shown := len(resourceNames)
457 if shown > MaxRenderResources {
458 shown = MaxRenderResources
459 }
460
461 for _, name := range resourceNames[:shown] {
462 admin := resources[name]
463 b.WriteString("## " + name + "\n\n")
464 b.WriteString("**Admin:** `" + string(admin) + "`\n\n")
465 if p, ok := pendingAdmins[name]; ok {
466 b.WriteString("**Pending admin:** `" + string(p) + "` (awaiting acceptance)\n\n")
467 }
468
469 perms, exists := permissions[name]
470 if !exists || len(perms) == 0 {
471 b.WriteString("_No permissions defined._\n\n")
472 continue
473 }
474
475 names := permList[name]
476 pShown := len(names)
477 if pShown > MaxRenderPermissions {
478 pShown = MaxRenderPermissions
479 }
480
481 b.WriteString("| Permission | Addresses |\n")
482 b.WriteString("|------------|-----------|\n")
483
484 for _, perm := range names[:pShown] {
485 holders := perms[perm]
486 if holders == nil || len(holders) == 0 {
487 continue
488 }
489 var addrs []string
490 for addr := range holders {
491 if holders[addr] {
492 addrs = append(addrs, string(addr))
493 }
494 }
495 // sort the FULL holder set before truncating, so the subset
496 // shown is a deterministic function of state rather than of
497 // map layout
498 sort.Strings(addrs)
499 extra := 0
500 if len(addrs) > MaxRenderHolders {
501 extra = len(addrs) - MaxRenderHolders
502 addrs = addrs[:MaxRenderHolders]
503 }
504 for i, a := range addrs {
505 addrs[i] = "`" + a + "`"
506 }
507 cell := strings.Join(addrs, ", ")
508 if extra > 0 {
509 cell += ", … +" + strconv.Itoa(extra) + " more"
510 }
511 b.WriteString("| " + perm + " | " + cell + " |\n")
512 }
513 if len(names) > pShown {
514 b.WriteString("\n_… " + strconv.Itoa(len(names)-pShown) +
515 " more permission(s) on this resource. Use GetPermissions or Has for complete data._\n")
516 }
517 b.WriteString("\n")
518 }
519
520 if len(resourceNames) > shown {
521 b.WriteString("_… " + strconv.Itoa(len(resourceNames)-shown) +
522 " more resource(s) not shown. Use ListResources, GetPermissions or Has for complete data._\n")
523 }
524
525 return b.String()
526}