Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

admin.gno

10.04 Kb · 202 lines
  1package valopers
  2
  3import (
  4	"gno.land/p/moul/authz/v0"
  5	"gno.land/p/nt/ufmt/v0"
  6	"gno.land/r/gov/dao"
  7)
  8
  9var auth *authz.Authorizer
 10
 11// Auth returns a read-only description of the realm's current governance
 12// authority (for rendering / inspection). It renders as
 13//
 14//	contract_authority[contract=gno.land/r/gov/dao,proposer=contract-identity]
 15//
 16// i.e. "GovDAO governs this realm, and only GovDAO may drive it" — a
 17// statement an on-chain reader can act on. The proposer half is
 18// load-bearing: without it the string is byte-identical whether the
 19// authority is gated or wide open (see ContractAuthority.String).
 20//
 21// It deliberately does NOT return the live *authz.Authorizer: an
 22// exported handle to the authority would let any realm reach its
 23// mutators (Transfer, DoByPrevious, ...) directly. Returning a
 24// description instead of the handle keeps that surface unreachable from
 25// here in the first place. Valoper.AuthOwner follows the same rule for
 26// per-operator auth lists.
 27func Auth() string {
 28	return auth.String()
 29}
 30
 31// updateInstructions is the realm's only privileged write. It authorizes
 32// as the realm that CROSSED INTO valopers, not as valopers itself.
 33//
 34// Why DoByPrevious and not DoByCurrent: own-path + DoByCurrent is a
 35// tautology (see NewContractAuthority's godoc for the mechanism, once).
 36// `rlm.Previous()` is the caller — on the legitimate path r/gov/dao's
 37// executor frame, which is what init.gno's authority is pointed at.
 38//
 39// What it buys: it is the code-level guard against this realm's most
 40// likely regression, a maintainer later adding an exported crossing
 41// entrypoint that reaches here. Under DoByCurrent such an entrypoint
 42// authorizes for any caller; under DoByPrevious the caller's own address
 43// is the principal and the gate rejects it. Pinned by
 44// filetests/z_govdao_only_principal_filetest.gno.
 45//
 46// Two things it does NOT cover on its own, both closed elsewhere:
 47// re-exporting the privileged closure (guarded by the seal in
 48// NewInstructionsProposalRequest), and exporting a function whose
 49// signature is assignable to dao's `func(realm) error` callback type,
 50// which the proxy would invoke on an attacker's behalf. The second was a
 51// live bypass of this gate until SimpleExecutor.Execute began rejecting
 52// invocation from outside r/gov/dao (r/gov/dao/types.gno); keep BOTH
 53// conditions in mind before adding an exported symbol here.
 54//
 55// Returns the authorization error rather than panicking on it. That matters
 56// on the governance path: impl.ExecuteProposal turns an executor ERROR into
 57// status Denied plus a DeniedReason, but it cannot see a panic — a panic
 58// aborts the whole ExecuteProposal transaction, burning its gas and leaving
 59// the proposal stuck Accepted, retryable forever and never deniable. The
 60// refusal is currently unreachable on the approved route (Previous() there
 61// is always r/gov/dao), but init.gno's handler is explicitly offered as the
 62// seam for an intent check (timelock, quorum, audit trail), and the first
 63// such check to return an error would hit exactly that.
 64func updateInstructions(_ int, rlm realm, newInstructions string) error {
 65	return auth.DoByPrevious(0, rlm, "update-instructions", func() error {
 66		instructions = newInstructions
 67		return nil
 68	})
 69}
 70
 71// NewInstructionsProposalRequest builds the GovDAO proposal that rewrites
 72// this realm's instructions. It replaces the previously exported
 73// NewInstructionsProposalCallback.
 74//
 75// SECURITY: the privileged closure must never leave this package.
 76//
 77// The VM mints a crossing frame's `cur` from the CALLEE's declaring
 78// package, so a closure declared here always runs with valopers'
 79// identity no matter who invokes it — and whoever holds the closure
 80// chooses its Previous() by wrapping it in an executor of their own.
 81// Handing such a closure to a caller therefore hands out the ability to
 82// satisfy this realm's gate whichever principal it is pointed at: the
 83// handler in init.gno runs the action and `instructions` is rewritten
 84// with no proposal and no vote. That is what the exported callback did.
 85//
 86// Returning a dao.ProposalRequest instead seals the capability: the
 87// executor is an unexported field with no accessor (dao.ProposalRequest
 88// exposes only Title/Description/Filter), so the only way to reach the
 89// closure is for GovDAO to execute the proposal that contains it.
 90//
 91// Note this is deliberately stronger than returning a dao.Executor —
 92// even a dao.NewSafeExecutor-wrapped one. Executor.Execute is an
 93// exported method, so a returned Executor stays directly invocable by
 94// its holder, and SafeExecutor's only gate (InAllowedDAOs) FAILS OPEN
 95// while the allowedDAOs list is empty, which is the documented
 96// bootstrap state (see r/gov/dao/loader/v0). A sealed request has no
 97// such conditional.
 98//
 99// The rule for this realm: no exported function may return a crossing
