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.

Blame · forge/access/store.go

Line attribution on main · 256 line(s)

ec5c519c
Keenan Nemetz
16d ago
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,
62576fa8
Keenan Nemetz
14d ago
5 // and they are keyed by account name — so a copy in a clone means nothing without the
ec5c519c
Keenan Nemetz
16d ago
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"
62b59a76
Keenan Nemetz
12d ago
16 "errors"
ec5c519c
Keenan Nemetz
16d ago
17 "fmt"
18 "time"
b1499c72
Keenan Nemetz
13d ago
19
95425af7
Keenan Nemetz
10d ago
20 _ "github.com/mattn/go-sqlite3"
b1499c72
Keenan Nemetz
13d ago
21
d37d301f
Keenan Nemetz
17h ago
22 "rodent.software/vole/internal/audit"
ec5c519c
Keenan Nemetz
16d ago
23 )
24
25 type Store struct {
26 db *sql.DB
b1499c72
Keenan Nemetz
13d ago
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.
ec5c519c
Keenan Nemetz
16d ago
30 AuditPath string
5b814e99
Keenan Nemetz
14d ago
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
62b59a76
Keenan Nemetz
12d ago
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
ec5c519c
Keenan Nemetz
16d ago
40 }
41
62b59a76
Keenan Nemetz
12d ago
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
ec5c519c
Keenan Nemetz
16d ago
49 // Open opens (and creates) the access table in the instance database. It is the same
62576fa8
Keenan Nemetz
14d ago
50 // file as the roster: access rules are keyed by account name, so the two have to be
ec5c519c
Keenan Nemetz
16d ago
51 // backed up and restored together or the ids stop resolving.
52 func Open(path string) (*Store, error) {
95425af7
Keenan Nemetz
10d ago
53 dsn := path + "?_busy_timeout=5000&_journal_mode=WAL"
54 db, err := sql.Open("sqlite3", dsn)
ec5c519c
Keenan Nemetz
16d ago
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,
62b59a76
Keenan Nemetz
12d ago
64 updated_by TEXT NOT NULL DEFAULT '',
65 revision INTEGER NOT NULL DEFAULT 0
ec5c519c
Keenan Nemetz
16d ago
66 );`); err != nil {
67 db.Close()
68 return nil, err
69 }
62b59a76
Keenan Nemetz
12d ago
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 }
ec5c519c
Keenan Nemetz
16d ago
77 return &Store{db: db}, nil
78 }
62b59a76
Keenan Nemetz
12d ago
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 }
ec5c519c
Keenan Nemetz
16d ago
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.
c5902451
Keenan Nemetz
16d ago
116 func (s *Store) Get(repoID string) (*ACL, error) {
62b59a76
Keenan Nemetz
12d ago
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)
ec5c519c
Keenan Nemetz
16d ago
122 if err == sql.ErrNoRows {
123 return nil, nil
124 }
125 if err != nil {
126 return nil, err
127 }
c5902451
Keenan Nemetz
16d ago
128 a, err := Parse([]byte(raw))
ec5c519c
Keenan Nemetz
16d ago
129 if err != nil {
130 return nil, fmt.Errorf("access: %s: %w", repoID, err)
131 }
62b59a76
Keenan Nemetz
12d ago
132 a.revision = rev
5b814e99
Keenan Nemetz
14d ago
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
62b59a76
Keenan Nemetz
12d ago
142 a.active = s.Active
5b814e99
Keenan Nemetz
14d ago
143 }
144 return a
ec5c519c
Keenan Nemetz
16d ago
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.
62b59a76
Keenan Nemetz
12d ago
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.
c5902451
Keenan Nemetz
16d ago
157 func (s *Store) Set(repoID string, a *ACL, actor string, now time.Time) error {
ec5c519c
Keenan Nemetz
16d ago
158 raw, err := a.Bytes()
159 if err != nil {
160 return err
161 }
b1499c72
Keenan Nemetz
13d ago
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 }
62b59a76
Keenan Nemetz
12d ago
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 {
ec5c519c
Keenan Nemetz
16d ago
184 return err
185 }
62b59a76
Keenan Nemetz
12d ago
186 if n, _ := res.RowsAffected(); n == 0 {
187 return ErrConflict
188 }
189 a.revision++
ec5c519c
Keenan Nemetz
16d ago
190 return nil
191 }
192
62b59a76
Keenan Nemetz
12d ago
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
b1499c72
Keenan Nemetz
13d ago
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
ec5c519c
Keenan Nemetz
16d ago
241 }
b1499c72
Keenan Nemetz
13d ago
242 return s.audit(audit.Record{Action: "acl.delete", Actor: actor, Subject: repoID, Detail: detail})
ec5c519c
Keenan Nemetz
16d ago
243 }
244
b1499c72
Keenan Nemetz
13d ago
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
ec5c519c
Keenan Nemetz
16d ago
250 }
b1499c72
Keenan Nemetz
13d ago
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)
ec5c519c
Keenan Nemetz
16d ago
256 }