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}536Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.