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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}