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, ¬Null, &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 | |