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.

Phase 4: access rules live in the instance database

ec5c519cb9fc4277baa8263cfd31891a95f1d7ac · Keenan Nemetz · 17d ago · parent 90fdaf39

Phase 4: access rules live in the instance database

The ACL was one acl.json on refs/vole/acl: a ref only the server writes, read on
every push and every request. It is configuration the instance owns, keyed by
account id, and keeping it in git cost a commit, a compare-and-swap retry and a
rev-parse per read to store a handful of rows — the argument that already moved
the roster out of data/system.git, applied to the rules that name its accounts.

It is now a row in data/users.db beside the roster, in the same file as the ids
it refers to:

- access.Store: repo_acl(repo_id, acl, updated, updated_by), one row per
  repository, keyed by the stable repository id so a rename moves nothing.
- Meta.ACL reads the row; a repository with no row is deny-everything, exactly
  what a missing ref meant.
- Meta.SetACL is one upsert, so SQLite orders concurrent edits and the CAS loop
  with its retries is gone. The author stays in the row and in data/audit.log,
  which is what the commit message used to say.
- Deleting a repository forgets its rules, after the directory is gone.
- repo.Store.ImportACLs reads refs/vole/acl once at startup for any repository
  without a row, so an existing instance keeps its rules. It never overwrites a
  row, and a second run imports nothing.
- The index's acl_sha is a digest of the rules rather than the ACL commit id.
- pre-receive reads the rules from VOLE_USERS_DB and makes GIT_DIR absolute
  before deriving the repository from it, since git hands a hook ".".

What the ref bought — rules that travel with a clone — is what this gives up,
deliberately: the rules are account ids, so a copy in a clone is unreadable
without this instance's roster, and the backup that holds the accounts now holds
them too.

Measured: internal/repo imports rules from a real refs/vole/acl commit and is
idempotent on a second run; internal/access covers round-trip, replacement,
deletion and the audit line; the full suite — auth, site visibility, transport
and pushes — runs against rules read from SQLite.

internal/access/access.go +123

@@ -0,0 +1,123 @@
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 id — 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 "fmt"
17 "os"
18 "time"
19
20 "github.com/rodent-software/vole/internal/acl"
21
22 _ "modernc.org/sqlite"
23 )
24
25 type Store struct {
26 db *sql.DB
27 // AuditPath receives one line per change. Empty means no audit sink, which is only
28 // acceptable for tests: an access change is the one administrative act that must
29 // leave a trace, since the old copy was a commit message.
30 AuditPath string
31 }
32
33 // Open opens (and creates) the access table in the instance database. It is the same
34 // file as the roster: access rules are keyed by account id, so the two have to be
35 // backed up and restored together or the ids stop resolving.
36 func Open(path string) (*Store, error) {
37 dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
38 db, err := sql.Open("sqlite", dsn)
39 if err != nil {
40 return nil, err
41 }
42 db.SetMaxOpenConns(2)
43 if _, err := db.Exec(`
44 CREATE TABLE IF NOT EXISTS repo_acl (
45 repo_id TEXT PRIMARY KEY,
46 acl TEXT NOT NULL,
47 updated INTEGER NOT NULL,
48 updated_by TEXT NOT NULL DEFAULT ''
49 );`); err != nil {
50 db.Close()
51 return nil, err
52 }
53 return &Store{db: db}, nil
54 }
55
56 func (s *Store) Close() error { return s.db.Close() }
57
58 // Get returns a repository's rules, or nil when it has none — which callers must treat
59 // as deny-everything, the same as a repository that never had an ACL.
60 func (s *Store) Get(repoID string) (*acl.ACL, error) {
61 var raw string
62 err := s.db.QueryRow(`SELECT acl FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&raw)
63 if err == sql.ErrNoRows {
64 return nil, nil
65 }
66 if err != nil {
67 return nil, err
68 }
69 a, err := acl.Parse([]byte(raw))
70 if err != nil {
71 return nil, fmt.Errorf("access: %s: %w", repoID, err)
72 }
73 return a, nil
74 }
75
76 // Set replaces a repository's rules. The row keeps who changed them and when, because
77 // the git copy used to keep that in the commit and losing it would make an access
78 // change the one thing nobody can look up afterwards.
79 func (s *Store) Set(repoID string, a *acl.ACL, actor string, now time.Time) error {
80 raw, err := a.Bytes()
81 if err != nil {
82 return err
83 }
84 if _, err := s.db.Exec(`INSERT INTO repo_acl (repo_id, acl, updated, updated_by) VALUES (?,?,?,?)
85 ON CONFLICT(repo_id) DO UPDATE SET acl = excluded.acl,
86 updated = excluded.updated, updated_by = excluded.updated_by`,
87 repoID, string(raw), now.UTC().Unix(), actor); err != nil {
88 return err
89 }
90 s.audit(fmt.Sprintf("acl %s updated by %s", repoID, actorOr(actor)))
91 return nil
92 }
93
94 // Delete forgets a repository's rules, which is what deleting the repository does.
95 func (s *Store) Delete(repoID string) error {
96 var actor string
97 _ = s.db.QueryRow(`SELECT updated_by FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&actor)
98 if _, err := s.db.Exec(`DELETE FROM repo_acl WHERE repo_id = ?`, repoID); err != nil {
99 return err
100 }
101 s.audit(fmt.Sprintf("acl %s deleted with the repository (last changed by %s)", repoID, actorOr(actor)))
102 return nil
103 }
104
105 // audit is best-effort: failing to write the log must not change policy.
106 func (s *Store) audit(line string) {
107 if s.AuditPath == "" {
108 return
109 }
110 f, err := os.OpenFile(s.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
111 if err != nil {
112 return
113 }
114 defer f.Close()
115 fmt.Fprintf(f, "%s\t%s\n", time.Now().UTC().Format(time.RFC3339), line)
116 }
117
118 func actorOr(actor string) string {
119 if actor == "" {
120 return "unknown"
121 }
122 return actor
123 }