100// closure, a dao.Executor, or anything else that carries valopers'
101// frame identity — and no exported function may itself BE such a thing,
102// i.e. have a signature assignable to dao's `func(realm) error` callback
103// type, since the proxy would then invoke it on an attacker's behalf.
104// filetests/z_foreign_realm_capability_filetest.gno pins the first;
105// filetests/z_govdao_only_principal_filetest.gno pins the second.
106func NewInstructionsProposalRequest(cur realm, newInstructions string) dao.ProposalRequest {
107	cb := func(cur realm) error {
108		return updateInstructions(0, cur, newInstructions)
109	}
110
111	title := instructionsProposalTitle
112	description := ufmt.Sprintf("Update the instructions to: \n\n%s", newInstructions)
113
114	return dao.NewProposalRequest(title, description, dao.NewSimpleExecutor(0, cur, cb, ""))
115}
116
117// instructionsProposalTitle is the on-chain title of the instructions
118// proposal, named as a constant so a test can pin it: it is rendered to
119// GovDAO voters and is the string off-chain tooling matches on. It was
120// "/p/gnops/valopers: ..." before this realm's proposal construction moved
121// here; the "/p/" was simply wrong (valopers is an /r/ realm).
122const instructionsProposalTitle = "/r/gnops/valopers: Update instructions"
123
124// rotateAuthority replaces this realm's governance authority.
125//
126// Unexported, and reachable only through the sealed request built by
127// NewAuthorityRotationProposalRequest — same discipline as
128// updateInstructions, for the same reason.
129//
130// authz.Authorizer.Transfer derives its principal as
131// rlm.Previous().Address(), so inside a GovDAO-executed callback declared
132// here Previous() is r/gov/dao — exactly the principal init.gno's authority
133// asserts. No same-package cross() trick is needed.
134func rotateAuthority(_ int, rlm realm, newContractPath string) error {
135	return auth.Transfer(0, rlm, authz.NewContractAuthority(
136		newContractPath,
137		func(_ string, action authz.PrivilegedAction) error {
138			return action()
139		},
140	))
141}
142
143// NewAuthorityRotationProposalRequest builds the GovDAO proposal that
144// re-points this realm's governance authority at `newContractPath`.
145//
146// WHY THIS EXISTS. Without it the authority installed by init.gno is
147// permanently frozen: nothing else calls auth.Transfer, Auth() returns a
148// description rather than the live Authorizer, and Transfer routes through
149// the authority's own gate — so if the asserted principal ever stops being
150// presentable, `instructions` becomes unwritable with no on-chain recovery.
151// That is not hypothetical: the r/gov/dao proxy path is a governance
152// artifact that can be superseded, and a well-formed but wrong path (say
153// the impl path instead of the proxy) is accepted at construction and dead
154// forever. Redeploying instead is expensive — r/sys/validators/v0/cache.gno
155// hardcodes `const valopersRealmPath`, so a new pkgpath means editing a
156// second genesis realm and re-registering every valoper. It must exist
157// before deploy, because code cannot be added to a deployed realm.
158//
159// WHY A PATH AND NOT AN authz.Authority. Taking an Authority would make
160// this an open-interface input (Class-3 impl-substitution): a proposal
161// could install an always-approve or always-deny authority, and readers of
162// Auth() could not tell from the rendered description alone. Taking a path
163// keeps the installed authority canonical by construction — always a
164// ContractAuthority with the pass-through handler and the contract-identity
165// gate — so the only thing governance can change is WHICH principal is
166// asserted. The path is validated by authz.NewContractAuthority, which
167// panics on a malformed one, aborting the proposal execution rather than
168// bricking the realm.
169//
170// This remains a privileged surface: a majority that can pass this proposal
171// can point the authority at a principal it controls. That is the same
172// power a majority already has over `instructions`, and it is the price of
173// being recoverable at all.
174func NewAuthorityRotationProposalRequest(cur realm, newContractPath string) dao.ProposalRequest {
175	cb := func(cur realm) error {
176		return rotateAuthority(0, cur, newContractPath)
177	}
178
179	title := "/r/gnops/valopers: Rotate governance authority"
180	description := ufmt.Sprintf(
181		"Re-point the valopers governance authority at: \n\n%s\n\n"+
182			"The authority stays a contract-identity ContractAuthority; only the asserted principal changes.",
183		newContractPath,
184	)
185
186	return dao.NewProposalRequest(title, description, dao.NewSimpleExecutor(0, cur, cb, ""))
187}
188
189// NewInstructionsProposalCallback was removed, not deprecated: it was the
190// capability leak described above, so an inert shim would be pointless and
191// a working one would reopen the hole. Precedent + replay check: the
192// min-fee callback below was already removed the same way even though it
193// HAD been used on gnoland1 (GovDAO Prop #19), whereas the instructions
194// callback has no on-chain trace at all — the live realm's instructions
195// are byte-identical to the genesis default and none of gnoland1's 26
196// GovDAO proposals is an "Update instructions" one.
197//
198// The min-fee callback was removed: the fee now lives in sysparams
199// under node:valoper:register_fee, and
200// proposal.ProposeNewMinFeeProposalRequest delegates to
201// sys/params.NewSysParamUint64PropRequest. Removing avoids the
202// forward-compat hazard of a no-op shim with no caller-auth gating.