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.