Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Make access edits and approvals survive a concurrent change

62b59a76f9064a58a45d0997f6889d2a7f6265e7 · Keenan Nemetz · 13d ago · parent 78cd3437

Make access edits and approvals survive a concurrent change

Access rules were one row, and every editor read it, changed a field, and
wrote the whole document back with no version check. Two people on the
page at once silently overwrote each other, and the specific case that
mattered was worse than a lost edit: a repository admin's stale settings
save could restore a role an instance admin had just revoked.

The row now carries a revision, and a rule set read through the store
carries the revision it was read at. Writing it back is a
compare-and-swap: if the row moved, the write is refused with ErrConflict
instead of undoing the other change. A rule set built from scratch is a
create and refuses if a row appeared meanwhile, so no stale document can
replace a live one. Existing instances get the column added on open.

On top of that, the operations that own one field apply it to the row as
it is now (Store.Update, retried against a moving row):

  - the repository Settings page writes only the visibility. It no longer
    rewrites entries, runners or the approval policy, so an instance
    admin's revocation survives a page that was loaded before it.
  - adding one account writes that one entry.
  - the access form changes only the rows it rendered; an entry or a
    runners-list membership added while the page was open is kept.

Approvals and runners are also bound to current standing. An approval
counts only while its author is an active account that still holds
maintain or admin on the repository, filtered in FindApprovedMatch and
counted the same way on the request page, so the page cannot promise a
merge the gate then refuses. ACL.CanReport refuses a retired account even
while its name is in the runners list; the row outlives the account, so
membership alone would let a revoked account keep vouching for builds and
leasing jobs. The roster's Active predicate is attached by the store, the
same way the instance role already was, in both the server and the hook
process.

Tests: a stale whole-document write is refused and cannot restore a
revoked role; concurrent field-scoped updates all land; Update reports
the row as it is now; a retired account stops counting as an approver and
as a runner; a demoted maintainer's approval no longer lands a change; a
settings save leaves entries, runners, approval policy and protection
alone; a retired runner's record push is refused.

forge/access/acl.go +42−2

@@ -97,13 +97,53 @@ type ACL struct {
97 97 // pass it in. It is deliberately unexported: instance roles belong to the
98 98 // account, and writing one into repo_acl would freeze it at that moment.
99 99 instanceRole func(string) Role
100 // active is the roster's answer to "is this account still active", attached by
101 // the store beside instanceRole. An explicit entry or runners-list membership
102 // outlives retirement otherwise: the row stays after the account is retired,
103 // and a retired account that still counts as an approver or a runner is a
104 // permission nobody revoked. Nil means the caller has no roster (a test).
105 active func(string) bool
106 // revision is the version of the row this rule set was read from, attached by
107 // the store. It is what makes a write compare-and-swap: a rule set read before
108 // somebody else's change carries the old revision and is refused. Zero means a
109 // rule set built from scratch for a repository that has no row yet.
110 revision int64
111 }
112
113 // Active reports whether the account is still active. A rule set with no roster
114 // attached treats every name as active, which is only true in a test: every
115 // instance wires the lookup.
116 func (a *ACL) Active(name string) bool {
117 if a == nil || a.active == nil {
118 return true
119 }
120 return a.active(name)
121 }
122
123 // EligibleApprover reports whether this account's approval still counts. An
124 // approval says "I read this change and I am answerable for it"; that statement is
125 // worth nothing if the account has been retired or no longer maintains the
126 // repository. The gate and the page count approvals through this rule, so they
127 // cannot disagree about how many there are.
128 func (a *ACL) EligibleApprover(name string) bool {
129 if a == nil || name == "" || !a.Active(name) {
130 return false
131 }
132 return a.RoleFor(name).AtLeast(Maintain)
100 133 }
101 134
102 135 // CanReport says whether a CI result reported by this account counts as a check
103 136 // for this repository. An empty account never counts: an unattributed result is not
104 // a result anybody is answerable for.
137 // a result anybody is answerable for. Neither does a retired account: the runners
138 // list is edited by hand and the row stays after retirement, so membership alone
139 // would let a revoked account keep vouching for builds — and keep leasing jobs,
140 // which means executing repository code.
141 //
142 // A repository role is deliberately not required of an explicit runner. The
143 // runners list answers a different question from read/write ("may this account's
144 // build be believed about it"), and a machine that only builds may hold no role.
105 145 func (a *ACL) CanReport(accountID string) bool {
106 if accountID == "" {
146 if accountID == "" || !a.Active(accountID) {
107 147 return false
108 148 }
109 149 if a.RoleFor(accountID).AtLeast(Admin) {