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.

The roster is a database, not a repository

427c47611f7db09e52618a0f2d05291a8641b2d0 · Keenan Nemetz · 17d ago · parent 0f442b1c

The roster is a database, not a repository

data/system.git is gone. Accounts, every name they have held and their SSH keys
live in data/users.db, next to the other authoritative store (runs.db) and the
disposable index.

  users       id, name, status, created
  user_names  every name ever held -- primary key, so a name is issued once
  user_keys   fingerprint, type, line -- primary key, so a key has one owner

Two invariants that used to be parse-time checks are now table keys, and a roster
change is one transaction instead of a commit, a CAS retry and a tree walk. The
snapshot, the five-second reload and the audit line per change are unchanged, so
`vole user add-key` still applies without a restart and revocation is still
immediate.

Clients no longer clone the roster, because there is nothing to clone. The one
view a client needs -- active id and name -- is served by a `vole-roster` SSH
verb over the key it already pushes with, so name resolution gained no endpoint
and no second credential. `vole request new --author keenan` now resolves through
that verb (TestRequestCLIResolvesNamesOverSSH: the request landed with
author_id = the account id, and an unknown name fails before pushing).

A WAL database cannot be backed up with cp, so `vole user backup <path>` takes a
consistent copy with VACUUM INTO (TestRosterIsOneDatabaseFile opens the copy and
resolves a renamed account and its key from it). This replaces the
"identity survives without a side database" criterion, which is withdrawn rather
than left looking unmet: the roster is now exactly one file, and the backup is
what proves it is not lost with the index.

Verified through the binary: init -> create -> add-key -> rename -> list ->
backup, the restored copy listing the same account, former name and key; then
scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).

internal/identity/identity.go +307−202

