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.

authz.gno

29.57 Kb · 709 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: The contract at a given path is itself the authority
  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: Makes the contract at a path its own authority
 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/nym-moultest123/addrset/v1"
 43	"gno.land/p/nym-moultest123/once/v0"
 44	"gno.land/p/nt/ufmt/v0"
 45)
 46
 47// Authorizer is the main wrapper object that handles authority management.
 48// It is configured with a replaceable Authority implementation.
 49type Authorizer struct {
 50	auth Authority
 51}
 52
 53// Authority represents an entity that can authorize privileged actions.
 54// It is implemented by MemberAuthority, ContractAuthority, AutoAcceptAuthority,
 55// and DroppedAuthority.
 56//
 57// Authority is the canonical safe shape for cross-package authority
 58// interfaces: methods are address-typed (no realm/cur crosses the interface
 59// boundary), and consumers correctly derive `caller` from
 60// `cur.Previous().Address()` under `rlm.IsCurrent()` before invoking
 61// Authorize. No cur-leak (class 1) is possible through this interface.
 62//
 63// However, two RESIDUAL RISKS apply:
 64//
 65//   - Class-3 impl-substitution: NewWithAuthority and Authorizer.Transfer
 66//     accept any Authority impl. A malicious Authority can always-approve
 67//     (silent privilege escalation) or always-deny (denial-of-service).
 68//     Consumers should pass canonical impls from this package
 69//     (MemberAuthority, ContractAuthority, AutoAcceptAuthority,
 70//     DroppedAuthority) unless they have explicit reason to register a
 71//     foreign impl. We do not expose an IsCanonicalAuthority allowlist
 72//     because the package is intentionally extensible — third-party impls
 73//     are the design intent.
 74//
 75//   - Class-4 closed-over-authority: NewContractAuthority and
 76//     NewRestrictedContractAuthority capture a caller-supplied
 77//     PrivilegedActionHandler closure. The handler runs synchronously
 78//     inside Authorize with the consumer's authority. A hostile handler
 79//     can swallow actions, log the caller, or execute arbitrary code
 80//     under the consumer's frame. Register only trusted handler functions.
 81//     See r/gnops/valopers/init.gno for the realistic registration shape.
 82//
 83//   - Caller- and title-forgery on the RAW interface: Authorize takes
 84//     `caller` and `title` as ARGUMENTS, not from the frame. A consumer
 85//     that exposes a bare Authority (rather than the *Authorizer that
 86//     wraps it) therefore lets any holder present any caller and any
 87//     title. Caller-forgery is contained — the only authority-mutating
 88//     closure lives inside Authorizer.Transfer, so a forged caller on a
 89//     raw Authorize runs the caller's own inert closure and cannot
 90//     transfer; TestForgedCallerCannotTransfer pins that boundary.
 91//     Title-forgery is NOT contained: the title is passed straight to the
 92//     contractHandler, so a handler that branches on it (routing, quotas,
 93//     audit trails) must not treat it as trusted. Keep the Authority
 94//     unexported and hand out only what callers need; see
 95//     r/gnops/valopers/admin.gno, which exports a description string.
 96//
 97// # Backing store
 98//
 99// This is the B+ tree successor to [gno.land/p/nym-moultest123/authz/v0] (which is
