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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | // Package access stores each repository's access rules. |
| 2 | // |
| 3 | // The rules live in the instance database beside the roster, not in the repository. |
| 4 | // They are configuration: only the instance writes them, only the instance reads them, |
| 5 | // and they are keyed by account name — so a copy in a clone means nothing without the |
| 6 | // roster that gives those ids names. Keeping them in git bought a commit, a |
| 7 | // compare-and-swap retry and a rev-parse per read to store a handful of rows, and that |
| 8 | // is the same argument that moved the roster out of data/system.git. |
| 9 | // |
| 10 | // What the repository used to carry is imported once (repo.Store.ImportACLs), so an |
| 11 | // instance that has refs/vole/acl keeps its rules when it upgrades. |
| 12 | package access |
| 13 | |
| 14 | import ( |
| 15 | "database/sql" |
| 16 | "errors" |
| 17 | "fmt" |
| 18 | "time" |
| 19 | |
| 20 | _ "github.com/mattn/go-sqlite3" |
| 21 | |
| 22 | "rodent.software/vole/internal/audit" |
| 23 | ) |
| 24 | |
| 25 | type Store struct { |
| 26 | db *sql.DB |
| 27 | // AuditPath receives one line per change. It is required in practice: an |
| 28 | // access change is the one administrative act that must leave a trace, so a |
| 29 | // change that cannot be recorded is refused rather than applied silently. |
| 30 | AuditPath string |
| 31 | // InstanceRole, when set, resolves an account's instance-wide role. The |
| 32 | // roster owns it; the store attaches it to every ACL it returns so RoleFor |
| 33 | // can apply the global grant without every caller having to pass it in. |
| 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 |
| 40 | } |
| 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 | |
| 49 | // Open opens (and creates) the access table in the instance database. It is the same |
| 50 | // file as the roster: access rules are keyed by account name, so the two have to be |
| 51 | // backed up and restored together or the ids stop resolving. |
| 52 | func Open(path string) (*Store, error) { |
| 53 | dsn := path + "?_busy_timeout=5000&_journal_mode=WAL" |
| 54 | db, err := sql.Open("sqlite3", dsn) |
| 55 | if err != nil { |
| 56 | return nil, err |
| 57 | } |
| 58 | db.SetMaxOpenConns(2) |
| 59 | if _, err := db.Exec(` |
| 60 | CREATE TABLE IF NOT EXISTS repo_acl ( |
| 61 | repo_id TEXT PRIMARY KEY, |
| 62 | acl TEXT NOT NULL, |
| 63 | updated INTEGER NOT NULL, |
| 64 | updated_by TEXT NOT NULL DEFAULT '', |
| 65 | revision INTEGER NOT NULL DEFAULT 0 |
| 66 | );`); err != nil { |
| 67 | db.Close() |
| 68 | return nil, err |
| 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 | } |
| 77 | return &Store{db: db}, nil |
| 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 | } |
| 112 | func (s *Store) Close() error { return s.db.Close() } |
| 113 | |
| 114 | // Get returns a repository's rules, or nil when it has none — which callers must treat |
| 115 | // as deny-everything, the same as a repository that never had an ACL. |
| 116 | func (s *Store) Get(repoID string) (*ACL, error) { |
| 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) |
| 122 | if err == sql.ErrNoRows { |
| 123 | return nil, nil |
| 124 | } |
| 125 | if err != nil { |
| 126 | return nil, err |
| 127 | } |
| 128 | a, err := Parse([]byte(raw)) |
| 129 | if err != nil { |
| 130 | return nil, fmt.Errorf("access: %s: %w", repoID, err) |
| 131 | } |
| 132 | a.revision = rev |
| 133 | return s.Attach(a), nil |
| 134 | } |
| 135 | |
| 136 | // Attach wires the roster's instance-role lookup into an ACL. It is exported for |
| 137 | // the one path that has to build an empty rule set itself: a repository with no |
| 138 | // row still needs the instance-wide rules to apply. |
| 139 | func (s *Store) Attach(a *ACL) *ACL { |
| 140 | if a != nil && s != nil { |
| 141 | a.instanceRole = s.InstanceRole |
| 142 | a.active = s.Active |
| 143 | } |
| 144 | return a |
| 145 | } |
| 146 | |
| 147 | // Set replaces a repository's rules. The row keeps who changed them and when, because |
| 148 | // the git copy used to keep that in the commit and losing it would make an access |
| 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. |
| 157 | func (s *Store) Set(repoID string, a *ACL, actor string, now time.Time) error { |
| 158 | raw, err := a.Bytes() |
| 159 | if err != nil { |
| 160 | return err |
| 161 | } |
| 162 | // Recorded before the write, so a rule set that cannot be audited is not |
| 163 | // applied. A failed insert leaves an extra line, which is the safe direction. |
| 164 | if err := s.audit(audit.Record{Action: "acl.update", Actor: actor, Subject: repoID}); err != nil { |
| 165 | return err |
| 166 | } |
| 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 { |
| 184 | return err |
| 185 | } |
| 186 | if n, _ := res.RowsAffected(); n == 0 { |
| 187 | return ErrConflict |
| 188 | } |
| 189 | a.revision++ |
| 190 | return nil |
| 191 | } |
| 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 | |
| 229 | // RecordDelete writes the audit line for a repository deletion and reports |
| 230 | // whether it was written. Deleting a repository is a two-step operation on |
| 231 | // purpose: the line is recorded, the content is removed, and only then are the |
| 232 | // rules forgotten. A deletion that cannot be recorded therefore never starts, |
| 233 | // and a removal that fails leaves the rules in place rather than a repository |
| 234 | // nobody can administer. |
| 235 | func (s *Store) RecordDelete(repoID, actor string) error { |
| 236 | var lastChangedBy string |
| 237 | _ = s.db.QueryRow(`SELECT updated_by FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&lastChangedBy) |
| 238 | detail := "repository deleted" |
| 239 | if lastChangedBy != "" { |
| 240 | detail += "; last changed by " + lastChangedBy |
| 241 | } |
| 242 | return s.audit(audit.Record{Action: "acl.delete", Actor: actor, Subject: repoID, Detail: detail}) |
| 243 | } |
| 244 | |
| 245 | // Delete forgets a repository's rules, which is what deleting the repository |
| 246 | // does. RecordDelete has already recorded the change. |
| 247 | func (s *Store) Delete(repoID string) error { |
| 248 | _, err := s.db.Exec(`DELETE FROM repo_acl WHERE repo_id = ?`, repoID) |
| 249 | return err |
| 250 | } |
| 251 | |
| 252 | // audit appends one access change. The error is returned to the caller: a rule set |
| 253 | // that cannot be audited is not changed. |
| 254 | func (s *Store) audit(r audit.Record) error { |
| 255 | return audit.Append(s.AuditPath, r) |
| 256 | } |
| 257 |