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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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 · 12d 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.

server/web/admin_access.go +44−19

@@ -79,47 +79,68 @@ func (w *Web) SaveRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
79 79 return
80 80 }
81 81
82 updated := *acl
83 updated.Entries = map[string]string{}
84 82 defaultRole, err := access.ParseRole(r.PostFormValue("default"))
85 83 if err != nil {
86 84 w.accessFailure(rw, r, ctx, err.Error())
87 85 return
88 86 }
89 updated.Default = defaultRole.String()
90 87 approvals, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("approvals")))
91 88 if err != nil || approvals < 0 {
92 89 w.accessFailure(rw, r, ctx, "approvals must be a non-negative number")
93 90 return
94 91 }
95 updated.ApprovalsRequired = &approvals
96 92 checks := r.PostFormValue("checks") != ""
97 updated.ChecksRequired = &checks
98 updated.Protected = splitLines(r.PostFormValue("protected"))
93 protected := splitLines(r.PostFormValue("protected"))
99 94
95 // The rows this page rendered, and what the form said about each one. Anything
96 // the form did not submit is not this submission's to change: an entry or a
97 // runner another admin added while this page was open must survive the save.
98 rendered := map[string]bool{}
99 type roleChange struct {
100 name string
101 role access.Role
102 }
103 var roles []roleChange
100 104 var runners []string
101 105 for _, row := range w.aclRows(acl) {
102 // A field the form did not submit keeps whatever the ACL already had,
103 // so a race with a newly added entry cannot silently delete it.
106 rendered[row.Name] = true
104 107 if raw, present := r.PostForm["role:"+row.Name]; present {
105 108 role, err := access.ParseRole(raw[0])
106 109 if err != nil {
107 110 w.accessFailure(rw, r, ctx, "account "+row.Name+": "+err.Error())
108 111 return
109 112 }
110 if role != access.None {
111 updated.Entries[row.Name] = role.String()
112 }
113 } else if existing, ok := acl.Entries[row.Name]; ok {
114 updated.Entries[row.Name] = existing
113 roles = append(roles, roleChange{name: row.Name, role: role})
115 114 }
116 115 if r.PostFormValue("runner:"+row.Name) != "" {
117 116 runners = append(runners, row.Name)
118 117 }
119 118 }
120 updated.Runners = nil
121 updated.SetRunners(runners)
122 if err := m.SetACL(&updated, pr.account.Name); err != nil {
119
120 if _, err := m.UpdateACL(pr.account.Name, func(cur *access.ACL) error {
121 cur.Default = defaultRole.String()
122 cur.ApprovalsRequired = &approvals
123 cur.ChecksRequired = &checks
124 cur.Protected = protected
125 for _, rc := range roles {
126 if rc.role == access.None {
127 delete(cur.Entries, rc.name)
128 continue
129 }
130 cur.Entries[rc.name] = rc.role.String()
131 }
132 // Runners for accounts this page did not render stay as they are; the
133 // rendered ones are exactly what the form said.
134 var kept []string
135 for _, existing := range cur.Runners {
136 if !rendered[existing] {
137 kept = append(kept, existing)
138 }
139 }
140 cur.Runners = nil
141 cur.SetRunners(append(kept, runners...))
142 return nil
143 }); err != nil {
123 144 w.serverError(rw, r, "cannot save access rules: "+err.Error())
124 145 return
125 146 }
@@ -145,7 +166,7 @@ func (w *Web) AddRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
145 166 w.notFound(rw, r)
146 167 return
147 168 }
148 m, acl, pr := ctx.m, ctx.acl, ctx.pr
169 m, pr := ctx.m, ctx.pr
149 170 if !w.readForm(rw, r, maxSmallForm) {
150 171 return
151 172 }
@@ -168,8 +189,12 @@ func (w *Web) AddRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
168 189 w.accessFailure(rw, r, ctx, "Choose a role to grant.")
169 190 return
170 191 }
171 acl.Entries[acct.Name] = role.String()
172 if err := m.SetACL(acl, pr.account.Name); err != nil {
192 // One entry, applied to the rules as they are now: a page loaded before another
193 // admin's change must not write that change away.
194 if _, err := m.UpdateACL(pr.account.Name, func(cur *access.ACL) error {
195 cur.Entries[acct.Name] = role.String()
196 return nil
197 }); err != nil {
173 198 w.serverError(rw, r, "cannot save access rules: "+err.Error())
174 199 return
175 200 }