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/store.go +126−7

@@ -13,6 +13,7 @@ package access
13 13
14 14 import (
15 15 "database/sql"
16 "errors"
16 17 "fmt"
17 18 "time"
18 19
@@ -31,8 +32,20 @@ type Store struct {
31 32 // roster owns it; the store attaches it to every ACL it returns so RoleFor
32 33 // can apply the global grant without every caller having to pass it in.
33 34 InstanceRole func(string) Role
35 // Active, when set, answers whether an account is still active. The store
36 // attaches it to every ACL so an entry or a runners-list membership does not
37 // outlive retirement: the row stays after the account is retired, and a
38 // permission nobody revoked is worse than one that is merely forgotten.
39 Active func(string) bool
34 40 }
35 41
42 // ErrConflict means the rules changed between the read and the write. It is not an
43 // error to retry blindly: the caller's rule set is stale, and reapplying it would
44 // undo whatever the other writer did. Anything that owns one field should use
45 // Update, which reads again; anything replacing the whole document should ask the
46 // person to reload.
47 var ErrConflict = errors.New("access: the rules changed since they were read; reload and try again")
48
36 49 // Open opens (and creates) the access table in the instance database. It is the same
37 50 // file as the roster: access rules are keyed by account name, so the two have to be
38 51 // backed up and restored together or the ids stop resolving.
@@ -48,20 +61,64 @@ CREATE TABLE IF NOT EXISTS repo_acl (
48 61 repo_id TEXT PRIMARY KEY,
49 62 acl TEXT NOT NULL,
50 63 updated INTEGER NOT NULL,
51 updated_by TEXT NOT NULL DEFAULT ''
64 updated_by TEXT NOT NULL DEFAULT '',
65 revision INTEGER NOT NULL DEFAULT 0
52 66 );`); err != nil {
53 67 db.Close()
54 68 return nil, err
55 69 }
70 // Instances created before optimistic concurrency have the table without the
71 // column. Adding it is what makes their next write a compare-and-swap instead
72 // of a blind replace, so it is migration rather than a schema anyone chooses.
73 if err := addRevisionColumn(db); err != nil {
74 db.Close()
75 return nil, err
76 }
56 77 return &Store{db: db}, nil
57 78 }
79
80 // addRevisionColumn adds repo_acl.revision to a table that predates it. SQLite has
81 // no ADD COLUMN IF NOT EXISTS, so the column list is read first.
82 func addRevisionColumn(db *sql.DB) error {
83 rows, err := db.Query(`PRAGMA table_info(repo_acl)`)
84 if err != nil {
85 return err
86 }
87 defer rows.Close()
88 for rows.Next() {
89 var (
90 cid int
91 name, typ string
92 notNull int
93 defaultVal sql.NullString
94 pk int
95 )
96 if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultVal, &pk); err != nil {
97 return err
98 }
99 if name == "revision" {
100 return nil
101 }
102 }
103 if err := rows.Err(); err != nil {
104 return err
105 }
106 if err := rows.Close(); err != nil {
107 return err
108 }
109 _, err = db.Exec(`ALTER TABLE repo_acl ADD COLUMN revision INTEGER NOT NULL DEFAULT 0`)
110 return err
111 }
58 112 func (s *Store) Close() error { return s.db.Close() }
59 113
60 114 // Get returns a repository's rules, or nil when it has none — which callers must treat
61 115 // as deny-everything, the same as a repository that never had an ACL.
62 116 func (s *Store) Get(repoID string) (*ACL, error) {
63 var raw string
64 err := s.db.QueryRow(`SELECT acl FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&raw)
117 var (
118 raw string
119 rev int64
120 )
121 err := s.db.QueryRow(`SELECT acl, revision FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&raw, &rev)
65 122 if err == sql.ErrNoRows {
66 123 return nil, nil
67 124 }
@@ -72,6 +129,7 @@ func (s *Store) Get(repoID string) (*ACL, error) {
72 129 if err != nil {
73 130 return nil, fmt.Errorf("access: %s: %w", repoID, err)
74 131 }
132 a.revision = rev
75 133 return s.Attach(a), nil
76 134 }
77 135
@@ -81,6 +139,7 @@ func (s *Store) Get(repoID string) (*ACL, error) {
81 139 func (s *Store) Attach(a *ACL) *ACL {
82 140 if a != nil && s != nil {
83 141 a.instanceRole = s.InstanceRole
142 a.active = s.Active
84 143 }
85 144 return a
86 145 }
@@ -88,6 +147,13 @@ func (s *Store) Attach(a *ACL) *ACL {
88 147 // Set replaces a repository's rules. The row keeps who changed them and when, because
89 148 // the git copy used to keep that in the commit and losing it would make an access
90 149 // change the one thing nobody can look up afterwards.
150 //
151 // A rule set read through Get carries the revision it was read at, and the write is
152 // refused with ErrConflict if the row has moved since: otherwise two people editing
153 // one page would silently overwrite each other, and a repository admin's stale save
154 // could restore a role an instance admin had just revoked. A rule set built from
155 // scratch (revision zero) is a create: it inserts, and refuses if a row appeared
156 // in the meantime rather than replacing it.
91 157 func (s *Store) Set(repoID string, a *ACL, actor string, now time.Time) error {
92 158 raw, err := a.Bytes()
93 159 if err != nil {
@@ -98,15 +164,68 @@ func (s *Store) Set(repoID string, a *ACL, actor string, now time.Time) error {
98 164 if err := s.audit(audit.Record{Action: "acl.update", Actor: actor, Subject: repoID}); err != nil {
99 165 return err
100 166 }
101 if _, err := s.db.Exec(`INSERT INTO repo_acl (repo_id, acl, updated, updated_by) VALUES (?,?,?,?)
102 ON CONFLICT(repo_id) DO UPDATE SET acl = excluded.acl,
103 updated = excluded.updated, updated_by = excluded.updated_by`,
104 repoID, string(raw), now.UTC().Unix(), actor); err != nil {
167 if a.revision == 0 {
168 res, err := s.db.Exec(`INSERT INTO repo_acl (repo_id, acl, updated, updated_by, revision)
169 VALUES (?,?,?,?,1) ON CONFLICT(repo_id) DO NOTHING`,
170 repoID, string(raw), now.UTC().Unix(), actor)
171 if err != nil {
172 return err
173 }
174 if n, _ := res.RowsAffected(); n == 0 {
175 return ErrConflict
176 }
177 a.revision = 1
178 return nil
179 }
180 res, err := s.db.Exec(`UPDATE repo_acl SET acl = ?, updated = ?, updated_by = ?, revision = revision + 1
181 WHERE repo_id = ? AND revision = ?`,
182 string(raw), now.UTC().Unix(), actor, repoID, a.revision)
183 if err != nil {
105 184 return err
106 185 }
186 if n, _ := res.RowsAffected(); n == 0 {
187 return ErrConflict
188 }
189 a.revision++
107 190 return nil
108 191 }
109 192
193 // Update applies one change to a repository's current rules and returns the result. It
194 // reads the row, applies fn, and writes it back under the compare-and-swap; a write
195 // that lost a race is retried against the row as it now is, so a change that owns one
196 // field cannot undo another writer's change to a different field.
197 //
198 // fn must therefore be a decision about the rule set it is handed, not a replay of one
199 // it read earlier: "make this account a maintainer", not "write back this map".
200 func (s *Store) Update(repoID, actor string, fn func(*ACL) error) (*ACL, error) {
201 const attempts = 8
202 var last error
203 for i := 0; i < attempts; i++ {
204 cur, err := s.Get(repoID)
205 if err != nil {
206 return nil, err
207 }
208 if cur == nil {
209 cur = s.Attach(Empty(""))
210 }
211 if err := fn(cur); err != nil {
212 return nil, err
213 }
214 if err := s.Set(repoID, cur, actor, time.Now().UTC()); err != nil {
215 if errors.Is(err, ErrConflict) {
216 last = err
217 continue
218 }
219 return nil, err
220 }
221 return cur, nil
222 }
223 if last == nil {
224 last = ErrConflict
225 }
226 return nil, last
227 }
228
110 229 // RecordDelete writes the audit line for a repository deletion and reports
111 230 // whether it was written. Deleting a repository is a two-step operation on
112 231 // purpose: the line is recorded, the content is removed, and only then are the