PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/permbook

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
permbook
Namespace
g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39
Files
3 (gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/permbook
gno
0.9

Files (3)

  • gnomod.tomltoml
  • permbook.gnogno
  • permbook_test.gnogno
permbook.gnogno
1// Package permbook is a bounded, authorization-carrying facade over2// gno.land/p/nt/groups/v0. It lets a realm define named permissions, grant3// them to addresses, revoke them, and ask "does this address currently hold4// this permission" without iterating anything.5//6// It holds no coins, imports no banker, stores no callbacks, and owns no7// package-level state. The importing realm allocates a *Book and keeps it8// private.9//10// # What this package adds, and what it does not11//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, no17//     metadata slot, and an empty permission is pruned rather than kept;18//  4. a cross-realm surface that is safe by CONSTRUCTION — the *groups.Group19//     is unexported and no method returns a mutable handle to it.20//21// Everything else — the B+-tree registry, the member sets, ordered22// iteration, the readonly views — comes from groups. This package writes no23// data-structure code.24//25// # The query26//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 the32// permission, O(log H) to find the holder. It is NOT a function of how many33// permissions `who` already holds. That matters: every other named-permission34// implementation surveyed answers this by iterating the subject's35// permissions, which makes the most-privileged address the most expensive to36// check — the wrong asymptotic for an authorization check on a hot path.37//38// # Authorization39//40// Mutators take (_ int, rlm realm) and identify the principal as41// rlm.Previous().Address(), after asserting rlm.IsCurrent(). The consuming42// 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 realm51// cannot replay an old cur to impersonate the admin. This is the same shape52// as p/nt/ownable/v0 and p/nt/ownable/v0/exts/authorizable.53//54// # Lifecycle of a permission55//56// A permission comes into existence on its first grant and ceases to exist57// when its last holder is revoked. There is no create step and no reserved58// name. Has returns false either way, so the distinction is invisible to a59// 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-step66//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 address69// is permanently fatal — address.IsValid only checks bech32 form, so a70// mistyped address passes validation and leaves a book nobody can ever grant71// or revoke on again. This is the Y4 finding from the audit of Cosmic Bull's72// own r/permission_registry, carried forward.73//74// # Consumer contract — the parts this package CANNOT enforce75//76//  1. THREAD A LIVE cur. rlm.IsCurrent() proves the realm value came from a77//     live crossing frame, and rlm.Previous().Address() is then the principal78//     that crossed into your realm. A consumer that wraps a permbook mutator79//     in a NON-crossing exported helper resolves its importer's caller80//     instead of its importer — Class-2 designation forgery, in the consumer.81//     Call permbook mutators from your own crossing entrypoints, passing that82//     entrypoint's own cur.83//84//  2. DO NOT LEAK THE *Book. No method here returns a mutable handle, but a85//     consumer that exports its *Book — or returns it from a function86//     reachable by another realm — hands out every mutator on it, and87//     borrow rule #2 commits those writes under YOUR realm's authority. Keep88//     it in an unexported package-level variable.89//90//  3. Has ANSWERS ABOUT AN ADDRESS, NOT ABOUT YOUR CALLER. It performs no91//     authentication. Derive the address from your own crossing entrypoint's92//     cur.Previous().Address() and pass it in.93//94//  4. CHOOSE LIMITS DELIBERATELY. They are fixed for the life of a Book. A95//     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 set99//100// A groups.Group carries a base address set alongside its named roles. This101// package never writes to it, so it is provably empty for any Book, and102// "in the base set" can never mean "holds a permission".103package permbook104105import (106	"errors"107108	"gno.land/p/nt/groups/v0"109)110111// Ceilings on what Limits may be configured to. They bound worst-case gas112// and storage for any Book, however the consumer configures it.113//114// MaxPermissionsCeiling is the one that bounds GAS rather than merely115// storage. Three operations walk every permission in the book at O(P log H)116// — Permissions, HasAny, and RevokeAll — and this ceiling is what bounds117// them. Everything else is logarithmic or paginated; see each method's own118// doc for its cost, which is authoritative.119//120// In particular HasAny is NOT a cheap variant of Has. Has is two tree121// descents; HasAny is a full walk of the registry. Do not put HasAny on a122// hot authorization path or inside a per-item render loop.123//124// These ceilings are not the objection this package raises against a shared125// registry. A shared registry's caps are rivalrous and unraisable because126// one immutable realm holds every tenant's state; here a consumer that needs127// more simply allocates another Book in its own realm, at no cost to anyone128// else.129const (130	MaxPermissionsCeiling = 256131	MaxHoldersCeiling     = 10000132	MaxNameLenCeiling     = 64133)134135// Default limits, used by NewDefault.136const (137	DefaultMaxPermissions          = 64138	DefaultMaxHoldersPerPermission = 1024139	DefaultMaxNameLen              = 32140)141142var (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)157158// 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 at161	// once. Because Permissions and RevokeAll walk all of them, this bounds162	// gas, not just storage.163	MaxPermissions int164165	// MaxHoldersPerPermission bounds how many addresses may hold any one166	// permission. No operation iterates holders unpaginated, so this bounds167	// storage rather than gas.168	MaxHoldersPerPermission int169170	// MaxNameLen bounds permission-name length.171	MaxNameLen int172}173174// DefaultLimits returns the limits used by NewDefault.175func DefaultLimits() Limits {176	return Limits{177		MaxPermissions:          DefaultMaxPermissions,178		MaxHoldersPerPermission: DefaultMaxHoldersPerPermission,179		MaxNameLen:              DefaultMaxNameLen,180	}181}182183func (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 <= MaxNameLenCeiling187}188189// Book is a bounded set of named permissions with an admin. The zero value190// 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 the194// pointer itself can invoke those mutators, and borrow rule #2 commits the195// writes under the ALLOCATING realm's authority.196type Book struct {197	g            *groups.Group198	admin        address199	pendingAdmin address200	lim          Limits201}202203// 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, ErrInvalidAddress207	}208	if !lim.valid() {209		return nil, ErrInvalidLimits210	}211	return &Book{212		g:     groups.NewGroup(),213		admin: admin,214		lim:   lim,215	}, nil216}217218// NewDefault constructs an empty Book owned by admin, with DefaultLimits.219func NewDefault(admin address) (*Book, error) {220	return New(admin, DefaultLimits())221}222223// --- authorization ---224225// principal returns the address that crossed into the caller's realm, after226// 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(""), ErrNotLiveRealm230	}231	return rlm.Previous().Address(), nil232}233234// assertAdmin resolves the principal from rlm and requires it to be the235// book's admin.236func (b *Book) assertAdmin(_ int, rlm realm) error {237	who, err := principal(0, rlm)238	if err != nil {239		return err240	}241	if who != b.admin {242		return ErrUnauthorized243	}244	return nil245}246247// --- grant and revoke ---248249// Grant gives addr the named permission. Admin only.250//251// The permission is created if it does not exist. Granting a permission the252// address already holds returns ErrAlreadyGranted rather than silently253// 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 err257	}258	if !b.validName(perm) {259		return ErrInvalidName260	}261	if !addr.IsValid() {262		return ErrInvalidAddress263	}264265	r, found := b.g.GetRole(perm)266	if !found {267		if b.g.RoleCount() >= b.lim.MaxPermissions {268			return ErrPermissionLimit269		}270		var err error271		r, err = b.g.AddRole(perm)272		if err != nil {273			return err274		}275	}276277	members := r.Members()278	if members.Has(addr) {279		return ErrAlreadyGranted280	}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, so286		// Size() >= 1. A permission created moments ago above has Size() 0287		// and therefore cannot reach this branch at all. So the permission288		// this rejects always pre-existed with holders, and returning leaves289		// no orphan empty bucket occupying a MaxPermissions slot.290		//291		// That argument depends on Limits.valid rejecting a zero holder292		// limit. If that ever changes, this branch needs an undo.293		return ErrHolderLimit294	}295296	members.Add(addr)297	return nil298}299300// Revoke removes the named permission from addr. Admin only.301//302// Revoking the last holder removes the permission itself, freeing its slot303// against MaxPermissions. Has reports false either way, so this is invisible304// 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 err308	}309	if !b.revokeOne(perm, addr) {310		return ErrNotGranted311	}312	return nil313}314315// revokeOne removes addr from perm and prunes the permission if it is left316// empty. Reports whether addr actually held it. No authorization: every317// 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 false322	}323	members := r.Members()324	if !members.Remove(addr) {325		return false326	}327	if members.Size() == 0 {328		b.g.RemoveRole(perm)329	}330	return true331}332333// RevokeAll removes addr from every permission in the book and reports how334// many were removed. Admin only.335//336// Cost is O(P log H) in the book's permission count — bounded by337// MaxPermissions, which is why that limit has a ceiling.338//339// Implementation note: the permission names are collected FIRST, into a340// value slice, and the registry is mutated only after that walk returns.341// groups documents that mutating the role registry mid-iteration can panic342// 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, err346	}347	names := b.g.RolesContaining(addr)348	n := 0349	for _, perm := range names {350		if b.revokeOne(perm, addr) {351			n++352		}353	}354	return n, nil355}356357// 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 and360// 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 err364	}365	if !b.g.RemoveRole(perm) {366		return ErrUnknownPermission367	}368	return nil369}370371// --- admin handoff ---372373// NominateAdmin records a nominee for the admin role. Admin only. The374// handoff does NOT take effect until the nominee calls AcceptAdmin, and a375// 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 err379	}380	if !nominee.IsValid() {381		return ErrInvalidAddress382	}383	if nominee == b.admin {384		return ErrSameAdmin385	}386	b.pendingAdmin = nominee387	return nil388}389390// 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 err394	}395	if b.pendingAdmin == address("") {396		return ErrNoPendingAdmin397	}398	b.pendingAdmin = address("")399	return nil400}401402// 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 err407	}408	if b.pendingAdmin == address("") {409		return ErrNoPendingAdmin410	}411	if who != b.pendingAdmin {412		return ErrNotPendingAdmin413	}414	b.admin = who415	b.pendingAdmin = address("")416	return nil417}418419// --- queries ---420//421// Queries perform NO caller authentication. They answer questions about an422// address, not about your caller. See consumer-contract clause 3.423424// Has reports whether addr currently holds the named permission.425//426// Two B+-tree descents, O(log P + log H). Independent of how many other427// 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 false432	}433	return r.Members().Has(addr)434}435436// 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}440441// 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}446447// PermissionCount returns how many distinct permissions currently exist.448func (b *Book) PermissionCount() int {449	return b.g.RoleCount()450}451452// PermissionNames returns up to count permission names in lexicographic453// order, starting at offset. Paginated so the caller, not the book, chooses454// how much work a single call does.455func (b *Book) PermissionNames(offset, count int) []string {456	if count <= 0 {457		return nil458	}459	var out []string460	b.g.IterateRoles(offset, count, func(rr *groups.ReadonlyRole) bool {461		out = append(out, rr.Name())462		return false463	})464	return out465}466467// HolderCount returns how many addresses hold the named permission, or 0 if468// it does not exist.469func (b *Book) HolderCount(perm string) int {470	r, found := b.g.GetRole(perm)471	if !found {472		return 0473	}474	return r.Members().Size()475}476477// Holders returns up to count holders of the named permission, in sorted478// order, starting at offset. Paginated for the same reason as479// PermissionNames. Returns nil for an unknown permission.480func (b *Book) Holders(perm string, offset, count int) []address {481	if count <= 0 {482		return nil483	}484	r, found := b.g.GetRole(perm)485	if !found {486		return nil487	}488	var out []address489	r.Members().IterateByOffset(offset, count, func(a address) bool {490		out = append(out, a)491		return false492	})493	return out494}495496// Admin returns the book's current admin.497func (b *Book) Admin() address {498	return b.admin499}500501// PendingAdmin returns the nominated-but-not-yet-accepted admin, or the502// empty address if there is no pending nomination.503func (b *Book) PendingAdmin() address {504	return b.pendingAdmin505}506507// IsAdmin reports whether addr is the book's admin.508func (b *Book) IsAdmin(addr address) bool {509	return addr == b.admin510}511512// Limits returns the book's fixed limits.513func (b *Book) Limits() Limits {514	return b.lim515}516517// --- names ---518519// validName restricts permission names to lowercase alphanumerics and520// underscores, within the book's MaxNameLen.521//522// Beyond hygiene this is a security property: permission names end up in523// 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 false528	}529	for _, c := range name {530		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {531			return false532		}533	}534	return true535}536

Functions

not supported for pure packages by the node (vm/qfuncs)

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.