Search Apps Documentation Source Content File Folder Download Copy Actions Download

authz.gno

16.27 Kb · 447 lines
  1// Package authz provides flexible authorization control for privileged actions.
  2//
  3// # Authorization Strategies
  4//
  5// The package supports multiple authorization strategies:
  6//   - Member-based: Single user or team of users
  7//   - Contract-based: Async authorization (e.g., via DAO)
  8//   - Auto-accept: Allow all actions
  9//   - Drop: Deny all actions
 10//
 11// Core Components
 12//
 13//   - Authority interface: Base interface implemented by all authorities
 14//   - Authorizer: Main wrapper object for authority management
 15//   - MemberAuthority: Manages authorized addresses
 16//   - ContractAuthority: Delegates to another contract
 17//   - AutoAcceptAuthority: Accepts all actions
 18//   - DroppedAuthority: Denies all actions
 19//
 20// Quick Start
 21//
 22//	// Initialize with contract deployer as authority
 23//	var member address(...)
 24//	var auth = authz.NewWithMembers(member)
 25//
 26//	// Create functions that require authorization
 27//	func UpdateConfig(cur realm, newValue string) error {
 28//		return auth.DoByPrevious(0, cur, "update_config", func() error {
 29//			config = newValue
 30//			return nil
 31//		})
 32//	}
 33//
 34// See example_test.gno for more usage examples.
 35package authz
 36
 37import (
 38	"chain"
 39	"errors"
 40	"strings"
 41
 42	"gno.land/p/moul/addrset"
 43	"gno.land/p/moul/once"
 44	"gno.land/p/nt/avl/v0"
 45	"gno.land/p/nt/avl/v0/rotree"
 46	"gno.land/p/nt/ufmt/v0"
 47)
 48
 49// Authorizer is the main wrapper object that handles authority management.
 50// It is configured with a replaceable Authority implementation.
 51type Authorizer struct {
 52	auth Authority
 53}
 54
 55// Authority represents an entity that can authorize privileged actions.
 56// It is implemented by MemberAuthority, ContractAuthority, AutoAcceptAuthority,
 57// and DroppedAuthority.
 58//
 59// Authority is the canonical safe shape for cross-package authority
 60// interfaces: methods are address-typed (no realm/cur crosses the interface
 61// boundary), and consumers correctly derive `caller` from
 62// `cur.Previous().Address()` under `rlm.IsCurrent()` before invoking
 63// Authorize. No cur-leak (class 1) is possible through this interface.
 64//
 65// However, two RESIDUAL RISKS apply:
 66//
 67//   - Class-3 impl-substitution: NewWithAuthority and Authorizer.Transfer
 68//     accept any Authority impl. A malicious Authority can always-approve
 69//     (silent privilege escalation) or always-deny (denial-of-service).
 70//     Consumers should pass canonical impls from this package
 71//     (MemberAuthority, ContractAuthority, AutoAcceptAuthority,
 72//     DroppedAuthority) unless they have explicit reason to register a
 73//     foreign impl. We do not expose an IsCanonicalAuthority allowlist
 74//     because the package is intentionally extensible — third-party impls
 75//     are the design intent.
 76//
 77//   - Class-4 closed-over-authority: NewContractAuthority and
 78//     NewRestrictedContractAuthority capture a caller-supplied
 79//     PrivilegedActionHandler closure. The handler runs synchronously
 80//     inside Authorize with the consumer's authority. A hostile handler
 81//     can swallow actions, log the caller, or execute arbitrary code
 82//     under the consumer's frame. Register only trusted handler functions.
 83//     See r/gnops/valopers/init.gno for the realistic registration shape.
 84//
 85// We do NOT seal Authority via an unexported marker method — that pattern
 86// is bypassable via embedding in Gno; see
 87// p/test/seal/filetests/z_seal_*_filetest.gno for the four bypass tests.
 88type Authority interface {
 89	// Authorize executes a privileged action if the caller is authorized
 90	// Additional args can be provided for context (e.g., for proposal creation)
 91	Authorize(caller address, title string, action PrivilegedAction, args ...any) error
 92
 93	// String returns a human-readable description of the authority
 94	String() string
 95}
 96
 97// PrivilegedAction defines a function that performs a privileged action.
 98type PrivilegedAction func() error
 99