@@ -1,8 +1,13 @@
1 // Package identity stores the user roster in git.
1 // Package identity stores the user roster in SQLite.
2 2 //
3 // data/system.git
4 // refs/heads/main
5 // users/<ulid>/meta.json
3 // It is the one authoritative database next to the derived index (index.db) and
4 // the CI queue (runs.db). A roster is small and written by a handful of operator
5 // commands, so the whole thing is a handful of rows:
6 //
7 // users one row per account: stable id, current name, status, created
8 // user_names every name an account has ever held; the primary key is what
9 // stops a name being handed out twice
10 // user_keys one row per authorized key, unique by fingerprint
6 11 //
7 12 // An account has a stable id and a mutable name. The id is what ACL entries and
8 13 // approvals refer to, so renaming somebody touches no repository state, and a
@@ -13,15 +18,16 @@
13 18 // account id, and every authorization decision downstream uses that id rather
14 19 // than a name somebody chose to type.
15 20 //
16 // Nothing stored here is secret, so the repository can be cloned, copied or put
17 // under configuration management without leaking anything.
21 // Nothing stored here is secret (public keys only), so the database file can be
22 // copied as a backup. Copy it with Backup rather than cp: the database runs in
23 // WAL mode, so the newest committed rows may still be in the -wal file.
18 24 package identity
19 25
20 26 import (
21 "encoding/json"
27 "database/sql"
28 "errors"
22 29 "fmt"
23 30 "os"
24 "os/exec"
25 31 "path/filepath"
26 32 "regexp"
27 33 "sort"
@@ -29,20 +35,12 @@ import (
29 35 "sync/atomic"
30 36 "time"
31 37
32 "github.com/rodent-software/vole/internal/gitx"
38 _ "modernc.org/sqlite"
39
33 40 "github.com/rodent-software/vole/internal/id"
34 41 "golang.org/x/crypto/ssh"
35 42 )
36 43
37 const (
38 // Ref is the branch of the system repository carrying the roster.
39 Ref = "refs/heads/main"
40 // Dir is the roster subtree.
41 Dir = "users"
42
43 metaName = "/meta.json"
44 )
45
46 44 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
47 45
48 46 // ValidateName reports whether s may be an account name.
@@ -71,14 +69,6 @@ type Account struct {
71 69
72 70 func (a *Account) Active() bool { return a.Status == "active" }
73 71
74 type meta struct {
75 Name string `json:"name"`
76 Status string `json:"status"`
77 Created string `json:"created"`
78 Former []string `json:"former_names,omitempty"`
79 Keys []Key `json:"keys"`
80 }
81
82 72 // Snapshot is an immutable view of the roster, swapped atomically so the SSH
83 73 // auth callback stays a map lookup.
84 74 type Snapshot struct {
@@ -106,22 +96,57 @@ func (s *Snapshot) Lookup(fingerprint string) (string, bool) {
106 96 type Store struct {
107 97 // AuditPath, when set, receives one line per roster change.
108 98 AuditPath string
109 repo *gitx.Repo
99 db *sql.DB
100 path string
110 101 snap atomic.Pointer[Snapshot]
111 102 }
112 103
113 // Init creates the system repository if it is missing.
114 func Init(dir string) error {
115 if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil {
116 return nil
104 const schema = `
105 CREATE TABLE IF NOT EXISTS users (
106 id TEXT PRIMARY KEY,
107 name TEXT NOT NULL UNIQUE,
108 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
109 created INTEGER NOT NULL
110 );
111 CREATE TABLE IF NOT EXISTS user_names (
112 name TEXT PRIMARY KEY,
113 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE
114 );
115 CREATE TABLE IF NOT EXISTS user_keys (
116 fingerprint TEXT PRIMARY KEY,
117 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
118 type TEXT NOT NULL,
119 line TEXT NOT NULL,
120 comment TEXT NOT NULL DEFAULT ''
121 );
122 CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_id);
123 `
124
125 func openDB(path string) (*sql.DB, error) {
126 // _txlock=immediate makes every write transaction take the write lock up
127 // front, so a second `vole user` process waits on busy_timeout instead of
128 // failing when it tries to upgrade a read lock mid-transaction.
129 db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate")
130 if err != nil {
131 return nil, err
117 132 }
118 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
133 db.SetMaxOpenConns(4)
134 return db, nil
135 }
136
137 // Init creates the roster database if it is missing. Running it on an existing
138 // roster only re-applies the schema, which is how an upgrade adds a table.
139 func Init(path string) error {
140 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
119 141 return err
120 142 }
121 // -b main so that `git log` in the roster repository shows the roster rather
122 // than an unborn master branch.
123 if out, err := exec.Command("git", "init", "--bare", "-q", "-b", "main", dir).CombinedOutput(); err != nil {
124 return fmt.Errorf("identity: git init --bare %s: %w: %s", dir, err, out)
143 db, err := openDB(path)
144 if err != nil {
145 return fmt.Errorf("identity: %w", err)
146 }
147 defer db.Close()
148 if _, err := db.Exec(schema); err != nil {
149 return fmt.Errorf("identity: create roster: %w", err)
125 150 }
126 151 return nil
127 152 }
@@ -129,39 +154,44 @@ func Init(dir string) error {
129 154 // Open loads the roster. An empty roster is valid: it means nobody can write yet.
130 155 // A roster that does not parse is a hard error, because booting with a partial
131 156 // roster would silently change who can authenticate.
132 func Open(dir string) (*Store, error) {
133 if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil {
134 if _, err := os.Stat(dir); err != nil {
135 return nil, fmt.Errorf("identity: %s is missing; run vole init", dir)
136 }
157 func Open(path string) (*Store, error) {
158 if _, err := os.Stat(path); err != nil {
159 return nil, fmt.Errorf("identity: %s is missing; run vole init", path)
137 160 }
138 s := &Store{repo: gitx.Open(dir)}
161 db, err := openDB(path)
162 if err != nil {
163 return nil, fmt.Errorf("identity: %w", err)
164 }
165 s := &Store{db: db, path: path}
139 166 if err := s.Reload(); err != nil {
167 db.Close()
140 168 return nil, err
141 169 }
142 170 return s, nil
143 171 }
144 172
173 func (s *Store) Close() error { return s.db.Close() }
174
145 175 func (s *Store) Snapshot() *Snapshot { return s.snap.Load() }
146 176
147 // Dir is the system repository's path.
148 func (s *Store) Dir() string { return s.repo.Dir }
177 // Path is the roster database's path.
178 func (s *Store) Path() string { return s.path }
179
180 // Backup writes a consistent copy of the roster to dst. VACUUM INTO takes a read
181 // transaction, so it is safe against a running instance and the copy is one file
182 // with no -wal to lose.
183 func (s *Store) Backup(dst string) error {
184 if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
185 return err
186 }
187 _, err := s.db.Exec(`VACUUM INTO ?`, dst)
188 return err
189 }
149 190
150 191 // Reload re-reads the roster, keeping the previous snapshot if the new one does
151 192 // not parse: a half-finished edit must not lock everybody out.
152 193 func (s *Store) Reload() error {
153 commit, err := s.repo.RevParse(Ref)
154 if err != nil {
155 return err
156 }
157 files := map[string][]byte{}
158 if commit != "" {
159 files, err = s.repo.FlatTree(commit)
160 if err != nil {
161 return err
162 }
163 }
164 snap, err := parse(files)
194 snap, err := s.load()
165 195 if err != nil {
166 196 return err
167 197 }
@@ -169,66 +199,113 @@ func (s *Store) Reload() error {
169 199 return nil
170 200 }
171 201
172 func parse(files map[string][]byte) (*Snapshot, error) {
202 func (s *Store) load() (*Snapshot, error) {
173 203 snap := &Snapshot{
174 204 ByID: map[string]*Account{},
175 205 ByKey: map[string]string{},
176 206 ByName: map[string]*Account{},
177 207 Reserved: map[string]string{},
178 208 }
179 paths := make([]string, 0, len(files))
180 for p := range files {
181 if strings.HasPrefix(p, Dir+"/") && strings.HasSuffix(p, metaName) {
182 paths = append(paths, p)
209 rows, err := s.db.Query(`SELECT id, name, status, created FROM users ORDER BY id`)
210 if err != nil {
211 return nil, fmt.Errorf("identity: read roster: %w", err)
212 }
213 defer rows.Close()
214 for rows.Next() {
215 var a Account
216 var status string
217 var created int64
218 if err := rows.Scan(&a.ID, &a.Name, &status, &created); err != nil {
219 return nil, fmt.Errorf("identity: read roster: %w", err)
220 }
221 if !id.Valid(a.ID) {
222 return nil, fmt.Errorf("identity: %q is not a valid account id", a.ID)
223 }
224 if err := ValidateName(a.Name); err != nil {
225 return nil, fmt.Errorf("identity: account %s: %w", a.ID, err)
226 }
227 switch status {
228 case "", "active":
229 status = "active"
230 case "retired":
231 default:
232 return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.ID)
183 233 }
234 a.Status = status
235 a.Created = time.Unix(created, 0).UTC()
236 snap.ByID[a.ID] = &a
237 }
238 if err := rows.Err(); err != nil {
239 return nil, err
184 240 }
185 sort.Strings(paths)
186 for _, path := range paths {
187 accountID := strings.TrimSuffix(strings.TrimPrefix(path, Dir+"/"), metaName)
188 if strings.Contains(accountID, "/") || !id.Valid(accountID) {
189 return nil, fmt.Errorf("identity: %s: %q is not a valid account id", path, accountID)
241
242 // Every name ever held, so both the current and former names stay reserved.
243 names, err := s.db.Query(`SELECT name, user_id FROM user_names`)
244 if err != nil {
245 return nil, fmt.Errorf("identity: read roster: %w", err)
246 }
247 defer names.Close()
248 for names.Next() {
249 var name, owner string
250 if err := names.Scan(&name, &owner); err != nil {
251 return nil, fmt.Errorf("identity: read roster: %w", err)
190 252 }
191 var m meta
192 dec := json.NewDecoder(strings.NewReader(string(files[path])))
193 dec.DisallowUnknownFields()
194 if err := dec.Decode(&m); err != nil {
195 return nil, fmt.Errorf("identity: %s: %w", path, err)
253 a, ok := snap.ByID[owner]
254 if !ok {
255 return nil, fmt.Errorf("identity: the name %q points at unknown account %s", name, owner)
196 256 }
197 if err := ValidateName(m.Name); err != nil {
198 return nil, fmt.Errorf("identity: %s: %w", path, err)
257 if other, taken := snap.Reserved[name]; taken && other != owner {
258 return nil, fmt.Errorf("identity: the name %q is claimed by both %s and %s", name, other, owner)
199 259 }
200 switch m.Status {
201 case "":
202 m.Status = "active"
203 case "active", "retired":
204 default:
205 return nil, fmt.Errorf("identity: %s: status must be active or retired", path)
260 snap.Reserved[name] = owner
261 if name != a.Name {
262 a.Former = append(a.Former, name)
206 263 }
207 created, err := time.Parse(time.RFC3339, m.Created)
208 if err != nil {
209 return nil, fmt.Errorf("identity: %s: created must be RFC3339: %w", path, err)
264 }
265 if err := names.Err(); err != nil {
266 return nil, err
267 }
268
269 keys, err := s.db.Query(`SELECT fingerprint, user_id, type, line, comment FROM user_keys ORDER BY fingerprint`)
270 if err != nil {
271 return nil, fmt.Errorf("identity: read roster: %w", err)
272 }
273 defer keys.Close()
274 for keys.Next() {
275 var k Key
276 var owner string
277 if err := keys.Scan(&k.Fingerprint, &owner, &k.Type, &k.Line, &k.Comment); err != nil {
278 return nil, fmt.Errorf("identity: read roster: %w", err)
279 }
280 a, ok := snap.ByID[owner]
281 if !ok {
282 return nil, fmt.Errorf("identity: key %s belongs to unknown account %s", k.Fingerprint, owner)
283 }
284 if other, taken := snap.ByKey[k.Fingerprint]; taken && other != owner {
285 return nil, fmt.Errorf("identity: key %s appears under both %s and %s", k.Fingerprint, other, owner)
210 286 }
211 a := &Account{ID: accountID, Name: m.Name, Status: m.Status, Former: m.Former, Keys: m.Keys, Created: created.UTC()}
287 a.Keys = append(a.Keys, k)
212 288 if a.Active() {
213 snap.ByName[a.Name] = a
214 snap.Count++
289 snap.ByKey[k.Fingerprint] = owner
215 290 }
216 for _, n := range append([]string{a.Name}, a.Former...) {
217 if other, taken := snap.Reserved[n]; taken && other != a.ID {
218 return nil, fmt.Errorf("identity: the name %q is claimed by both %s and %s", n, other, a.ID)
291 }
292 if err := keys.Err(); err != nil {
293 return nil, err
294 }
295
296 for _, a := range snap.ByID {
297 sort.Strings(a.Former)
298 sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint })
299 if a.Active() {
300 if other, ok := snap.ByName[a.Name]; ok {
301 return nil, fmt.Errorf("identity: two active accounts are called %q (%s and %s)", a.Name, other.ID, a.ID)
219 302 }
220 snap.Reserved[n] = a.ID
303 snap.ByName[a.Name] = a
304 snap.Count++
221 305 }
222 for _, k := range a.Keys {
223 if other, ok := snap.ByKey[k.Fingerprint]; ok && other != a.ID {
224 return nil, fmt.Errorf("identity: key %s appears under both %s and %s", k.Fingerprint, other, a.ID)
225 }
226 if a.Active() {
227 snap.ByKey[k.Fingerprint] = a.ID
228 }
306 if _, ok := snap.Reserved[a.Name]; !ok {
307 return nil, fmt.Errorf("identity: account %s is called %q but reserves nothing", a.ID, a.Name)
229 308 }
230 sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint })
231 snap.ByID[a.ID] = a
232 309 }
233 310 return snap, nil
234 311 }
@@ -293,23 +370,42 @@ func (s *Store) All() []*Account {
293 370 return out
294 371 }
295 372
373 // Roster returns the active accounts as id/name pairs, sorted by name. The SSH
374 // server serves this to authenticated clients so `vole request` can turn a name
375 // into the account id that ACLs and approvals store.
376 func (s *Store) Roster() []Account {
377 var out []Account
378 for _, a := range s.All() {
379 if a.Active() {
380 out = append(out, Account{ID: a.ID, Name: a.Name})
381 }
382 }
383 return out
384 }
385
296 386 // ---------- mutations ----------
297 387 //
298 // Every change is one commit on the system repository, so `git log users'`
299 // answers who changed what and when without a separate audit trail.
388 // Each change is one transaction, and SQLite enforces the two invariants that
389 // matter: a name is issued once and a key belongs to one account. The audit log
390 // is the only history — there is no commit trail behind the rows any more.
300 391
301 func (s *Store) update(message, actor string, mutate func(files map[string][]byte, snap *Snapshot) error) error {
302 _, err := gitx.CommitFiles(s.repo, Ref, func(files map[string][]byte) error {
303 snap, err := parse(files)
304 if err != nil {
305 return err
306 }
307 return mutate(files, snap)
308 }, message, actor)
392 func (s *Store) mutate(action, account, detail string, fn func(tx *sql.Tx) error) error {
393 tx, err := s.db.Begin()
309 394 if err != nil {
395 return fmt.Errorf("identity: %w", err)
396 }
397 defer tx.Rollback()
398 if err := fn(tx); err != nil {
310 399 return err
311 400 }
312 return s.Reload()
401 if err := tx.Commit(); err != nil {
402 return fmt.Errorf("identity: %w", err)
403 }
404 if err := s.Reload(); err != nil {
405 return err
406 }
407 s.audit(action, account, detail)
408 return nil
313 409 }
314 410
315 411 // Create adds an account with a fresh id and no keys.
@@ -317,30 +413,30 @@ func (s *Store) Create(name, actor string) (*Account, error) {
317 413 if err := ValidateName(name); err != nil {
318 414 return nil, err
319 415 }
320 var created *Account
321 err := s.update("user: create "+name, actor, func(files map[string][]byte, snap *Snapshot) error {
322 if owner, taken := snap.Reserved[name]; taken {
416 now := time.Now().UTC()
417 accountID, err := id.New(now)
418 if err != nil {
419 return nil, err
420 }
421 err = s.mutate("identity.create", accountID, name, func(tx *sql.Tx) error {
422 if owner, err := reservedBy(tx, name); err != nil {
423 return err
424 } else if owner != "" {
323 425 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a retired or former name is never handed out twice", name, owner)
324 426 }
325 now := time.Now().UTC()
326 accountID, err := id.New(now)
327 if err != nil {
328 return err
427 if _, err := tx.Exec(`INSERT INTO users(id, name, status, created) VALUES(?,?, 'active', ?)`,
428 accountID, name, now.Unix()); err != nil {
429 return fmt.Errorf("identity: create %s: %w", name, err)
329 430 }
330 a := &Account{ID: accountID, Name: name, Status: "active", Created: now}
331 raw, err := marshal(a)
332 if err != nil {
333 return err
431 if _, err := tx.Exec(`INSERT INTO user_names(name, user_id) VALUES(?,?)`, name, accountID); err != nil {
432 return fmt.Errorf("identity: create %s: %w", name, err)
334 433 }
335 files[Dir+"/"+accountID+metaName] = raw
336 created = a
337 434 return nil
338 435 })
339 436 if err != nil {
340 437 return nil, err
341 438 }
342 s.audit("identity.create", created.ID, created.Name)
343 return created, nil
439 return s.Find(accountID)
344 440 }
345 441
346 442 // Rename changes the name on an account. The old name stays reserved forever, so
@@ -349,58 +445,57 @@ func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) {
349 445 if err := ValidateName(newName); err != nil {
350 446 return nil, err
351 447 }
352 var renamed *Account
353 err := s.update("user: rename "+idOrName+" to "+newName, actor, func(files map[string][]byte, snap *Snapshot) error {
354 a, err := find(snap, idOrName)
448 var accountID string
449 err := s.mutate("identity.rename", idOrName, newName, func(tx *sql.Tx) error {
450 a, err := findTx(tx, idOrName)
355 451 if err != nil {
356 452 return err
357 453 }
358 454 if a.Name == newName {
359 455 return fmt.Errorf("identity: %s is already called %q", a.ID, newName)
360 456 }
361 if owner, taken := snap.Reserved[newName]; taken && owner != a.ID {
457 if owner, err := reservedBy(tx, newName); err != nil {
458 return err
459 } else if owner != "" && owner != a.ID {
362 460 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused", newName, owner)
363 461 }
364 a.Former = append(append([]string{}, a.Former...), a.Name)
365 a.Name = newName
366 raw, err := marshal(a)
367 if err != nil {
368 return err
462 if _, err := tx.Exec(`UPDATE users SET name=? WHERE id=?`, newName, a.ID); err != nil {
463 return fmt.Errorf("identity: rename %s: %w", a.ID, err)
464 }
465 if _, err := tx.Exec(`INSERT OR IGNORE INTO user_names(name, user_id) VALUES(?,?)`, newName, a.ID); err != nil {
466 return fmt.Errorf("identity: rename %s: %w", a.ID, err)
369 467 }
370 files[Dir+"/"+a.ID+metaName] = raw
371 renamed = a
468 accountID = a.ID
372 469 return nil
373 470 })
374 471 if err != nil {
375 472 return nil, err
376 473 }
377 s.audit("identity.rename", renamed.ID, renamed.Name)
378 return renamed, nil
474 return s.Find(accountID)
379 475 }
380 476
381 477 // Retire disables an account. Its keys stop authenticating at the next reload and
382 478 // its name is never reissued.
383 479 func (s *Store) Retire(idOrName, actor string) (*Account, error) {
384 var retired *Account
385 err := s.update("user: retire "+idOrName, actor, func(files map[string][]byte, snap *Snapshot) error {
386 a, err := find(snap, idOrName)
480 var accountID string
481 err := s.mutate("identity.retire", idOrName, "", func(tx *sql.Tx) error {
482 a, err := findTx(tx, idOrName)
387 483 if err != nil {
388 484 return err
389 485 }
390 a.Status = "retired"
391 raw, err := marshal(a)
392 if err != nil {
393 return err
486 if a.Status == "retired" {
487 return fmt.Errorf("identity: %s is already retired", a.Name)
394 488 }
395 files[Dir+"/"+a.ID+metaName] = raw
396 retired = a
489 if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE id=?`, a.ID); err != nil {
490 return fmt.Errorf("identity: retire %s: %w", a.ID, err)
491 }
492 accountID = a.ID
397 493 return nil
398 494 })
399 495 if err != nil {
400 496 return nil, err
401 497 }
402 s.audit("identity.retire", retired.ID, retired.Name)
403 return retired, nil
498 return s.Find(accountID)
404 499 }
405 500
406 501 // AddKey attaches an authorized_keys line to an account and returns its
@@ -412,97 +507,107 @@ func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) {
412 507 return "", fmt.Errorf("identity: not an SSH public key: %w", err)
413 508 }
414 509 fp := ssh.FingerprintSHA256(pub)
415 err = s.update("user: add key "+fp, actor, func(files map[string][]byte, snap *Snapshot) error {
416 a, err := find(snap, idOrName)
510 err = s.mutate("identity.add-key", idOrName, fp+" "+comment, func(tx *sql.Tx) error {
511 a, err := findTx(tx, idOrName)
417 512 if err != nil {
418 513 return err
419 514 }
420 if owner, taken := snap.ByKey[fp]; taken && owner != a.ID {
515 var owner string
516 err = tx.QueryRow(`SELECT u.name FROM user_keys k JOIN users u ON u.id=k.user_id WHERE k.fingerprint=?`, fp).Scan(&owner)
517 switch {
518 case err == nil && owner == a.Name:
519 return fmt.Errorf("identity: %s already has that key", a.Name)
520 case err == nil:
421 521 return fmt.Errorf("identity: that key is already registered to %s", owner)
522 case !errors.Is(err, sql.ErrNoRows):
523 return fmt.Errorf("identity: read keys: %w", err)
422 524 }
423 for _, k := range a.Keys {
424 if k.Fingerprint == fp {
425 return fmt.Errorf("identity: %s already has that key", a.Name)
426 }
525 if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line, comment) VALUES(?,?,?,?,?)`,
526 fp, a.ID, pub.Type(), line, comment); err != nil {
527 return fmt.Errorf("identity: add key: %w", err)
427 528 }
428 a.Keys = append(a.Keys, Key{Fingerprint: fp, Type: pub.Type(), Line: line, Comment: comment})
429 raw, err := marshal(a)
430 if err != nil {
431 return err
432 }
433 files[Dir+"/"+a.ID+metaName] = raw
434 529 return nil
435 530 })
436 531 if err != nil {
437 532 return "", err
438 533 }
439 s.audit("identity.add-key", idOrName, fp+" "+comment)
440 534 return fp, nil
441 535 }
442 536
443 537 // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted.
444 538 func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error {
445 err := s.update("user: remove key "+fingerprint, actor, func(files map[string][]byte, snap *Snapshot) error {
446 a, err := find(snap, idOrName)
539 return s.mutate("identity.remove-key", idOrName, fingerprint, func(tx *sql.Tx) error {
540 a, err := findTx(tx, idOrName)
447 541 if err != nil {
448 542 return err
449 543 }
450 var kept []Key
451 found := 0
452 for _, k := range a.Keys {
453 if k.Fingerprint == fingerprint || strings.HasPrefix(k.Fingerprint, fingerprint) {
454 found++
455 continue
544 rows, err := tx.Query(`SELECT fingerprint FROM user_keys WHERE user_id=?`, a.ID)
545 if err != nil {
546 return fmt.Errorf("identity: read keys: %w", err)
547 }
548 var matched []string
549 for rows.Next() {
550 var fp string
551 if err := rows.Scan(&fp); err != nil {
552 rows.Close()
553 return fmt.Errorf("identity: read keys: %w", err)
456 554 }
457 kept = append(kept, k)
555 if fp == fingerprint || strings.HasPrefix(fp, fingerprint) {
556 matched = append(matched, fp)
557 }
558 }
559 rows.Close()
560 if err := rows.Err(); err != nil {
561 return err
458 562 }
459 563 switch {
460 case found == 0:
564 case len(matched) == 0:
461 565 return fmt.Errorf("identity: %s has no key %s", a.Name, fingerprint)
462 case found > 1:
463 return fmt.Errorf("identity: %q matches %d keys of %s; give more of the fingerprint", fingerprint, found, a.Name)
566 case len(matched) > 1:
567 return fmt.Errorf("identity: %q matches %d keys of %s; give more of the fingerprint", fingerprint, len(matched), a.Name)
464 568 }
465 a.Keys = kept
466 raw, err := marshal(a)
467 if err != nil {
468 return err
569 if _, err := tx.Exec(`DELETE FROM user_keys WHERE fingerprint=?`, matched[0]); err != nil {
570 return fmt.Errorf("identity: remove key: %w", err)
469 571 }
470 files[Dir+"/"+a.ID+metaName] = raw
471 572 return nil
472 573 })
473 if err != nil {
474 return err
475 }
476 s.audit("identity.remove-key", idOrName, fingerprint)
477 return nil
478 574 }
479 575
480 func find(snap *Snapshot, idOrName string) (*Account, error) {
481 if a, ok := snap.ByID[idOrName]; ok {
482 return a, nil
576 // reservedBy returns the account id holding name, or "" when the name is unused.
577 func reservedBy(tx *sql.Tx, name string) (string, error) {
578 var owner string
579 err := tx.QueryRow(`SELECT user_id FROM user_names WHERE name=?`, name).Scan(&owner)
580 if errors.Is(err, sql.ErrNoRows) {
581 return "", nil
483 582 }
484 if a, ok := snap.ByName[idOrName]; ok {
485 return a, nil
583 if err != nil {
584 return "", fmt.Errorf("identity: read names: %w", err)
486 585 }
487 return nil, fmt.Errorf("identity: no active account %q", idOrName)
586 return owner, nil
488 587 }
489 588
490 func marshal(a *Account) ([]byte, error) {
491 b, err := json.MarshalIndent(meta{
492 Name: a.Name,
493 Status: a.Status,
494 Created: a.Created.UTC().Format(time.RFC3339),
495 Former: a.Former,
496 Keys: a.Keys,
497 }, "", " ")
589 // findTx resolves an id (any status) or an active name inside a transaction.
590 func findTx(tx *sql.Tx, idOrName string) (*Account, error) {
591 var a Account
592 var created int64
593 err := tx.QueryRow(`SELECT id, name, status, created FROM users WHERE id=?`, idOrName).
594 Scan(&a.ID, &a.Name, &a.Status, &created)
595 if errors.Is(err, sql.ErrNoRows) {
596 err = tx.QueryRow(`SELECT id, name, status, created FROM users WHERE name=? AND status='active'`, idOrName).
597 Scan(&a.ID, &a.Name, &a.Status, &created)
598 }
599 if errors.Is(err, sql.ErrNoRows) {
600 return nil, fmt.Errorf("identity: no active account %q", idOrName)
601 }
498 602 if err != nil {
499 return nil, err
603 return nil, fmt.Errorf("identity: read roster: %w", err)
500 604 }
501 return append(b, '\n'), nil
605 a.Created = time.Unix(created, 0).UTC()
606 return &a, nil
502 607 }
503 608
504 // audit appends one line describing a roster change. The git history is the real
505 // record; this is for operators grepping one file.
609 // audit appends one line describing a roster change. The database holds the
610 // roster; this file is the only record of who changed it and when.
506 611 func (s *Store) audit(action, account, detail string) {
507 612 if s.AuditPath == "" {
508 613 return