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.

Code

394 commit(s) on main

Default the run verbs to origin Keenan Nemetz · 17m ago 2e6aa96e
history blame raw 42.0 KiB
1// Package identity stores the user roster in SQLite.
2//
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: fixed name, status, instance role, created
8// user_keys one row per authorized SSH key, unique by fingerprint
9// user_credentials one row per passkey: the browser credential's public half
10// user_invites one-time enrollment tokens, stored only as hashes
11// sessions browser sessions, stored only as token hashes
12//
13// An account is identified by its registered name, which is fixed the moment it
14// is created. The name is what contributor branches and metadata authors are
15// written in, so it can never move: a rename would strand every ref under the old
16// folder. It is also what ACL entries, approvals, locks and CI records are keyed
17// by. Because the users row is kept after retirement, the UNIQUE constraint on the
18// name stops a retired name being handed to a second account — which is what stops
19// a new user from inheriting an old one's access and approvals.
20//
21// The credentials anchor the account. An SSH key is resolved at the SSH
22// handshake; a passkey is resolved in the browser login ceremony. Both yield the
23// same account name, and every authorization decision downstream uses that name
24// rather than a second internal identifier.
25//
26// Nothing stored here is a usable secret: public keys and public credential
27// halves, plus hashes of session and invite tokens. The database file can still
28// be copied as a backup, but it carries authentication state, so restoring an
29// old copy should be followed by `vole admin account revoke-sessions`. Copy it with
30// Backup rather than cp: the database runs in WAL mode, so the newest committed
31// rows may still be in the -wal file.
32package identity
33
34import (
35 "database/sql"
36 "encoding/json"
37 "errors"
38 "fmt"
39 "os"
40 "path/filepath"
41 "regexp"
42 "sort"
43 "strings"
44 "sync/atomic"
45 "time"
46
47 _ "github.com/mattn/go-sqlite3"
48
49 "golang.org/x/crypto/ssh"
50 "rodent.software/vole/forge/access"
51 "rodent.software/vole/internal/audit"
52 "rodent.software/vole/internal/secure"
53)
54
55var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
56
57// ValidateName reports whether s may be an account name. Names are also branch
58// path components (refs/heads/contrib/<name>/...), which is why they are limited
59// to the characters git accepts in a ref component.
60func ValidateName(s string) error {
61 if !nameRE.MatchString(s) {
62 return fmt.Errorf("name %q is not valid: 2-32 characters, lowercase letters, digits, - and _", s)
63 }
64 return nil
65}
66
67type Key struct {
68 Fingerprint string `json:"fingerprint"`
69 Type string `json:"type"`
70 Line string `json:"line"`
71 Comment string `json:"comment,omitempty"`
72}
73
74// Kinds of account. A bot is an automation identity: it holds repository roles and
75// keys like a person, but it has no browser sign-in and cannot be an instance admin,
76// because nothing it does needs either. The kind is fixed at creation for the same
77// reason the name is — branches, audit lines and access rules already refer to the
78// account — and there is no command that changes one.
79const (
80 KindPerson = "person"
81 KindBot = "bot"
82)
83
84// ValidKind reports whether a value is an account kind.
85func ValidKind(kind string) error {
86 switch kind {
87 case KindPerson, KindBot:
88 return nil
89 }
90 return fmt.Errorf("%q is not an account kind; use %q or %q", kind, KindPerson, KindBot)
91}
92
93type Account struct {
94 Name string // fixed at creation; contributor branches are named after it
95 // Kind is KindPerson or KindBot, fixed at creation. An account read from a
96 // roster that predates the column is a person.
97 Kind string
98 Status string // active | retired
99 Keys []Key
100 // Role is the account's instance-wide role. It applies to public
101 // repositories; Admin applies to every repository, private ones included.
102 // Admin is kept as a derived convenience for callers that only ask whether
103 // the account may use the instance-admin surfaces.
104 Role access.Role
105 Admin bool
106 Created time.Time
107}
108
109func (a *Account) Active() bool { return a.Status == "active" }
110
111// Bot reports whether this account is an automation identity. A zero Kind is a
112// person: every roster before the column existed held people.
113func (a *Account) Bot() bool { return a.Kind == KindBot }
114
115// Snapshot is an immutable view of the roster, swapped atomically so the SSH
116// auth callback stays a map lookup.
117type Snapshot struct {
118 // ByKey maps a key fingerprint to an active account name. Retired accounts
119 // are absent, so revoking is immediate.
120 ByKey map[string]string
121 // ByName maps a name to its account, including retired accounts: a name is
122 // fixed at creation and remains attached to the account that took it.
123 ByName map[string]*Account
124
125 Count int // active accounts
126}
127
128func (s *Snapshot) Lookup(fingerprint string) (string, bool) {
129 if s == nil {
130 return "", false
131 }
132 name, ok := s.ByKey[fingerprint]
133 return name, ok
134}
135
136type Store struct {
137 // AuditPath receives one line per roster change. A change that cannot be
138 // recorded is refused, because this file is the only history of the roster.
139 AuditPath string
140 db *sql.DB
141 path string
142 snap atomic.Pointer[Snapshot]
143}
144
145const schema = `
146CREATE TABLE IF NOT EXISTS users (
147 name TEXT PRIMARY KEY,
148 kind TEXT NOT NULL DEFAULT 'person' CHECK (kind IN ('person','bot')),
149 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
150 created INTEGER NOT NULL,
151 instance_role TEXT NOT NULL DEFAULT 'none'
152);
153CREATE TABLE IF NOT EXISTS user_keys (
154 fingerprint TEXT PRIMARY KEY,
155 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
156 type TEXT NOT NULL,
157 line TEXT NOT NULL,
158 comment TEXT NOT NULL DEFAULT ''
159);
160CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_name);
161CREATE TABLE IF NOT EXISTS user_credentials (
162 credential_id TEXT PRIMARY KEY,
163 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
164 public_key BLOB NOT NULL,
165 attestation_type TEXT NOT NULL DEFAULT '',
166 transports TEXT NOT NULL DEFAULT '[]',
167 flags INTEGER NOT NULL DEFAULT 0,
168 aaguid BLOB,
169 sign_count INTEGER NOT NULL DEFAULT 0,
170 clone_warning INTEGER NOT NULL DEFAULT 0,
171 attachment TEXT NOT NULL DEFAULT '',
172 label TEXT NOT NULL DEFAULT '',
173 created INTEGER NOT NULL,
174 last_used INTEGER NOT NULL DEFAULT 0
175);
176CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_name);
177CREATE TABLE IF NOT EXISTS user_invites (
178 token_hash TEXT PRIMARY KEY,
179 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
180 created INTEGER NOT NULL,
181 expires INTEGER NOT NULL,
182 used_at INTEGER,
183 created_by TEXT NOT NULL DEFAULT ''
184);
185CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_name);
186CREATE TABLE IF NOT EXISTS sessions (
187 token_hash TEXT PRIMARY KEY,
188 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
189 credential_id TEXT REFERENCES user_credentials(credential_id) ON DELETE CASCADE,
190 created INTEGER NOT NULL,
191 last_seen INTEGER NOT NULL,
192 expires INTEGER NOT NULL,
193 user_agent TEXT NOT NULL DEFAULT ''
194);
195CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_name);
196CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);
197`
198
199func openDB(path string) (*sql.DB, error) {
200 // _txlock=immediate makes every write transaction take the write lock up
201 // front, so a second `vole admin account` process waits on busy_timeout instead of
202 // failing when it tries to upgrade a read lock mid-transaction.
203 db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=1&_txlock=immediate")
204 if err != nil {
205 return nil, err
206 }
207 db.SetMaxOpenConns(4)
208 return db, nil
209}
210
211// Init creates the roster database if it is missing. Running it on an existing
212// roster only re-applies the schema, which is how an upgrade adds a table.
213func Init(path string) error {
214 // The roster's directory is created the way the roster itself is created:
215 // reachable by the service account and nobody else.
216 if err := secure.Mkdir(filepath.Dir(path)); err != nil {
217 return err
218 }
219 db, err := openDB(path)
220 if err != nil {
221 return fmt.Errorf("identity: %w", err)
222 }
223 defer db.Close()
224 if err := migrate(db); err != nil {
225 return fmt.Errorf("identity: migrate roster: %w", err)
226 }
227 if err := migrateInstanceRoles(db); err != nil {
228 return fmt.Errorf("identity: migrate instance roles: %w", err)
229 }
230 if err := migrateAccountKind(db); err != nil {
231 return fmt.Errorf("identity: migrate account kind: %w", err)
232 }
233 if _, err := db.Exec(schema); err != nil {
234 return fmt.Errorf("identity: create roster: %w", err)
235 }
236 return nil
237}
238
239// migrate upgrades a roster written by an older build in place. It is idempotent
240// and runs on every open, so an operator who restores an old backup does not
241// have to remember which version produced it.
242//
243// Rosters used to key accounts by an internal id and carry a display label.
244// Both are gone: the registered name is the identity. A roster with the old
245// shape is rebuilt around the name, copying every row through the account it
246// belonged to, so no key, passkey, invite, session or admin grant is lost.
247func migrate(db *sql.DB) error {
248 hasUsers, err := tableExists(db, "users")
249 if err != nil {
250 return err
251 }
252 if !hasUsers {
253 return nil
254 }
255 hasID, err := columnExists(db, "users", "id")
256 if err != nil {
257 return err
258 }
259 if !hasID {
260 // A name-keyed roster is current. The alias table from an even older
261 // build is redundant: the users row itself reserves the name.
262 _, err := db.Exec(`DROP TABLE IF EXISTS user_names`)
263 return err
264 }
265
266 tx, err := db.Begin()
267 if err != nil {
268 return err
269 }
270 defer tx.Rollback()
271
272 // Build the replacement tables first, then copy through the old id so a
273 // reference becomes the name it resolved to.
274 for _, name := range []string{"users_new", "user_keys_new", "instance_admins_new",
275 "user_credentials_new", "user_invites_new", "sessions_new"} {
276 if _, err := tx.Exec(`DROP TABLE IF EXISTS ` + name); err != nil {
277 return err
278 }
279 }
280 if _, err := tx.Exec(`
281CREATE TABLE users_new (
282 name TEXT PRIMARY KEY,
283 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
284 created INTEGER NOT NULL,
285 instance_role TEXT NOT NULL DEFAULT 'none'
286);
287CREATE TABLE user_keys_new (
288 fingerprint TEXT PRIMARY KEY,
289 user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE,
290 type TEXT NOT NULL,
291 line TEXT NOT NULL,
292 comment TEXT NOT NULL DEFAULT ''
293);
294CREATE TABLE instance_admins_new (
295 user_name TEXT PRIMARY KEY REFERENCES users_new(name) ON DELETE CASCADE
296);
297CREATE TABLE user_credentials_new (
298 credential_id TEXT PRIMARY KEY,
299 user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE,
300 public_key BLOB NOT NULL,
301 attestation_type TEXT NOT NULL DEFAULT '',
302 transports TEXT NOT NULL DEFAULT '[]',
303 flags INTEGER NOT NULL DEFAULT 0,
304 aaguid BLOB,
305 sign_count INTEGER NOT NULL DEFAULT 0,
306 clone_warning INTEGER NOT NULL DEFAULT 0,
307 attachment TEXT NOT NULL DEFAULT '',
308 label TEXT NOT NULL DEFAULT '',
309 created INTEGER NOT NULL,
310 last_used INTEGER NOT NULL DEFAULT 0
311);
312CREATE TABLE user_invites_new (
313 token_hash TEXT PRIMARY KEY,
314 user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE,
315 created INTEGER NOT NULL,
316 expires INTEGER NOT NULL,
317 used_at INTEGER,
318 created_by TEXT NOT NULL DEFAULT ''
319);
320CREATE TABLE sessions_new (
321 token_hash TEXT PRIMARY KEY,
322 user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE,
323 credential_id TEXT REFERENCES user_credentials_new(credential_id) ON DELETE CASCADE,
324 created INTEGER NOT NULL,
325 last_seen INTEGER NOT NULL,
326 expires INTEGER NOT NULL,
327 user_agent TEXT NOT NULL DEFAULT ''
328);`); err != nil {
329 return err
330 }
331
332 if _, err := tx.Exec(`INSERT INTO users_new(name, status, created) SELECT name, status, created FROM users`); err != nil {
333 return err
334 }
335 if err := copyIfTable(tx, "user_keys", `INSERT INTO user_keys_new(fingerprint, user_name, type, line, comment)
336SELECT k.fingerprint, u.name, k.type, k.line, k.comment FROM user_keys k JOIN users u ON u.id = k.user_id`); err != nil {
337 return err
338 }
339 if err := copyIfTable(tx, "instance_admins", `INSERT INTO instance_admins_new(user_name)
340SELECT u.name FROM instance_admins ia JOIN users u ON u.id = ia.user_id`); err != nil {
341 return err
342 }
343 if err := copyIfTable(tx, "user_credentials", `INSERT INTO user_credentials_new(
344credential_id, user_name, public_key, attestation_type, transports, flags, aaguid,
345sign_count, clone_warning, attachment, label, created, last_used)
346SELECT c.credential_id, u.name, c.public_key, c.attestation_type, c.transports, c.flags, c.aaguid,
347c.sign_count, c.clone_warning, c.attachment, c.label, c.created, c.last_used
348FROM user_credentials c JOIN users u ON u.id = c.user_id`); err != nil {
349 return err
350 }
351 if err := copyIfTable(tx, "user_invites", `INSERT INTO user_invites_new(token_hash, user_name, created, expires, used_at, created_by)
352SELECT i.token_hash, u.name, i.created, i.expires, i.used_at, i.created_by
353FROM user_invites i JOIN users u ON u.id = i.user_id`); err != nil {
354 return err
355 }
356 if err := copyIfTable(tx, "sessions", `INSERT INTO sessions_new(token_hash, user_name, credential_id, created, last_seen, expires, user_agent)
357SELECT s.token_hash, u.name, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent
358FROM sessions s JOIN users u ON u.id = s.account_id`); err != nil {
359 return err
360 }
361
362 // Access rules and file locks live in the same database and name accounts by
363 // the old id. Rewrite them through the same mapping before the ids disappear.
364 if err := migrateUserReferences(tx); err != nil {
365 return err
366 }
367
368 // Drop the old tables children-first, then put the replacements in place.
369 for _, name := range []string{"sessions", "user_invites", "user_credentials",
370 "instance_admins", "user_keys", "user_names", "users"} {
371 if _, err := tx.Exec(`DROP TABLE IF EXISTS ` + name); err != nil {
372 return err
373 }
374 }
375 for _, rename := range [][2]string{
376 {"users_new", "users"},
377 {"user_credentials_new", "user_credentials"},
378 {"user_keys_new", "user_keys"},
379 {"instance_admins_new", "instance_admins"},
380 {"user_invites_new", "user_invites"},
381 {"sessions_new", "sessions"},
382 } {
383 if _, err := tx.Exec(`ALTER TABLE ` + rename[0] + ` RENAME TO ` + rename[1]); err != nil {
384 return err
385 }
386 }
387 if _, err := tx.Exec(`
388CREATE INDEX user_keys_owner ON user_keys(user_name);
389CREATE INDEX user_credentials_owner ON user_credentials(user_name);
390CREATE INDEX user_invites_owner ON user_invites(user_name);
391CREATE INDEX sessions_user ON sessions(user_name);
392CREATE INDEX sessions_expiry ON sessions(expires);`); err != nil {
393 return err
394 }
395 return tx.Commit()
396}
397
398// migrateAccountKind adds the kind column to a roster that predates bots. Every
399// existing account is a person, which is the default: a column that defaulted to
400// anything else would turn every operator into an automation on upgrade.
401func migrateAccountKind(db *sql.DB) error {
402 hasUsers, err := tableExists(db, "users")
403 if err != nil || !hasUsers {
404 return err
405 }
406 hasKind, err := columnExists(db, "users", "kind")
407 if err != nil {
408 return err
409 }
410 if hasKind {
411 return nil
412 }
413 _, err = db.Exec(`ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'person'`)
414 return err
415}
416
417// migrateInstanceRoles turns the old instance_admins table into the instance_role
418// column on users. An instance admin is now the Admin role in the same ladder as
419// every other instance-wide grant, so there is one answer to "what may this
420// account do here". The old rows are copied and then cleared: leaving them would
421// re-grant admin on the next open after somebody revoked it.
422func migrateInstanceRoles(db *sql.DB) error {
423 hasUsers, err := tableExists(db, "users")
424 if err != nil || !hasUsers {
425 return err
426 }
427 hasRole, err := columnExists(db, "users", "instance_role")
428 if err != nil {
429 return err
430 }
431 if !hasRole {
432 if _, err := db.Exec(`ALTER TABLE users ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'none'`); err != nil {
433 return err
434 }
435 }
436 // contributor was the historical spelling of contribute. Normalise the
437 // stored value so a raw look at the roster shows the same role the UI and
438 // CLI do.
439 if _, err := db.Exec(`UPDATE users SET instance_role='contribute' WHERE instance_role IN ('contributor','contrib')`); err != nil {
440 return err
441 }
442 hasAdmins, err := tableExists(db, "instance_admins")
443 if err != nil || !hasAdmins {
444 return err
445 }
446 if _, err := db.Exec(`UPDATE users SET instance_role='admin'
447WHERE instance_role='none' AND name IN (SELECT user_name FROM instance_admins)`); err != nil {
448 return err
449 }
450 _, err = db.Exec(`DELETE FROM instance_admins`)
451 return err
452}
453
454// migrateUserReferences rewrites the tables that store an account key as plain
455// text rather than a foreign key. Only access rules and locks live that way; a
456// missing table is normal on a database that never used those features.
457func migrateUserReferences(tx *sql.Tx) error {
458 tableExists := func(name string) (bool, error) {
459 var n int
460 err := tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
461 return n > 0, err
462 }
463 hasACL, err := tableExists("repo_acl")
464 if err != nil {
465 return err
466 }
467 hasLocks, err := tableExists("lfs_locks")
468 if err != nil {
469 return err
470 }
471 if !hasACL && !hasLocks {
472 return nil
473 }
474 if _, err := tx.Exec(`DROP TABLE IF EXISTS _user_id_map;
475CREATE TABLE _user_id_map(old_id TEXT PRIMARY KEY, name TEXT NOT NULL);
476INSERT INTO _user_id_map(old_id, name) SELECT id, name FROM users`); err != nil {
477 return err
478 }
479 if hasACL {
480 type aclRow struct {
481 repoID string
482 raw string
483 }
484 rows, err := tx.Query(`SELECT repo_id, acl FROM repo_acl`)
485 if err != nil {
486 return err
487 }
488 var updates []aclRow
489 for rows.Next() {
490 var r aclRow
491 if err := rows.Scan(&r.repoID, &r.raw); err != nil {
492 rows.Close()
493 return err
494 }
495 updates = append(updates, r)
496 }
497 rows.Close()
498 if err := rows.Err(); err != nil {
499 return err
500 }
501 for _, u := range updates {
502 rewritten, err := rewriteACLNames(tx, u.raw)
503 if err != nil {
504 return err
505 }
506 if rewritten == u.raw {
507 continue
508 }
509 if _, err := tx.Exec(`UPDATE repo_acl SET acl=? WHERE repo_id=?`, rewritten, u.repoID); err != nil {
510 return err
511 }
512 }
513 if _, err := tx.Exec(`UPDATE repo_acl SET updated_by=COALESCE(
514(SELECT name FROM _user_id_map WHERE old_id=repo_acl.updated_by), updated_by)`); err != nil {
515 return err
516 }
517 }
518 if hasLocks {
519 if _, err := tx.Exec(`UPDATE lfs_locks SET owner=COALESCE(
520(SELECT name FROM _user_id_map WHERE old_id=lfs_locks.owner), owner)
521WHERE owner IN (SELECT old_id FROM _user_id_map)`); err != nil {
522 return err
523 }
524 }
525 _, err = tx.Exec(`DROP TABLE _user_id_map`)
526 return err
527}
528
529// rewriteACLNames replaces account ids with names inside an access-rule document.
530// The fields are handled generically so a rule set written by a newer build keeps
531// every setting this one does not know about.
532func rewriteACLNames(tx *sql.Tx, raw string) (string, error) {
533 var doc map[string]any
534 if err := json.Unmarshal([]byte(raw), &doc); err != nil {
535 return "", fmt.Errorf("identity: read access rules during migration: %w", err)
536 }
537 lookup := func(id string) string {
538 var name string
539 // The mapping is in the transaction's table; a small query per key is
540 // fine because an ACL has a handful of entries.
541 if err := tx.QueryRow(`SELECT name FROM _user_id_map WHERE old_id=?`, id).Scan(&name); err == nil && name != "" {
542 return name
543 }
544 return id
545 }
546 if entries, ok := doc["entries"].(map[string]any); ok {
547 next := make(map[string]any, len(entries))
548 for id, role := range entries {
549 next[lookup(id)] = role
550 }
551 doc["entries"] = next
552 }
553 if runners, ok := doc["runners"].([]any); ok {
554 for i, r := range runners {
555 if id, ok := r.(string); ok {
556 runners[i] = lookup(id)
557 }
558 }
559 doc["runners"] = runners
560 }
561 out, err := json.MarshalIndent(doc, "", " ")
562 if err != nil {
563 return "", err
564 }
565 return string(out) + "\n", nil
566}
567
568func tableExists(db *sql.DB, table string) (bool, error) {
569 var n int
570 err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
571 return n > 0, err
572}
573
574func columnExists(db *sql.DB, table, column string) (bool, error) {
575 rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
576 if err != nil {
577 return false, err
578 }
579 defer rows.Close()
580 for rows.Next() {
581 var (
582 cid int
583 name string
584 typ string
585 notnull int
586 dflt any
587 pk int
588 )
589 if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
590 return false, err
591 }
592 if name == column {
593 return true, nil
594 }
595 }
596 return false, rows.Err()
597}
598
599func copyIfTable(tx *sql.Tx, table, statement string) error {
600 var n int
601 if err := tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n); err != nil {
602 return err
603 }
604 if n == 0 {
605 return nil
606 }
607 _, err := tx.Exec(statement)
608 return err
609}
610
611// Open loads the roster. An empty roster is valid: it means nobody can write yet.
612// A roster that does not parse is a hard error, because booting with a partial
613// roster would silently change who can authenticate.
614func Open(path string) (*Store, error) {
615 if _, err := os.Stat(path); err != nil {
616 return nil, fmt.Errorf("identity: %s is missing; run vole admin init", path)
617 }
618 db, err := openDB(path)
619 if err != nil {
620 return nil, fmt.Errorf("identity: %w", err)
621 }
622 // Applying the schema here keeps an existing roster usable when a new table
623 // is added; every statement is CREATE IF NOT EXISTS. Migration runs first so
624 // the new indexes are only applied to tables that already have the new shape.
625 if err := migrate(db); err != nil {
626 db.Close()
627 return nil, fmt.Errorf("identity: %w", err)
628 }
629 if err := migrateInstanceRoles(db); err != nil {
630 db.Close()
631 return nil, fmt.Errorf("identity: %w", err)
632 }
633 if err := migrateAccountKind(db); err != nil {
634 db.Close()
635 return nil, fmt.Errorf("identity: %w", err)
636 }
637 if _, err := db.Exec(schema); err != nil {
638 db.Close()
639 return nil, fmt.Errorf("identity: %w", err)
640 }
641 s := &Store{db: db, path: path}
642 if err := s.Reload(); err != nil {
643 db.Close()
644 return nil, err
645 }
646 return s, nil
647}
648
649func (s *Store) Close() error { return s.db.Close() }
650
651func (s *Store) Snapshot() *Snapshot { return s.snap.Load() }
652
653// Path is the roster database's path.
654func (s *Store) Path() string { return s.path }
655
656// Backup writes a consistent copy of the roster to dst. VACUUM INTO takes a read
657// transaction, so it is safe against a running instance and the copy is one file
658// with no -wal to lose.
659func (s *Store) Backup(dst string) error {
660 if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
661 return err
662 }
663 if _, err := s.db.Exec(`VACUUM INTO ?`, dst); err != nil {
664 return err
665 }
666 // VACUUM INTO creates the file with the process umask, and this copy holds
667 // every account, SSH key and passkey in the roster.
668 _, err := secure.HardenFile(dst)
669 return err
670}
671
672// Reload re-reads the roster, keeping the previous snapshot if the new one does
673// not parse: a half-finished edit must not lock everybody out.
674func (s *Store) Reload() error {
675 snap, err := s.load()
676 if err != nil {
677 return err
678 }
679 s.snap.Store(snap)
680 return nil
681}
682
683func (s *Store) load() (*Snapshot, error) {
684 snap := &Snapshot{
685 ByKey: map[string]string{},
686 ByName: map[string]*Account{},
687 }
688 rows, err := s.db.Query(`SELECT name, kind, status, created, instance_role FROM users ORDER BY name`)
689 if err != nil {
690 return nil, fmt.Errorf("identity: read roster: %w", err)
691 }
692 defer rows.Close()
693 for rows.Next() {
694 var a Account
695 var status, role string
696 var created int64
697 if err := rows.Scan(&a.Name, &a.Kind, &status, &created, &role); err != nil {
698 return nil, fmt.Errorf("identity: read roster: %w", err)
699 }
700 if a.Kind == "" {
701 a.Kind = KindPerson
702 }
703 if err := ValidKind(a.Kind); err != nil {
704 return nil, fmt.Errorf("identity: account %s: %w", a.Name, err)
705 }
706 if err := ValidateName(a.Name); err != nil {
707 return nil, fmt.Errorf("identity: account %s: %w", a.Name, err)
708 }
709 switch status {
710 case "", "active":
711 status = "active"
712 case "retired":
713 default:
714 return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.Name)
715 }
716 r, err := access.ParseRole(role)
717 if err != nil {
718 return nil, fmt.Errorf("identity: account %s: instance role: %w", a.Name, err)
719 }
720 a.Status = status
721 a.Role = r
722 a.Admin = r.AtLeast(access.Admin)
723 a.Created = time.Unix(created, 0).UTC()
724 snap.ByName[a.Name] = &a
725 }
726 if err := rows.Err(); err != nil {
727 return nil, err
728 }
729
730 keys, err := s.db.Query(`SELECT fingerprint, user_name, type, line, comment FROM user_keys ORDER BY fingerprint`)
731 if err != nil {
732 return nil, fmt.Errorf("identity: read roster: %w", err)
733 }
734 defer keys.Close()
735 for keys.Next() {
736 var k Key
737 var owner string
738 if err := keys.Scan(&k.Fingerprint, &owner, &k.Type, &k.Line, &k.Comment); err != nil {
739 return nil, fmt.Errorf("identity: read roster: %w", err)
740 }
741 a, ok := snap.ByName[owner]
742 if !ok {
743 return nil, fmt.Errorf("identity: key %s belongs to unknown account %s", k.Fingerprint, owner)
744 }
745 if other, taken := snap.ByKey[k.Fingerprint]; taken && other != owner {
746 return nil, fmt.Errorf("identity: key %s appears under both %s and %s", k.Fingerprint, other, owner)
747 }
748 a.Keys = append(a.Keys, k)
749 if a.Active() {
750 snap.ByKey[k.Fingerprint] = owner
751 }
752 }
753 if err := keys.Err(); err != nil {
754 return nil, err
755 }
756
757 for _, a := range snap.ByName {
758 sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint })
759 if a.Active() {
760 snap.Count++
761 }
762 }
763 return snap, nil
764}
765
766// InstanceRole returns an account's instance-wide role. Retired and unknown
767// accounts have none, so a revoked account cannot keep a public repository open.
768func (s *Store) InstanceRole(name string) access.Role {
769 snap := s.Snapshot()
770 if snap == nil {
771 return access.None
772 }
773 a, ok := snap.ByName[name]
774 if !ok || !a.Active() {
775 return access.None
776 }
777 return a.Role
778}
779
780// Active reports whether the account exists and has not been retired. Rules that
781// outlive an account — an ACL entry, a runners-list membership, an approval — ask
782// this before counting it.
783func (s *Store) Active(name string) bool {
784 snap := s.Snapshot()
785 if snap == nil {
786 return false
787 }
788 a, ok := snap.ByName[name]
789 return ok && a.Active()
790}
791
792// IsAdmin reports whether an active account has the instance-wide Admin role,
793// which is also the remote administration API's gate.
794func (s *Store) IsAdmin(name string) bool {
795 return s.InstanceRole(name).AtLeast(access.Admin)
796}
797
798func (s *Store) setInstanceRole(name string, role access.Role, actor, action, detail string) (*Account, error) {
799 if role < access.None || role > access.Admin {
800 return nil, fmt.Errorf("identity: invalid role %d", role)
801 }
802 err := s.mutate(action, actor, name, detail, func(tx *sql.Tx) error {
803 a, err := findTx(tx, name)
804 if err != nil {
805 return err
806 }
807 if !a.Active() {
808 return fmt.Errorf("identity: %s is retired", a.Name)
809 }
810 if a.Bot() && role.AtLeast(access.Admin) {
811 return fmt.Errorf("identity: %s is a bot account and cannot be an instance admin", a.Name)
812 }
813 if a.Role.AtLeast(access.Admin) && role < access.Admin {
814 var activeAdmins int
815 if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil {
816 return err
817 }
818 if activeAdmins <= 1 {
819 return fmt.Errorf("identity: %s is the last instance admin; grant another admin before revoking this one", a.Name)
820 }
821 }
822 _, err = tx.Exec(`UPDATE users SET instance_role=? WHERE name=?`, role.String(), a.Name)
823 return err
824 })
825 if err != nil {
826 return nil, err
827 }
828 return s.Find(name)
829}
830
831// SetInstanceRole changes an account's instance-wide role. It refuses to remove
832// the last active instance admin, so a remote mistake cannot lock everybody out.
833func (s *Store) SetInstanceRole(name string, role access.Role, actor string) (*Account, error) {
834 return s.setInstanceRole(name, role, actor, "identity.set-role", "role="+role.String())
835}
836
837// SetAdmin is the historical spelling of setting or clearing the Admin role. It
838// stays so existing CLI scripts and remote callers keep working.
839func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error) {
840 if admin {
841 return s.setInstanceRole(name, access.Admin, actor, "identity.grant-admin", "role=admin")
842 }
843 a, err := s.Find(name)
844 if err != nil {
845 return nil, err
846 }
847 if !a.Role.AtLeast(access.Admin) {
848 return nil, fmt.Errorf("identity: %s is not an instance admin", a.Name)
849 }
850 return s.setInstanceRole(name, access.None, actor, "identity.revoke-admin", "role=none")
851}
852
853// Lookup resolves a key fingerprint to an active account name.
854func (s *Store) Lookup(fingerprint string) (name string, ok bool) {
855 return s.Snapshot().Lookup(fingerprint)
856}
857
858// Find resolves a name, including the name of a retired account.
859func (s *Store) Find(name string) (*Account, error) {
860 if a, ok := s.Snapshot().ByName[name]; ok {
861 return a, nil
862 }
863 return nil, fmt.Errorf("identity: no account %q", name)
864}
865
866// All returns every account, active first, then by name.
867func (s *Store) All() []*Account {
868 snap := s.Snapshot()
869 out := make([]*Account, 0, len(snap.ByName))
870 for _, a := range snap.ByName {
871 out = append(out, a)
872 }
873 sort.Slice(out, func(i, j int) bool {
874 if out[i].Active() != out[j].Active() {
875 return out[i].Active()
876 }
877 return out[i].Name < out[j].Name
878 })
879 return out
880}
881
882// Roster returns the active account names, sorted. The SSH server serves this to
883// authenticated clients so `vole change` can check that a name is a real account.
884func (s *Store) Roster() []string {
885 var out []string
886 for _, a := range s.All() {
887 if a.Active() {
888 out = append(out, a.Name)
889 }
890 }
891 return out
892}
893
894// ---------- mutations ----------
895//
896// Each change is one transaction, and SQLite enforces the two invariants that
897// matter: a name is issued once and a key belongs to one account. The audit log
898// is the only history — there is no commit trail behind the rows any more.
899
900func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx) error) error {
901 tx, err := s.db.Begin()
902 if err != nil {
903 return fmt.Errorf("identity: %w", err)
904 }
905 defer tx.Rollback()
906 if err := fn(tx); err != nil {
907 return err
908 }
909 // The record is written before the commit, so a roster change that cannot be
910 // recorded does not happen either. A failed commit leaves an extra line; a
911 // line nobody could write would leave a change nobody can explain.
912 if err := s.audit(action, actor, account, detail); err != nil {
913 return err
914 }
915 if err := tx.Commit(); err != nil {
916 return fmt.Errorf("identity: %w", err)
917 }
918 if err := s.Reload(); err != nil {
919 return err
920 }
921 return nil
922}
923
924// Create adds an account with a fixed name and its first SSH key. The account,
925// the name and the key are written in one transaction, so a name is never claimed
926// by an account that cannot authenticate.
927//
928// An account that will use a passkey instead is created with CreateWithInvite;
929// the invite is the credential-shaped way in.
930func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) {
931 return s.CreateWithRole(name, access.None, authorizedKey, actor)
932}
933
934// CreateWithRole creates an account with an instance-wide role and its first SSH
935// key in one transaction. The role is account standing, not a repository rule:
936// it applies to public repositories, while Admin applies to every repository.
937func (s *Store) CreateWithRole(name string, role access.Role, authorizedKey, actor string) (*Account, error) {
938 if err := ValidateName(name); err != nil {
939 return nil, err
940 }
941 key, err := parseKey(authorizedKey)
942 if err != nil {
943 return nil, err
944 }
945 now := time.Now().UTC()
946 err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint+" role="+role.String(), func(tx *sql.Tx) error {
947 return createAccount(tx, name, KindPerson, role, now, &key, nil)
948 })
949 if err != nil {
950 return nil, err
951 }
952 return s.Find(name)
953}
954
955// CreateBot creates an automation account: an identity that can be assigned work,
956// mentioned, and attributed, but cannot sign in to the browser. The role is the
957// account's instance-wide standing, and the same rule as for a person applies: a
958// bot may not be an instance admin. Repository roles are granted separately, by the
959// admin who decides what this automation may do.
960//
961// A bot may be created without a key. That account cannot act until one is added or
962// until the instance issues it a per-run credential, which is the point: a key is a
963// long-lived grant and a per-run credential is not.
964func (s *Store) CreateBot(name string, role access.Role, authorizedKey, actor string) (*Account, error) {
965 if err := ValidateName(name); err != nil {
966 return nil, err
967 }
968 var key *Key
969 if strings.TrimSpace(authorizedKey) != "" {
970 parsed, err := parseKey(authorizedKey)
971 if err != nil {
972 return nil, err
973 }
974 key = &parsed
975 }
976 now := time.Now().UTC()
977 detail := "kind=bot role=" + role.String()
978 if key != nil {
979 detail = key.Fingerprint + " " + detail
980 }
981 err := s.mutate("identity.create-bot", actor, name, detail, func(tx *sql.Tx) error {
982 return createAccount(tx, name, KindBot, role, now, key, nil)
983 })
984 if err != nil {
985 return nil, err
986 }
987 return s.Find(name)
988}
989
990// pendingInvite is the row half of an enrollment link. The plaintext token is
991// returned to the caller once and never stored.
992type pendingInvite struct {
993 tokenHash string
994 expires time.Time
995 createdBy string
996}
997
998func createAccount(tx *sql.Tx, name, kind string, role access.Role, now time.Time, key *Key, invite *pendingInvite) error {
999 if role < access.None || role > access.Admin {
1000 return fmt.Errorf("identity: invalid role %d", role)
1001 }
1002 if err := ValidKind(kind); err != nil {
1003 return fmt.Errorf("identity: %w", err)
1004 }
1005 // An instance admin reaches every repository and every admin surface; a bot
1006 // exists to act in repositories, so admin is not a role it may hold. Repository
1007 // roles are unaffected: a bot may be a maintainer of one repository.
1008 if kind == KindBot && role.AtLeast(access.Admin) {
1009 return fmt.Errorf("identity: %s would be an instance admin; a bot account cannot hold that role", name)
1010 }
1011 if owner, err := nameOwner(tx, name); err != nil {
1012 return err
1013 } else if owner != "" {
1014 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a name is never handed out twice", name, owner)
1015 }
1016 if key != nil {
1017 if owner, err := keyOwner(tx, key.Fingerprint); err != nil {
1018 return err
1019 } else if owner != "" {
1020 return fmt.Errorf("identity: that key is already registered to %s", owner)
1021 }
1022 }
1023 if _, err := tx.Exec(`INSERT INTO users(name, kind, status, created, instance_role) VALUES(?, ?, 'active', ?, ?)`,
1024 name, kind, now.Unix(), role.String()); err != nil {
1025 return fmt.Errorf("identity: create %s: %w", name, err)
1026 }
1027 if key != nil {
1028 if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_name, type, line, comment) VALUES(?,?,?,?,?)`,
1029 key.Fingerprint, name, key.Type, key.Line, key.Comment); err != nil {
1030 return fmt.Errorf("identity: create %s: %w", name, err)
1031 }
1032 }
1033 return insertInvite(tx, name, invite, now)
1034}
1035
1036func insertInvite(tx *sql.Tx, name string, invite *pendingInvite, now time.Time) error {
1037 if invite == nil {
1038 return nil
1039 }
1040 if _, err := tx.Exec(`INSERT INTO user_invites(token_hash, user_name, created, expires, created_by) VALUES(?,?,?,?,?)`,
1041 invite.tokenHash, name, now.Unix(), invite.expires.Unix(), invite.createdBy); err != nil {
1042 return fmt.Errorf("identity: create invite: %w", err)
1043 }
1044 return nil
1045}
1046
1047// Retire disables an account. Its keys stop authenticating at the next reload and
1048// its name is never reissued.
1049func (s *Store) Retire(name, actor string) (*Account, error) {
1050 err := s.mutate("identity.retire", actor, name, "", func(tx *sql.Tx) error {
1051 a, err := findTx(tx, name)
1052 if err != nil {
1053 return err
1054 }
1055 if a.Status == "retired" {
1056 return fmt.Errorf("identity: %s is already retired", a.Name)
1057 }
1058 if a.Role.AtLeast(access.Admin) {
1059 var activeAdmins int
1060 if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil {
1061 return err
1062 }
1063 if activeAdmins <= 1 {
1064 return fmt.Errorf("identity: %s is the last instance admin; grant another admin before retiring this one", a.Name)
1065 }
1066 }
1067 if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE name=?`, a.Name); err != nil {
1068 return fmt.Errorf("identity: retire %s: %w", a.Name, err)
1069 }
1070 // Retiring is the one act that must end a browser session immediately.
1071 // The session lookup checks status too, but deleting here also clears the
1072 // rows and any outstanding enrollment links.
1073 if _, err := tx.Exec(`DELETE FROM sessions WHERE user_name=?`, a.Name); err != nil {
1074 return fmt.Errorf("identity: retire %s: %w", a.Name, err)
1075 }
1076 if _, err := tx.Exec(`DELETE FROM user_invites WHERE user_name=?`, a.Name); err != nil {
1077 return fmt.Errorf("identity: retire %s: %w", a.Name, err)
1078 }
1079 return nil
1080 })
1081 if err != nil {
1082 return nil, err
1083 }
1084 return s.Find(name)
1085}
1086
1087// parseKey validates one authorized_keys line and extracts what the roster stores.
1088func parseKey(authorizedKey string) (Key, error) {
1089 line := strings.TrimSpace(authorizedKey)
1090 if line == "" {
1091 return Key{}, errors.New("identity: no public key given")
1092 }
1093 pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
1094 if err != nil {
1095 return Key{}, fmt.Errorf("identity: not an SSH public key: %w", err)
1096 }
1097 return Key{
1098 Fingerprint: ssh.FingerprintSHA256(pub),
1099 Type: pub.Type(),
1100 Line: line,
1101 Comment: comment,
1102 }, nil
1103}
1104
1105// keyOwner returns the account name already holding a fingerprint, or "" when
1106// the key is unclaimed.
1107func keyOwner(tx *sql.Tx, fingerprint string) (string, error) {
1108 var owner string
1109 err := tx.QueryRow(`SELECT user_name FROM user_keys WHERE fingerprint=?`, fingerprint).Scan(&owner)
1110 if errors.Is(err, sql.ErrNoRows) {
1111 return "", nil
1112 }
1113 if err != nil {
1114 return "", fmt.Errorf("identity: read keys: %w", err)
1115 }
1116 return owner, nil
1117}
1118
1119// AddKey attaches an authorized_keys line to an account and returns its
1120// fingerprint. Creation requires a key; this is for adding another one.
1121func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) {
1122 key, err := parseKey(authorizedKey)
1123 if err != nil {
1124 return "", err
1125 }
1126 err = s.mutate("identity.add-key", actor, name, key.Fingerprint+" "+key.Comment, func(tx *sql.Tx) error {
1127 a, err := findTx(tx, name)
1128 if err != nil {
1129 return err
1130 }
1131 owner, err := keyOwner(tx, key.Fingerprint)
1132 if err != nil {
1133 return err
1134 }
1135 switch {
1136 case owner == a.Name:
1137 return fmt.Errorf("identity: %s already has that key", a.Name)
1138 case owner != "":
1139 return fmt.Errorf("identity: that key is already registered to %s", owner)
1140 }
1141 if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_name, type, line, comment) VALUES(?,?,?,?,?)`,
1142 key.Fingerprint, a.Name, key.Type, key.Line, key.Comment); err != nil {
1143 return fmt.Errorf("identity: add key: %w", err)
1144 }
1145 return nil
1146 })
1147 if err != nil {
1148 return "", err
1149 }
1150 return key.Fingerprint, nil
1151}
1152
1153// RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted.
1154func (s *Store) RemoveKey(name, fingerprint, actor string) error {
1155 return s.mutate("identity.remove-key", actor, name, fingerprint, func(tx *sql.Tx) error {
1156 a, err := findTx(tx, name)
1157 if err != nil {
1158 return err
1159 }
1160 rows, err := tx.Query(`SELECT fingerprint FROM user_keys WHERE user_name=?`, a.Name)
1161 if err != nil {
1162 return fmt.Errorf("identity: read keys: %w", err)
1163 }
1164 var matched []string
1165 for rows.Next() {
1166 var fp string
1167 if err := rows.Scan(&fp); err != nil {
1168 rows.Close()
1169 return fmt.Errorf("identity: read keys: %w", err)
1170 }
1171 if fp == fingerprint || strings.HasPrefix(fp, fingerprint) {
1172 matched = append(matched, fp)
1173 }
1174 }
1175 rows.Close()
1176 if err := rows.Err(); err != nil {
1177 return err
1178 }
1179 switch {
1180 case len(matched) == 0:
1181 return fmt.Errorf("identity: %s has no key %s", a.Name, fingerprint)
1182 case len(matched) > 1:
1183 return fmt.Errorf("identity: %q matches %d keys of %s; give more of the fingerprint", fingerprint, len(matched), a.Name)
1184 }
1185 if _, err := tx.Exec(`DELETE FROM user_keys WHERE fingerprint=?`, matched[0]); err != nil {
1186 return fmt.Errorf("identity: remove key: %w", err)
1187 }
1188 return nil
1189 })
1190}
1191
1192// nameOwner returns the account name when it is taken, or "" when it is unused.
1193// Retired accounts count: their row is the reservation that stops a name being
1194// reissued.
1195func nameOwner(tx *sql.Tx, name string) (string, error) {
1196 var owner string
1197 err := tx.QueryRow(`SELECT name FROM users WHERE name=?`, name).Scan(&owner)
1198 if errors.Is(err, sql.ErrNoRows) {
1199 return "", nil
1200 }
1201 if err != nil {
1202 return "", fmt.Errorf("identity: read names: %w", err)
1203 }
1204 return owner, nil
1205}
1206
1207// findTx resolves a name inside a transaction, including a retired one. Callers
1208// that require an active account check the status themselves.
1209func findTx(tx *sql.Tx, name string) (*Account, error) {
1210 var a Account
1211 var created int64
1212 var role string
1213 err := tx.QueryRow(`SELECT name, kind, status, created, instance_role FROM users WHERE name=?`, name).
1214 Scan(&a.Name, &a.Kind, &a.Status, &created, &role)
1215 if errors.Is(err, sql.ErrNoRows) {
1216 return nil, fmt.Errorf("identity: no account %q", name)
1217 }
1218 if err != nil {
1219 return nil, fmt.Errorf("identity: read roster: %w", err)
1220 }
1221 if a.Kind == "" {
1222 a.Kind = KindPerson
1223 }
1224 r, err := access.ParseRole(role)
1225 if err != nil {
1226 return nil, fmt.Errorf("identity: account %s: instance role: %w", a.Name, err)
1227 }
1228 a.Role = r
1229 a.Admin = r.AtLeast(access.Admin)
1230 a.Created = time.Unix(created, 0).UTC()
1231 return &a, nil
1232}
1233
1234// audit appends one line describing a roster change. The database holds the
1235// roster; this file is the only record of who changed it and when, so a failure
1236// to write it is returned to the caller and the change is refused.
1237func (s *Store) audit(action, actor, account, detail string) error {
1238 return audit.Append(s.AuditPath, audit.Record{
1239 Action: action,
1240 Actor: actor,
1241 Subject: account,
1242 Detail: detail,
1243 })
1244}
1245