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.

permbook.gno

17.78 Kb · 535 lines
  1// Package permbook is a bounded, authorization-carrying facade over
  2// gno.land/p/nt/groups/v0. It lets a realm define named permissions, grant
  3// them to addresses, revoke them, and ask "does this address currently hold
  4// this permission" without iterating anything.
  5//
  6// It holds no coins, imports no banker, stores no callbacks, and owns no
  7// package-level state. The importing realm allocates a *Book and keeps it
  8// private.
  9//
 10// # What this package adds, and what it does not
 11//
 12// It adds exactly four things to groups, and deliberately nothing else:
 13//
 14//  1. an ADMIN bound to the book, with a two-step handoff;
 15//  2. BOUNDS on permission count, holders per permission, and name shape;
 16//  3. permission semantics instead of membership semantics — no base set, no
 17//     metadata slot, and an empty permission is pruned rather than kept;
 18//  4. a cross-realm surface that is safe by CONSTRUCTION — the *groups.Group
 19//     is unexported and no method returns a mutable handle to it.
 20//
 21// Everything else — the B+-tree registry, the member sets, ordered
 22// iteration, the readonly views — comes from groups. This package writes no
 23// data-structure code.
 24//
 25// # The query
 26//
 27//	if !book.Has("withdraw", who) {
 28//		panic("not authorized")
 29//	}
 30//
 31// Has costs two independent B+-tree descents: O(log P) to find the
 32// permission, O(log H) to find the holder. It is NOT a function of how many
 33// permissions `who` already holds. That matters: every other named-permission
 34// implementation surveyed answers this by iterating the subject's
 35// permissions, which makes the most-privileged address the most expensive to
 36// check — the wrong asymptotic for an authorization check on a hot path.
 37//
 38// # Authorization
 39//
 40// Mutators take (_ int, rlm realm) and identify the principal as
 41// rlm.Previous().Address(), after asserting rlm.IsCurrent(). The consuming
 42// realm threads its own cur:
 43//
 44//	func Grant(cur realm, perm string, addr address) {
 45//		if err := book.Grant(0, cur, perm, addr); err != nil {
 46//			panic(err)
 47//		}
 48//	}
 49//
 50// IsCurrent() rejects a stale or stashed realm value, so a hostile realm
 51// cannot replay an old cur to impersonate the admin. This is the same shape
 52// as p/nt/ownable/v0 and p/nt/ownable/v0/exts/authorizable.
 53//
 54// # Lifecycle of a permission
 55//
 56// A permission comes into existence on its first grant and ceases to exist
 57// when its last holder is revoked. There is no create step and no reserved
 58// name. Has returns false either way, so the distinction is invisible to a
 59// caller and the book does not accumulate empty buckets.
 60//
 61//	(absent) ──Grant──> held by 1..MaxHoldersPerPermission ──Revoke last──> (absent)
 62//	                              │
 63//	                              └── DropPermission ──> (absent)
 64//
 65// # Admin handoff is two-step
 66//
 67// NominateAdmin records a nominee; only AcceptAdmin, called by that nominee,
 68// moves the admin. A one-step transfer to a well-formed-but-unowned address
 69// is permanently fatal — address.IsValid only checks bech32 form, so a
 70// mistyped address passes validation and leaves a book nobody can ever grant
 71// or revoke on again. This is the Y4 finding from the audit of Cosmic Bull's
 72// own r/permission_registry, carried forward.
 73//
 74// # Consumer contract — the parts this package CANNOT enforce
 75//
 76//  1. THREAD A LIVE cur. rlm.IsCurrent() proves the realm value came from a
 77//     live crossing frame, and rlm.Previous().Address() is then the principal
 78//     that crossed into your realm. A consumer that wraps a permbook mutator
 79//     in a NON-crossing exported helper resolves its importer's caller
 80//     instead of its importer — Class-2 designation forgery, in the consumer.
 81//     Call permbook mutators from your own crossing entrypoints, passing that
 82//     entrypoint's own cur.
 83//
 84//  2. DO NOT LEAK THE *Book. No method here returns a mutable handle, but a
 85//     consumer that exports its *Book — or returns it from a function
 86//     reachable by another realm — hands out every mutator on it, and
 87//     borrow rule #2 commits those writes under YOUR realm's authority. Keep
 88//     it in an unexported package-level variable.
 89//
 90//  3. Has ANSWERS ABOUT AN ADDRESS, NOT ABOUT YOUR CALLER. It performs no
 91//     authentication. Derive the address from your own crossing entrypoint's
 92//     cur.Previous().Address() and pass it in.
 93//
 94//  4. CHOOSE LIMITS DELIBERATELY. They are fixed for the life of a Book. A
 95//     consumer that needs different bounds later must allocate a second Book;
 96//     nothing here migrates state between them.
 97//
 98// # Note on the groups base set
 99//
