Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}