100// backed by an AVL tree via gno.land/p/nym-moultest123/addrset/v0): a bump to v1
101// because the member store — and thus the on-chain storage layout —
102// changed to gno.land/p/nym-moultest123/addrset/v1, itself backed by
103// gno.land/p/nt/bptree/v0. This is a storage/compatibility change; the
104// exported API is otherwise the same as v0, EXCEPT that the v0
105// MemberAuthority.Tree() escape hatch is intentionally removed so the
106// backing store never leaks across realms.
107//
108// Two behavioral caveats follow from the in-place-mutating B+ tree backing:
109//
110//   - do NOT mutate a MemberAuthority (AddMember/RemoveMember) from inside
111//     an iteration callback — the AVL backing's copy-on-write tolerated it,
112//     this one does not;
113//   - do NOT copy a non-zero MemberAuthority (or its members set) by value —
114//     the copies would share live tree nodes while their roots and sizes
115//     diverge (v0's copies were independent snapshots).
116//
117// We do NOT seal Authority via an unexported marker method — that pattern
118// is bypassable via embedding in Gno; see
119// p/test/seal/filetests/z_seal_*_filetest.gno for the four bypass tests.
120type Authority interface {
121	// Authorize executes a privileged action if the caller is authorized
122	// Additional args can be provided for context (e.g., for proposal creation)
123	Authorize(caller address, title string, action PrivilegedAction, args ...any) error
124
125	// String returns a human-readable description of the authority
126	String() string
127}
128
129// PrivilegedAction defines a function that performs a privileged action.
130type PrivilegedAction func() error
131
132// PrivilegedActionHandler is called by contract-based authorities to handle
133// privileged actions.
134type PrivilegedActionHandler func(title string, action PrivilegedAction) error
135
136// NewWithMembers creates a new Authorizer whose authority is a
137// MemberAuthority containing the given addresses. Callers express
138// authority intent at the call site:
139//
140//	// "auth realm is the authority"
141//	a := authz.NewWithMembers(cur.Address())
142//
143//	// "previous realm is the authority" (from a crossing function)
144//	a := authz.NewWithMembers(cur.Previous().Address())
145//
146//	// "EOA caller is the authority" (from init(cur realm))
147//	if !cur.Previous().IsUserCall() {
148//	    panic("realm must be initialized by EOA")
149//	}
150//	a := authz.NewWithMembers(cur.Previous().Address())
151//
152// This replaces the previous NewWithCurrent / NewWithPrevious /
153// NewWithOrigin sugar — those baked runtime.{Current,Previous,Origin}
154// reads into the constructor, which (a) prevented use from package-
155// level var initializers, (b) made the EOA-origin check inside
156// NewWithOrigin an indirect address comparison rather than the
157// straightforward IsUserCall predicate, and (c) coupled the
158// constructor to the runtime walks the rest of the migration is
159// moving away from.
160func NewWithMembers(addrs ...address) *Authorizer {
161	return &Authorizer{
162		auth: NewMemberAuthority(addrs...),
163	}
164}
165
166// NewWithAuthority creates a new Authorizer with a specific authority.
167//
168// SECURITY: `authority` is an open-interface input — any value satisfying
169// Authority is accepted. A malicious impl can always-approve (privilege
170// escalation) or always-deny (DoS). Prefer canonical impls from this
171// package (NewMemberAuthority, NewContractAuthority, NewAutoAcceptAuthority,
172// NewDroppedAuthority) unless you specifically need a foreign impl.
173func NewWithAuthority(authority Authority) *Authorizer {
174	return &Authorizer{
175		auth: authority,
176	}
177}
178
179// Authority returns the auth authority implementation
180func (a *Authorizer) Authority() Authority {
181	return a.auth
182}
183
184// Transfer changes the auth authority after validation. rlm must be the
185// caller's own captured cur (asserted via rlm.IsCurrent()); the
186// principal is rlm.Previous().Address(). Closes the address-parameter
187// forgery: an external realm cannot supply Owner() as `caller` to
188// bypass the underlying Authority's check.
189//
190// SECURITY (runtime substitution): once the current authority approves a
191// Transfer, the new authority is installed and effective on the next call.
192// If an attacker ever becomes the authority — even briefly — they can
193// install a permanent DroppedAuthority (DoS) or an AutoAcceptAuthority
194// (privilege escalation). Consumers concerned about this should wrap
195// Transfer with a one-shot guard or a quorum/cooldown check.
196//
197// `newAuthority` is also an open-interface input — see NewWithAuthority's
198// Class-3 caveat. Pass canonical impls.
199func (a *Authorizer) Transfer(_ int, rlm realm, newAuthority Authority) error {
200	if !rlm.IsCurrent() {
201		return errors.New("unauthorized")
202	}
203	caller := rlm.Previous().Address()
204	return a.auth.Authorize(caller, "transfer_authority", func() error {
205		a.auth = newAuthority
206		return nil
207	})
208}
209
210// DoByCurrent executes a privileged action authorized as `rlm`. `rlm`
211// must be the caller's own live cur (asserted via rlm.IsCurrent());
212// the authorized principal is `rlm.Address()`. To authorize as the
213// realm that called your function, use `DoByPrevious`.
214//
215//	auth.DoByCurrent(0, cur, "update_config", func() error { ... })    // current realm authorizes
216//	auth.DoByPrevious(0, cur, "update_config", func() error { ... })   // calling realm authorizes
217//
218// The `_ int` first parameter is a deliberate sentinel that pushes
219// `rlm realm` past the first-arg position so DoByCurrent stays a
220// non-crossing method — otherwise it would be a crossing method and
221// rlm.Previous() inside would resolve one realm deeper than the caller
222// intended.
223//
224// SECURITY: the IsCurrent guard closes Class-2 designation forgery (see
225// docs/resources/gno-security.md). A realm value's .Address() is set
226// when the value is minted at a crossing frame; the value can in
227// principle be stored and replayed. Without IsCurrent, a hostile realm
228// could capture a high-privilege realm's cur.Previous() (e.g., when
229// that realm called into it) and later pass the stored value here to
230// authorize actions as that realm. IsCurrent rejects stale captures by
231// requiring the value to match the topmost live crossing frame's cur.
232func (a *Authorizer) DoByCurrent(_ int, rlm realm, title string, action PrivilegedAction, args ...any) error {
233	if !rlm.IsCurrent() {
234		return errors.New("unauthorized")
235	}
236	return a.auth.Authorize(rlm.Address(), title, action, args...)
237}
238
239// DoByPrevious executes a privileged action authorized as the realm
240// that called the function invoking DoByPrevious. `rlm` must be the
241// caller's own live cur; the principal is derived as
242// `rlm.Previous().Address()`. Mirrors the Transfer/AddMember pattern:
243// always take live cur, derive the caller-of-caller internally rather
244// than accepting a stored/forwarded realm value.
245func (a *Authorizer) DoByPrevious(_ int, rlm realm, title string, action PrivilegedAction, args ...any) error {
246	if !rlm.IsCurrent() {
247		return errors.New("unauthorized")
248	}
249	return a.auth.Authorize(rlm.Previous().Address(), title, action, args...)
250}
251
252// String returns a string representation of the auth authority.
253//
254// A non-canonical impl is wrapped as custom_authority[...] so that the
255// official "dropped" is distinguishable from a "*custom*: dropped"
256// (autoclaimed) one. Shared with ContractAuthority.String, which applies
257// the same rule to its nested proposer.
258func (a *Authorizer) String() string {
259	return canonicalAuthorityString(a.auth)
260}
261
262// MemberAuthority is the default implementation using addrset for member
263// management.
264type MemberAuthority struct {
265	members addrset.Set
266}
267
268func NewMemberAuthority(members ...address) *MemberAuthority {
269	auth := &MemberAuthority{}
270	for _, addr := range members {
271		auth.members.Add(addr)
272	}
273	return auth
274}
275
276func (a *MemberAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
277	if !a.members.Has(caller) {
278		return errors.New("unauthorized")
279	}
280
281	if err := action(); err != nil {
282		return err
283	}
284	return nil
285}
286
287func (a *MemberAuthority) String() string {
288	addrs := []string{}
289	a.members.IterateByOffset(0, a.members.Size(), func(addr address) bool {
290		addrs = append(addrs, string(addr))
291		return false
292	})
293	addrsStr := strings.Join(addrs, ",")
294	return ufmt.Sprintf("member_authority[%s]", addrsStr)
295}
296
297// AddMember adds a new member to the authority. rlm must be the caller's
298// own captured cur; the principal is rlm.Previous().Address() and must
299// already be a member. The IsCurrent guard closes the forgery where an
300// external realm passes Owner() as caller to bypass members.Has(caller).
301func (a *MemberAuthority) AddMember(_ int, rlm realm, addr address) error {
302	if !rlm.IsCurrent() {
303		return errors.New("unauthorized")
304	}
305	caller := rlm.Previous().Address()
306	return a.Authorize(caller, "add_member", func() error {
307		a.members.Add(addr)
308		return nil
309	})
310}
311
312// AddMembers adds a list of members to the authority. Same rlm contract
313// as AddMember.
314func (a *MemberAuthority) AddMembers(_ int, rlm realm, addrs ...address) error {
315	if !rlm.IsCurrent() {
316		return errors.New("unauthorized")
317	}
318	caller := rlm.Previous().Address()
319	return a.Authorize(caller, "add_members", func() error {
320		for _, addr := range addrs {
321			a.members.Add(addr)
322		}
323		return nil
324	})
325}
326
327// RemoveMember removes a member from the authority. Same rlm contract
328// as AddMember.
329func (a *MemberAuthority) RemoveMember(_ int, rlm realm, addr address) error {
330	if !rlm.IsCurrent() {
331		return errors.New("unauthorized")
332	}
333	caller := rlm.Previous().Address()
334	return a.Authorize(caller, "remove_member", func() error {
335		a.members.Remove(addr)
336		return nil
337	})
338}
339
340// Has checks if the given address is a member of the authority
341func (a *MemberAuthority) Has(addr address) bool {
342	return a.members.Has(addr)
343}
344
345// ContractAuthority implements async contract-based authority
346type ContractAuthority struct {
347	contractPath    string
348	contractAddr    address
349	contractHandler PrivilegedActionHandler
350	proposer        Authority // controls who can create proposals
351}
352
353// NewContractAuthority makes the contract at `path` its OWN authority:
354// the default proposer accepts only chain.PackageAddress(path), so a
355// privileged action proceeds only when driven from that contract's own
356// frame.
357//
358// NewRestrictedContractAuthority does NOT widen this — it REPLACES it.
359// See its godoc; with an explicit proposer, `path` is a label and
360// contractAddr is never consulted.
361//
362// The `caller` compared against that address is established upstream by
363// Authorizer.DoByCurrent / DoByPrevious / Transfer under rlm.IsCurrent(),
364// so an external realm cannot present the contract address.
365//
366// BREAKING: the default proposer used to be
367// NewAutoAcceptAuthority ("anyone can propose"). Consumers that relied on
368// arbitrary callers driving a plain ContractAuthority will start getting
369// "unauthorized"; that is the fix, not a regression. Pass an explicit
370// proposer to NewRestrictedContractAuthority to opt back in.
371//
372// SECURITY — the gate binds the contract's IDENTITY, not its intent.
373// Inside any crossing frame of the contract, rlm.Address() is
374// unconditionally PackageAddress(path), so `DoByCurrent` from within the
375// contract is a tautology; only DoByPrevious and Transfer gain a real
376// check. Consequently ANY exported function of the contract that returns
377// a crossing closure (or anything else carrying its frame) hands this
378// authority to its caller. Keep privileged closures unexported.
379//
380// So DO NOT write NewContractAuthority(ownPath) + DoByCurrent. It reads
381// like "only I may do this" and compiles to "anyone may do this". Point
382// `path` at the principal you actually want to assert — usually the
383// governance realm that will drive the action — and use DoByPrevious, so
384// the comparison is against whoever crossed in:
385//
386//	// in r/gnops/valopers/init.gno
387//	auth = authz.NewWithAuthority(
388//	    authz.NewContractAuthority("gno.land/r/gov/dao", handler),
389//	)
390//	// in the privileged write
391//	auth.DoByPrevious(0, rlm, "update-instructions", action)
392//
393// Own-path is right only when something OTHER than the contract's own
394// frame supplies the caller — i.e. you drive it via DoByPrevious from a
395// realm you have deliberately let call in, or via Transfer.
396//
397// SECURITY — a gate is only as good as the principal it names. Pointing
398// `path` at a realm whose frame ANY caller can mint buys nothing. Check,
399// for whatever principal you choose, that nothing exported by that realm
400// hands out its frame identity:
401//
402//   - `gno.land/r/gov/dao` is safe to assert ONLY because
403//     SimpleExecutor.Execute rejects invocation from outside the proxy
404//     (r/gov/dao/types.gno). Before that gate existed, dao's exported
405//     NewSimpleExecutor + Execute let any realm mint a frame whose
406//     Previous() was r/gov/dao, so this whole pairing authenticated
407//     nothing. Do not assume a governance path is unforgeable; verify it.
408//   - Whatever the principal, an exported function of YOUR realm whose
409//     signature is assignable to a callback type the principal invokes
410//     (for r/gov/dao: `func(realm) error`) is itself the leak — the
411//     principal will happily call it for an attacker. Keep privileged
412//     entrypoints out of that shape, or take extra parameters.
413//
414// Neither shape defends against re-exporting the privileged closure: its
415// holder picks the Previous() they present by wrapping it in an executor
416// of their own. Unexported closures remain the load-bearing rule.
417//
418// A syntactically valid but WRONG `path` is a permanent brick, not an
419// error: the authority accepts nobody, and Transfer routes through the
420// same gate, so it cannot be rotated out either. Construction rejects
421// malformed paths, but it cannot tell a wrong path from a right one —
422// e.g. "gno.land/r/gov/dao/impl/v0" (the path r/gov/dao's own
423// allowedDAOs list holds) is well-formed and permanently dead, because
424// an executor's Previous() is the PROXY path, never the impl. Give the
425// consumer a governance-gated rotation entrypoint before deploying; see
426// r/gnops/valopers/admin.gno's NewAuthorityRotationProposalRequest.
427//
428// SECURITY (Class-4 captured callback): `handler` is a caller-supplied
429// closure that runs SYNCHRONOUSLY inside Authorize, with the consumer's
430// authority. A hostile handler can swallow actions, log the caller, or
431// execute arbitrary code under the consumer's frame. The package-internal
432// wrappedAction enforces at-most-once invocation and NOTHING ELSE — in
433// particular it does not check the caller, and the handler may call it
434// however it likes (multiple times, never, out of order). Register only
435// trusted handler functions; treat handler registration as the trust
436// boundary.
437//
438// Panics if `path` is empty or malformed, or if `handler` is nil.
439func NewContractAuthority(path string, handler PrivilegedActionHandler) *ContractAuthority {
440	addr := chain.PackageAddress(assertValidContractPath(path))
441	// The default proposer is a real Authority, not an absent one. Encoding
442	// the strictest policy as a nil field would mean the secure default is
443	// what you get by leaving something OUT: a struct literal that skips
444	// this constructor, a persisted value from an older build, or re-adding
445	// `if proposer == nil { proposer = NewAutoAcceptAuthority() }` would all
446	// silently be wide open again. A one-field struct costs the same as the
447	// nil it replaces and cannot be reached by omission.
448	return newContractAuthority(path, addr, handler, &contractIdentityAuthority{addr: addr})
449}
450
451// NewRestrictedContractAuthority creates a contract authority whose
452// proposer REPLACES the contract-identity gate.
453//
454// This is the escape hatch, not a second lock. `proposer` becomes the ONLY
455// authorization decision: contractAddr is not consulted on this path, so
456// `path` degrades to a label that appears in String() and asserts nothing.
457// NewRestrictedContractAuthority(p, h, NewAutoAcceptAuthority()) is exactly
458// the old default behaviour ("anyone can propose"), stated
459// explicitly rather than reached by default.
460//
461// In particular NewRestrictedContractAuthority("gno.land/r/gov/dao", h,
462// NewMemberAuthority(alice)) does NOT mean "GovDAO and alice". It means
463// "alice, and not GovDAO".
464//
465// SECURITY:
466//   - `handler` is the same Class-4 captured-callback risk as
467//     NewContractAuthority — runs synchronously inside Authorize with the
468//     consumer's authority. Register only trusted handler functions.
469//   - `proposer` is an open-interface input (Class-3 impl-substitution).
470//     A hostile proposer Authority can always-approve creation of any
471//     proposal, defeating the restriction. Pass canonical impls only.
472//     String() renders a non-canonical proposer wrapped as
473//     custom_authority[...] so such a substitution is at least visible.
474func NewRestrictedContractAuthority(path string, handler PrivilegedActionHandler, proposer Authority) Authority {
475	if proposer == nil {
476		panic("proposer cannot be nil")
477	}
478	addr := chain.PackageAddress(assertValidContractPath(path))
479	return newContractAuthority(path, addr, handler, proposer)
480}
481
482// newContractAuthority is the single construction path: both exported
483// constructors validate, then land here, so a guard added once applies to
484// both. `handler` is rejected here rather than surfaced at Authorize time
485// because Authorize checks contractHandler == nil BEFORE consulting the
486// proposer, and Transfer routes through Authorize — so a nil-handler
487// authority could never be rotated out. It is a permanent brick with no
488// on-chain recovery for anyone: the same bricked-governance failure
489// mode, reached by accident.
490func newContractAuthority(path string, addr address, handler PrivilegedActionHandler, proposer Authority) *ContractAuthority {
491	if handler == nil {
492		panic("contract handler cannot be nil")
493	}
494	return &ContractAuthority{
495		contractPath:    path,
496		contractAddr:    addr,
497		contractHandler: handler,
498		proposer:        proposer,
499	}
500}
501
502// assertValidContractPath rejects paths that chain.PackageAddress would
503// hash happily but that no realm could ever present, which would brick the
504// authority permanently (nobody authorizes, and Transfer routes through the
505// same gate so nobody can rotate out). Returns `path` so it composes.
506//
507// The authoritative rule is gnolang.ReGnoUserPkgPath ("all paths must be
508// lowercase ascii alphanumeric characters", gnovm/pkg/gnolang/mempackage.go),
509// but no validator is exported to Gno code — chain's isValidSubpath /
510// assertValidSubpath are unexported, and there is no chain.IsValidPkgPath.
511// So this is a deliberately permissive superset: segment ("/" segment)*,
512// segment = [a-z0-9] ([a-z0-9_.-]* [a-z0-9])?. It accepts every real
513// package path and catches what actually gets typed wrong — trailing or
514// embedded whitespace, empty segments, uppercase, stray punctuation. It
515// CANNOT catch a well-formed path that is simply the wrong principal. See
516// NewContractAuthority's godoc on rotation.
517func assertValidContractPath(path string) string {
518	if path == "" {
519		panic("contract path cannot be empty")
520	}
521	if !strings.Contains(path, "/") {
522		panic("contract path must be a package path, e.g. gno.land/r/gov/dao")
523	}
524	start := 0
525	for i := 0; i <= len(path); i++ {
526		if i == len(path) || path[i] == '/' {
527			if !isValidContractPathSegment(path[start:i]) {
528				panic("contract path must be '/'-separated segments of [a-z0-9] with '_.-' allowed inside a segment, e.g. gno.land/r/gov/dao")
529			}
530			start = i + 1
531		}
532	}
533	return path
534}
535
536func isValidContractPathSegment(seg string) bool {
537	if seg == "" {
538		return false
539	}
540	for i := 0; i < len(seg); i++ {
541		switch c := seg[i]; {
542		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
543		case c == '_' || c == '.' || c == '-':
544			// Allowed only strictly inside a segment.
545			if i == 0 || i == len(seg)-1 {
546				return false
547			}
548		default:
549			return false
550		}
551	}
552	return true
553}
554
555// contractIdentityAuthority is the default proposer installed by
556// NewContractAuthority: the contract at the bound path, and nobody else.
557// Unexported and unmutable by design — AddMember/RemoveMember are
558// meaningless for a fixed contract identity, which is why this is not a
559// MemberAuthority holding one address.
560type contractIdentityAuthority struct {
561	addr address
562}
563
564func (a *contractIdentityAuthority) Authorize(caller address, _ string, action PrivilegedAction, _ ...any) error {
565	if caller != a.addr {
566		return errors.New("unauthorized")
567	}
568	return action()
569}
570
571func (a *contractIdentityAuthority) String() string { return "contract-identity" }
572
573func (a *ContractAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
574	if a.contractHandler == nil {
575		return errors.New("contract handler is not set")
576	}
577
578	// setup a once instance to ensure the action is executed only once
579	executionOnce := once.Once{}
580
581	// wrappedAction enforces at-most-once invocation. The previous
582	// gate `unsafe.CurrentRealm() == contractAddr` is removed: it
583	// was .Title()-bypassable (runtime.CurrentRealm walks past
584	// non-crossing frames to the most-recent crossing ancestor) and
585	// the trust boundary is now upstream — Authorizer.DoByCurrent /
586	// DoByPrevious require rlm.IsCurrent() and pass a non-forgeable
587	// principal to Authorize, while the consumer realm's handler
588	// closure is the Class-4 trust root by lexical capture at
589	// registration time.
590	wrappedAction := func() error {
591		return executionOnce.DoErr(func() error {
592			return action()
593		})
594	}
595
596	handle := func() error {
597		if err := a.contractHandler(title, wrappedAction); err != nil {
598			return err
599		}
600		return nil
601	}
602
603	// The proposer IS the authorization decision. For an authority built by
604	// NewContractAuthority it is a contractIdentityAuthority bound to
605	// contractAddr — the contract itself and nobody else; for one built by
606	// NewRestrictedContractAuthority it is whatever the consumer installed,
607	// and contractAddr is deliberately not consulted (see that constructor's
608	// godoc). `caller` is established upstream by Authorizer.DoByCurrent /
609	// DoByPrevious / Transfer under rlm.IsCurrent(), so an external realm
610	// cannot present an arbitrary principal here.
611	//
612	// A nil proposer is not a policy, it is a malformed value: both
613	// constructors always install one, so nil means the struct was built by
614	// a literal that bypassed them. Fail closed rather than dereference.
615	if a.proposer == nil {
616		return errors.New("proposer is not set")
617	}
618	return a.proposer.Authorize(caller, title+"_proposal", handle, args...)
619}
620
621// String renders the contract path AND the proposer.
622//
623// The proposer half is security-relevant, not cosmetic: it is the only
624// thing distinguishing a gated authority from a wide-open one. Rendering
625// the path alone made
626//
627//	NewContractAuthority(path, handler)                              // gated
628//	NewRestrictedContractAuthority(path, handler, AutoAccept{})      // open to all
629//
630// byte-identical, so any consumer test asserting on this string — and
631// any on-chain reader inspecting it — was blind to the difference. A
632// consumer realm could have its authority swapped for a fully permissive
633// one and its assertions would stay green.
634//
635// "contract-identity" names the default installed by NewContractAuthority:
636// the contract at contractPath, and nobody else, may drive this authority.
637//
638// The proposer is rendered through canonicalAuthorityString, NOT by calling
639// a.proposer.String() directly. Authority is an open interface, so a foreign
640// impl can return any text it likes — including "contract-identity". That
641// made a fully permissive authority byte-identical to the gated default
642// again, defeating the very assertions this rendering exists to support.
643// Non-canonical impls are wrapped as custom_authority[...], mirroring what
644// Authorizer.String has always done at the outer level.
645//
646// Read the `contract=` half with care: it is load-bearing only for the
647// contract-identity default. With any other proposer it is a label —
648// see NewRestrictedContractAuthority.
649func (a *ContractAuthority) String() string {
650	return ufmt.Sprintf(
651		"contract_authority[contract=%s,proposer=%s]",
652		a.contractPath,
653		canonicalAuthorityString(a.proposer),
654	)
655}
656
657// canonicalAuthorityString renders an Authority, wrapping any
658// implementation that is not one of this package's own as
659// custom_authority[...] so a foreign impl cannot impersonate a canonical
660// one by choosing its String() text. Shared by ContractAuthority.String
661// (for the nested proposer) and Authorizer.String (for the installed
662// authority).
663func canonicalAuthorityString(auth Authority) string {
664	if auth == nil {
665		// Only reachable via a struct literal that bypassed the
666		// constructors; Authorize fails closed on the same condition.
667		return "<unset>"
668	}
669	switch auth.(type) {
670	case *MemberAuthority, *ContractAuthority, *AutoAcceptAuthority,
671		*droppedAuthority, *contractIdentityAuthority:
672		return auth.String()
673	default:
674		return ufmt.Sprintf("custom_authority[%s]", auth.String())
675	}
676}
677
678// AutoAcceptAuthority implements an authority that accepts all actions
679// AutoAcceptAuthority is a simple authority that automatically accepts all
680// actions.
681// It can be used as a proposer authority to allow anyone to create proposals.
682type AutoAcceptAuthority struct{}
683
684func NewAutoAcceptAuthority() *AutoAcceptAuthority {
685	return &AutoAcceptAuthority{}
686}
687
688func (a *AutoAcceptAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
689	return action()
690}
691
692func (a *AutoAcceptAuthority) String() string {
693	return "auto_accept_authority"
694}
695
696// droppedAuthority implements an authority that denies all actions
697type droppedAuthority struct{}
698
699func NewDroppedAuthority() Authority {
700	return &droppedAuthority{}
701}
702
703func (a *droppedAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
704	return errors.New("dropped authority: all actions are denied")
705}
706
707func (a *droppedAuthority) String() string {
708	return "dropped_authority"
709}