100// A groups.Group carries a base address set alongside its named roles. This
101// package never writes to it, so it is provably empty for any Book, and
102// "in the base set" can never mean "holds a permission".
103package permbook
104
105import (
106	"errors"
107
108	"gno.land/p/nt/groups/v0"
109)
110
111// Ceilings on what Limits may be configured to. They bound worst-case gas
112// and storage for any Book, however the consumer configures it.
113//
114// MaxPermissionsCeiling is the one that bounds GAS rather than merely
115// storage. Three operations walk every permission in the book at O(P log H)
116// — Permissions, HasAny, and RevokeAll — and this ceiling is what bounds
117// them. Everything else is logarithmic or paginated; see each method's own
118// doc for its cost, which is authoritative.
119//
120// In particular HasAny is NOT a cheap variant of Has. Has is two tree
121// descents; HasAny is a full walk of the registry. Do not put HasAny on a
122// hot authorization path or inside a per-item render loop.
123//
124// These ceilings are not the objection this package raises against a shared
125// registry. A shared registry's caps are rivalrous and unraisable because
126// one immutable realm holds every tenant's state; here a consumer that needs
127// more simply allocates another Book in its own realm, at no cost to anyone
128// else.
129const (
130	MaxPermissionsCeiling = 256
131	MaxHoldersCeiling     = 10000
132	MaxNameLenCeiling     = 64
133)
134
135// Default limits, used by NewDefault.
136const (
137	DefaultMaxPermissions          = 64
138	DefaultMaxHoldersPerPermission = 1024
139	DefaultMaxNameLen              = 32
140)
141
142var (
143	ErrUnauthorized      = errors.New("permbook: caller is not the admin")
144	ErrNotLiveRealm      = errors.New("permbook: rlm is not the caller's live cur")
145	ErrInvalidAddress    = errors.New("permbook: invalid address")
146	ErrInvalidName       = errors.New("permbook: permission name must be 1..MaxNameLen chars, lowercase alphanumeric and underscore only")
147	ErrInvalidLimits     = errors.New("permbook: limits must be positive and within the package ceilings")
148	ErrAlreadyGranted    = errors.New("permbook: address already holds this permission")
149	ErrNotGranted        = errors.New("permbook: address does not hold this permission")
150	ErrPermissionLimit   = errors.New("permbook: permission limit reached for this book")
151	ErrHolderLimit       = errors.New("permbook: holder limit reached for this permission")
152	ErrNoPendingAdmin    = errors.New("permbook: no pending admin nomination")
153	ErrNotPendingAdmin   = errors.New("permbook: caller is not the pending admin")
154	ErrSameAdmin         = errors.New("permbook: nominee is already the admin")
155	ErrUnknownPermission = errors.New("permbook: no such permission")
156)
157
158// Limits are fixed at construction and never change for the life of a Book.
159type Limits struct {
160	// MaxPermissions bounds how many distinct permission names may exist at
161	// once. Because Permissions and RevokeAll walk all of them, this bounds
162	// gas, not just storage.
163	MaxPermissions int
164
165	// MaxHoldersPerPermission bounds how many addresses may hold any one
166	// permission. No operation iterates holders unpaginated, so this bounds
167	// storage rather than gas.
168	MaxHoldersPerPermission int
169
170	// MaxNameLen bounds permission-name length.
171	MaxNameLen int
172}
173
174// DefaultLimits returns the limits used by NewDefault.
175func DefaultLimits() Limits {
176	return Limits{
177		MaxPermissions:          DefaultMaxPermissions,
178		MaxHoldersPerPermission: DefaultMaxHoldersPerPermission,
179		MaxNameLen:              DefaultMaxNameLen,
180	}
181}
182
183func (l Limits) valid() bool {
184	return l.MaxPermissions > 0 && l.MaxPermissions <= MaxPermissionsCeiling &&
185		l.MaxHoldersPerPermission > 0 && l.MaxHoldersPerPermission <= MaxHoldersCeiling &&
186		l.MaxNameLen > 0 && l.MaxNameLen <= MaxNameLenCeiling
187}
188
189// Book is a bounded set of named permissions with an admin. The zero value
190// is not usable; construct with New or NewDefault.
191//
192// SECURITY: keep a *Book in an unexported variable. It is the capability.
193// Every mutator on it is gated on the admin, but a realm that receives the
194// pointer itself can invoke those mutators, and borrow rule #2 commits the
195// writes under the ALLOCATING realm's authority.
196type Book struct {
197	g            *groups.Group
198	admin        address
199	pendingAdmin address
200	lim          Limits
201}
202
203// New constructs an empty Book owned by admin, with explicit limits.
204func New(admin address, lim Limits) (*Book, error) {
205	if !admin.IsValid() {
206		return nil, ErrInvalidAddress
207	}
208	if !lim.valid() {
209		return nil, ErrInvalidLimits
210	}
211	return &Book{
212		g:     groups.NewGroup(),
213		admin: admin,
214		lim:   lim,
215	}, nil
216}
217
218// NewDefault constructs an empty Book owned by admin, with DefaultLimits.
219func NewDefault(admin address) (*Book, error) {
220	return New(admin, DefaultLimits())
221}
222
223// --- authorization ---
224
225// principal returns the address that crossed into the caller's realm, after
226// proving rlm is a live crossing frame rather than a stashed value.
227func principal(_ int, rlm realm) (address, error) {
228	if !rlm.IsCurrent() {
229		return address(""), ErrNotLiveRealm
230	}
231	return rlm.Previous().Address(), nil
232}
233
234// assertAdmin resolves the principal from rlm and requires it to be the
235// book's admin.
236func (b *Book) assertAdmin(_ int, rlm realm) error {
237	who, err := principal(0, rlm)
238	if err != nil {
239		return err
240	}
241	if who != b.admin {
242		return ErrUnauthorized
243	}
244	return nil
245}
246
247// --- grant and revoke ---
248
249// Grant gives addr the named permission. Admin only.
250//
251// The permission is created if it does not exist. Granting a permission the
252// address already holds returns ErrAlreadyGranted rather than silently
253// succeeding, so a consumer cannot mistake a no-op for a state change.
254func (b *Book) Grant(_ int, rlm realm, perm string, addr address) error {
255	if err := b.assertAdmin(0, rlm); err != nil {
256		return err
257	}
258	if !b.validName(perm) {
259		return ErrInvalidName
260	}
261	if !addr.IsValid() {
262		return ErrInvalidAddress
263	}
264
265	r, found := b.g.GetRole(perm)
266	if !found {
267		if b.g.RoleCount() >= b.lim.MaxPermissions {
268			return ErrPermissionLimit
269		}
270		var err error
271		r, err = b.g.AddRole(perm)
272		if err != nil {
273			return err
274		}
275	}
276
277	members := r.Members()
278	if members.Has(addr) {
279		return ErrAlreadyGranted
280	}
281	if members.Size() >= b.lim.MaxHoldersPerPermission {
282		// No rollback is needed here, and adding one would be dead code.
283		//
284		// Reaching this branch requires Size() >= MaxHoldersPerPermission,
285		// and Limits.valid guarantees MaxHoldersPerPermission >= 1, so
286		// Size() >= 1. A permission created moments ago above has Size() 0
287		// and therefore cannot reach this branch at all. So the permission
288		// this rejects always pre-existed with holders, and returning leaves
289		// no orphan empty bucket occupying a MaxPermissions slot.
290		//
291		// That argument depends on Limits.valid rejecting a zero holder
292		// limit. If that ever changes, this branch needs an undo.
293		return ErrHolderLimit
294	}
295
296	members.Add(addr)
297	return nil
298}
299
300// Revoke removes the named permission from addr. Admin only.
301//
302// Revoking the last holder removes the permission itself, freeing its slot
303// against MaxPermissions. Has reports false either way, so this is invisible
304// to a caller and keeps the book from accumulating empty buckets.
305func (b *Book) Revoke(_ int, rlm realm, perm string, addr address) error {
306	if err := b.assertAdmin(0, rlm); err != nil {
307		return err
308	}
309	if !b.revokeOne(perm, addr) {
310		return ErrNotGranted
311	}
312	return nil
313}
314
315// revokeOne removes addr from perm and prunes the permission if it is left
316// empty. Reports whether addr actually held it. No authorization: every
317// caller is inside this package and has already gated on the admin.
318func (b *Book) revokeOne(perm string, addr address) bool {
319	r, found := b.g.GetRole(perm)
320	if !found {
321		return false
322	}
323	members := r.Members()
324	if !members.Remove(addr) {
325		return false
326	}
327	if members.Size() == 0 {
328		b.g.RemoveRole(perm)
329	}
330	return true
331}
332
333// RevokeAll removes addr from every permission in the book and reports how
334// many were removed. Admin only.
335//
336// Cost is O(P log H) in the book's permission count — bounded by
337// MaxPermissions, which is why that limit has a ceiling.
338//
339// Implementation note: the permission names are collected FIRST, into a
340// value slice, and the registry is mutated only after that walk returns.
341// groups documents that mutating the role registry mid-iteration can panic
342// and abort the transaction.
343func (b *Book) RevokeAll(_ int, rlm realm, addr address) (int, error) {
344	if err := b.assertAdmin(0, rlm); err != nil {
345		return 0, err
346	}
347	names := b.g.RolesContaining(addr)
348	n := 0
349	for _, perm := range names {
350		if b.revokeOne(perm, addr) {
351			n++
352		}
353	}
354	return n, nil
355}
356
357// DropPermission removes a permission and every grant of it. Admin only.
358//
359// Cost is O(log P): groups discards the whole member set with the role and
360// does not walk it, so this is safe for a permission with many holders.
361func (b *Book) DropPermission(_ int, rlm realm, perm string) error {
362	if err := b.assertAdmin(0, rlm); err != nil {
363		return err
364	}
365	if !b.g.RemoveRole(perm) {
366		return ErrUnknownPermission
367	}
368	return nil
369}
370
371// --- admin handoff ---
372
373// NominateAdmin records a nominee for the admin role. Admin only. The
374// handoff does NOT take effect until the nominee calls AcceptAdmin, and a
375// nomination may be withdrawn with CancelNomination until then.
376func (b *Book) NominateAdmin(_ int, rlm realm, nominee address) error {
377	if err := b.assertAdmin(0, rlm); err != nil {
378		return err
379	}
380	if !nominee.IsValid() {
381		return ErrInvalidAddress
382	}
383	if nominee == b.admin {
384		return ErrSameAdmin
385	}
386	b.pendingAdmin = nominee
387	return nil
388}
389
390// CancelNomination withdraws a pending nomination. Admin only.
391func (b *Book) CancelNomination(_ int, rlm realm) error {
392	if err := b.assertAdmin(0, rlm); err != nil {
393		return err
394	}
395	if b.pendingAdmin == address("") {
396		return ErrNoPendingAdmin
397	}
398	b.pendingAdmin = address("")
399	return nil
400}
401
402// AcceptAdmin completes a pending handoff. Only the nominee may call it.
403func (b *Book) AcceptAdmin(_ int, rlm realm) error {
404	who, err := principal(0, rlm)
405	if err != nil {
406		return err
407	}
408	if b.pendingAdmin == address("") {
409		return ErrNoPendingAdmin
410	}
411	if who != b.pendingAdmin {
412		return ErrNotPendingAdmin
413	}
414	b.admin = who
415	b.pendingAdmin = address("")
416	return nil
417}
418
419// --- queries ---
420//
421// Queries perform NO caller authentication. They answer questions about an
422// address, not about your caller. See consumer-contract clause 3.
423
424// Has reports whether addr currently holds the named permission.
425//
426// Two B+-tree descents, O(log P + log H). Independent of how many other
427// permissions addr holds. Never panics; unknown permissions report false.
428func (b *Book) Has(perm string, addr address) bool {
429	r, found := b.g.GetRole(perm)
430	if !found {
431		return false
432	}
433	return r.Members().Has(addr)
434}
435
436// HasAny reports whether addr holds any permission at all. O(P log H).
437func (b *Book) HasAny(addr address) bool {
438	return b.g.HasAny(addr)
439}
440
441// Permissions returns the names addr holds, in lexicographic order, or nil.
442// O(P log H) — bounded by MaxPermissions.
443func (b *Book) Permissions(addr address) []string {
444	return b.g.RolesContaining(addr)
445}
446
447// PermissionCount returns how many distinct permissions currently exist.
448func (b *Book) PermissionCount() int {
449	return b.g.RoleCount()
450}
451
452// PermissionNames returns up to count permission names in lexicographic
453// order, starting at offset. Paginated so the caller, not the book, chooses
454// how much work a single call does.
455func (b *Book) PermissionNames(offset, count int) []string {
456	if count <= 0 {
457		return nil
458	}
459	var out []string
460	b.g.IterateRoles(offset, count, func(rr *groups.ReadonlyRole) bool {
461		out = append(out, rr.Name())
462		return false
463	})
464	return out
465}
466
467// HolderCount returns how many addresses hold the named permission, or 0 if
468// it does not exist.
469func (b *Book) HolderCount(perm string) int {
470	r, found := b.g.GetRole(perm)
471	if !found {
472		return 0
473	}
474	return r.Members().Size()
475}
476
477// Holders returns up to count holders of the named permission, in sorted
478// order, starting at offset. Paginated for the same reason as
479// PermissionNames. Returns nil for an unknown permission.
480func (b *Book) Holders(perm string, offset, count int) []address {
481	if count <= 0 {
482		return nil
483	}
484	r, found := b.g.GetRole(perm)
485	if !found {
486		return nil
487	}
488	var out []address
489	r.Members().IterateByOffset(offset, count, func(a address) bool {
490		out = append(out, a)
491		return false
492	})
493	return out
494}
495
496// Admin returns the book's current admin.
497func (b *Book) Admin() address {
498	return b.admin
499}
500
501// PendingAdmin returns the nominated-but-not-yet-accepted admin, or the
502// empty address if there is no pending nomination.
503func (b *Book) PendingAdmin() address {
504	return b.pendingAdmin
505}
506
507// IsAdmin reports whether addr is the book's admin.
508func (b *Book) IsAdmin(addr address) bool {
509	return addr == b.admin
510}
511
512// Limits returns the book's fixed limits.
513func (b *Book) Limits() Limits {
514	return b.lim
515}
516
517// --- names ---
518
519// validName restricts permission names to lowercase alphanumerics and
520// underscores, within the book's MaxNameLen.
521//
522// Beyond hygiene this is a security property: permission names end up in
523// composite trust decisions and in rendered output, so no delimiter,
524// whitespace, or markdown character may enter one.
525func (b *Book) validName(name string) bool {
526	if name == "" || len(name) > b.lim.MaxNameLen {
527		return false
528	}
529	for _, c := range name {
530		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
531			return false
532		}
533	}
534	return true
535}