100// PrivilegedActionHandler is called by contract-based authorities to handle
101// privileged actions.
102type PrivilegedActionHandler func(title string, action PrivilegedAction) error
103
104// NewWithMembers creates a new Authorizer whose authority is a
105// MemberAuthority containing the given addresses. Callers express
106// authority intent at the call site:
107//
108//	// "auth realm is the authority"
109//	a := authz.NewWithMembers(cur.Address())
110//
111//	// "previous realm is the authority" (from a crossing function)
112//	a := authz.NewWithMembers(cur.Previous().Address())
113//
114//	// "EOA caller is the authority" (from init(cur realm))
115//	if !cur.Previous().IsUserCall() {
116//	    panic("realm must be initialized by EOA")
117//	}
118//	a := authz.NewWithMembers(cur.Previous().Address())
119//
120// This replaces the previous NewWithCurrent / NewWithPrevious /
121// NewWithOrigin sugar — those baked runtime.{Current,Previous,Origin}
122// reads into the constructor, which (a) prevented use from package-
123// level var initializers, (b) made the EOA-origin check inside
124// NewWithOrigin an indirect address comparison rather than the
125// straightforward IsUserCall predicate, and (c) coupled the
126// constructor to the runtime walks the rest of the migration is
127// moving away from.
128func NewWithMembers(addrs ...address) *Authorizer {
129	return &Authorizer{
130		auth: NewMemberAuthority(addrs...),
131	}
132}
133
134// NewWithAuthority creates a new Authorizer with a specific authority.
135//
136// SECURITY: `authority` is an open-interface input — any value satisfying
137// Authority is accepted. A malicious impl can always-approve (privilege
138// escalation) or always-deny (DoS). Prefer canonical impls from this
139// package (NewMemberAuthority, NewContractAuthority, NewAutoAcceptAuthority,
140// NewDroppedAuthority) unless you specifically need a foreign impl.
141func NewWithAuthority(authority Authority) *Authorizer {
142	return &Authorizer{
143		auth: authority,
144	}
145}
146
147// Authority returns the auth authority implementation
148func (a *Authorizer) Authority() Authority {
149	return a.auth
150}
151
152// Transfer changes the auth authority after validation. rlm must be the
153// caller's own captured cur (asserted via rlm.IsCurrent()); the
154// principal is rlm.Previous().Address(). Closes the address-parameter
155// forgery: an external realm cannot supply Owner() as `caller` to
156// bypass the underlying Authority's check.
157//
158// SECURITY (runtime substitution): once the current authority approves a
159// Transfer, the new authority is installed and effective on the next call.
160// If an attacker ever becomes the authority — even briefly — they can
161// install a permanent DroppedAuthority (DoS) or an AutoAcceptAuthority
162// (privilege escalation). Consumers concerned about this should wrap
163// Transfer with a one-shot guard or a quorum/cooldown check.
164//
165// `newAuthority` is also an open-interface input — see NewWithAuthority's
166// Class-3 caveat. Pass canonical impls.
167func (a *Authorizer) Transfer(_ int, rlm realm, newAuthority Authority) error {
168	if !rlm.IsCurrent() {
169		return errors.New("unauthorized")
170	}
171	caller := rlm.Previous().Address()
172	return a.auth.Authorize(caller, "transfer_authority", func() error {
173		a.auth = newAuthority
174		return nil
175	})
176}
177
178// DoByCurrent executes a privileged action authorized as `rlm`. `rlm`
179// must be the caller's own live cur (asserted via rlm.IsCurrent());
180// the authorized principal is `rlm.Address()`. To authorize as the
181// realm that called your function, use `DoByPrevious`.
182//
183//	auth.DoByCurrent(0, cur, "update_config", func() error { ... })    // current realm authorizes
184//	auth.DoByPrevious(0, cur, "update_config", func() error { ... })   // calling realm authorizes
185//
186// The `_ int` first parameter is a deliberate sentinel that pushes
187// `rlm realm` past the first-arg position so DoByCurrent stays a
188// non-crossing method — otherwise it would be a crossing method and
189// rlm.Previous() inside would resolve one realm deeper than the caller
190// intended.
191//
192// SECURITY: the IsCurrent guard closes Class-2 designation forgery (see
193// docs/resources/gno-security.md). A realm value's .Address() is set
194// when the value is minted at a crossing frame; the value can in
195// principle be stored and replayed. Without IsCurrent, a hostile realm
196// could capture a high-privilege realm's cur.Previous() (e.g., when
197// that realm called into it) and later pass the stored value here to
198// authorize actions as that realm. IsCurrent rejects stale captures by
199// requiring the value to match the topmost live crossing frame's cur.
200func (a *Authorizer) DoByCurrent(_ int, rlm realm, title string, action PrivilegedAction, args ...any) error {
201	if !rlm.IsCurrent() {
202		return errors.New("unauthorized")
203	}
204	return a.auth.Authorize(rlm.Address(), title, action, args...)
205}
206
207// DoByPrevious executes a privileged action authorized as the realm
208// that called the function invoking DoByPrevious. `rlm` must be the
209// caller's own live cur; the principal is derived as
210// `rlm.Previous().Address()`. Mirrors the Transfer/AddMember pattern:
211// always take live cur, derive the caller-of-caller internally rather
212// than accepting a stored/forwarded realm value.
213func (a *Authorizer) DoByPrevious(_ int, rlm realm, title string, action PrivilegedAction, args ...any) error {
214	if !rlm.IsCurrent() {
215		return errors.New("unauthorized")
216	}
217	return a.auth.Authorize(rlm.Previous().Address(), title, action, args...)
218}
219
220// String returns a string representation of the auth authority
221func (a *Authorizer) String() string {
222	authStr := a.auth.String()
223
224	switch a.auth.(type) {
225	case *MemberAuthority:
226	case *ContractAuthority:
227	case *AutoAcceptAuthority:
228	case *droppedAuthority:
229	default:
230		// this way official "dropped" is different from "*custom*: dropped" (autoclaimed).
231		return ufmt.Sprintf("custom_authority[%s]", authStr)
232	}
233	return authStr
234}
235
236// MemberAuthority is the default implementation using addrset for member
237// management.
238type MemberAuthority struct {
239	members addrset.Set
240}
241
242func NewMemberAuthority(members ...address) *MemberAuthority {
243	auth := &MemberAuthority{}
244	for _, addr := range members {
245		auth.members.Add(addr)
246	}
247	return auth
248}
249
250func (a *MemberAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
251	if !a.members.Has(caller) {
252		return errors.New("unauthorized")
253	}
254
255	if err := action(); err != nil {
256		return err
257	}
258	return nil
259}
260
261func (a *MemberAuthority) String() string {
262	addrs := []string{}
263	a.members.Tree().Iterate("", "", func(key string, _ any) bool {
264		addrs = append(addrs, key)
265		return false
266	})
267	addrsStr := strings.Join(addrs, ",")
268	return ufmt.Sprintf("member_authority[%s]", addrsStr)
269}
270
271// AddMember adds a new member to the authority. rlm must be the caller's
272// own captured cur; the principal is rlm.Previous().Address() and must
273// already be a member. The IsCurrent guard closes the forgery where an
274// external realm passes Owner() as caller to bypass members.Has(caller).
275func (a *MemberAuthority) AddMember(_ int, rlm realm, addr address) error {
276	if !rlm.IsCurrent() {
277		return errors.New("unauthorized")
278	}
279	caller := rlm.Previous().Address()
280	return a.Authorize(caller, "add_member", func() error {
281		a.members.Add(addr)
282		return nil
283	})
284}
285
286// AddMembers adds a list of members to the authority. Same rlm contract
287// as AddMember.
288func (a *MemberAuthority) AddMembers(_ int, rlm realm, addrs ...address) error {
289	if !rlm.IsCurrent() {
290		return errors.New("unauthorized")
291	}
292	caller := rlm.Previous().Address()
293	return a.Authorize(caller, "add_members", func() error {
294		for _, addr := range addrs {
295			a.members.Add(addr)
296		}
297		return nil
298	})
299}
300
301// RemoveMember removes a member from the authority. Same rlm contract
302// as AddMember.
303func (a *MemberAuthority) RemoveMember(_ int, rlm realm, addr address) error {
304	if !rlm.IsCurrent() {
305		return errors.New("unauthorized")
306	}
307	caller := rlm.Previous().Address()
308	return a.Authorize(caller, "remove_member", func() error {
309		a.members.Remove(addr)
310		return nil
311	})
312}
313
314// Tree returns a read-only view of the members tree
315func (a *MemberAuthority) Tree() *rotree.ReadOnlyTree {
316	tree := a.members.Tree().(*avl.Tree)
317	return rotree.Wrap(tree, nil)
318}
319
320// Has checks if the given address is a member of the authority
321func (a *MemberAuthority) Has(addr address) bool {
322	return a.members.Has(addr)
323}
324
325// ContractAuthority implements async contract-based authority
326type ContractAuthority struct {
327	contractPath    string
328	contractAddr    address
329	contractHandler PrivilegedActionHandler
330	proposer        Authority // controls who can create proposals
331}
332
333// NewContractAuthority creates a new contract-based authority.
334//
335// SECURITY (Class-4 captured callback): `handler` is a caller-supplied
336// closure that runs SYNCHRONOUSLY inside Authorize, with the consumer's
337// authority. A hostile handler can swallow actions, log the caller, or
338// execute arbitrary code under the consumer's frame. The package-internal
339// wrappedAction guards "execute action only from contractAddr" but the
340// handler can call wrappedAction however it likes (multiple times, never,
341// out of order). Register only trusted handler functions; treat handler
342// registration as the trust boundary.
343func NewContractAuthority(path string, handler PrivilegedActionHandler) *ContractAuthority {
344	return &ContractAuthority{
345		contractPath:    path,
346		contractAddr:    chain.PackageAddress(path),
347		contractHandler: handler,
348		proposer:        NewAutoAcceptAuthority(), // default: anyone can propose
349	}
350}
351
352// NewRestrictedContractAuthority creates a new contract authority with a
353// proposer restriction.
354//
355// SECURITY:
356//   - `handler` is the same Class-4 captured-callback risk as
357//     NewContractAuthority — runs synchronously inside Authorize with the
358//     consumer's authority. Register only trusted handler functions.
359//   - `proposer` is an open-interface input (Class-3 impl-substitution).
360//     A hostile proposer Authority can always-approve creation of any
361//     proposal, defeating the restriction. Pass canonical impls only.
362func NewRestrictedContractAuthority(path string, handler PrivilegedActionHandler, proposer Authority) Authority {
363	if path == "" {
364		panic("contract path cannot be empty")
365	}
366	if handler == nil {
367		panic("contract handler cannot be nil")
368	}
369	if proposer == nil {
370		panic("proposer cannot be nil")
371	}
372	return &ContractAuthority{
373		contractPath:    path,
374		contractAddr:    chain.PackageAddress(path),
375		contractHandler: handler,
376		proposer:        proposer,
377	}
378}
379
380func (a *ContractAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
381	if a.contractHandler == nil {
382		return errors.New("contract handler is not set")
383	}
384
385	// setup a once instance to ensure the action is executed only once
386	executionOnce := once.Once{}
387
388	// wrappedAction enforces at-most-once invocation. The previous
389	// gate `unsafe.CurrentRealm() == contractAddr` is removed: it
390	// was .Title()-bypassable (runtime.CurrentRealm walks past
391	// non-crossing frames to the most-recent crossing ancestor) and
392	// the trust boundary is now upstream — Authorizer.DoByCurrent /
393	// DoByPrevious require rlm.IsCurrent() and pass a non-forgeable
394	// principal to Authorize, while the consumer realm's handler
395	// closure is the Class-4 trust root by lexical capture at
396	// registration time.
397	wrappedAction := func() error {
398		return executionOnce.DoErr(func() error {
399			return action()
400		})
401	}
402
403	// Use the proposer authority to control who can create proposals
404	return a.proposer.Authorize(caller, title+"_proposal", func() error {
405		if err := a.contractHandler(title, wrappedAction); err != nil {
406			return err
407		}
408		return nil
409	}, args...)
410}
411
412func (a *ContractAuthority) String() string {
413	return ufmt.Sprintf("contract_authority[contract=%s]", a.contractPath)
414}
415
416// AutoAcceptAuthority implements an authority that accepts all actions
417// AutoAcceptAuthority is a simple authority that automatically accepts all
418// actions.
419// It can be used as a proposer authority to allow anyone to create proposals.
420type AutoAcceptAuthority struct{}
421
422func NewAutoAcceptAuthority() *AutoAcceptAuthority {
423	return &AutoAcceptAuthority{}
424}
425
426func (a *AutoAcceptAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
427	return action()
428}
429
430func (a *AutoAcceptAuthority) String() string {
431	return "auto_accept_authority"
432}
433
434// droppedAuthority implements an authority that denies all actions
435type droppedAuthority struct{}
436
437func NewDroppedAuthority() Authority {
438	return &droppedAuthority{}
439}
440
441func (a *droppedAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
442	return errors.New("dropped authority: all actions are denied")
443}
444
445func (a *droppedAuthority) String() string {
446	return "dropped_authority"
447}