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.

Blame · forge/identity/identity.go

Line attribution on main · 1244 line(s)

427c4761
Keenan Nemetz
17d ago
1 // Package identity stores the user roster in SQLite.
a5f19b3c
Keenan Nemetz
18d ago
2 //
427c4761
Keenan Nemetz
17d ago
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 //
5b814e99
Keenan Nemetz
14d ago
7 // users one row per account: fixed name, status, instance role, created
6c84d387
Keenan Nemetz
16d ago
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
a5f19b3c
Keenan Nemetz
18d ago
12 //
62576fa8
Keenan Nemetz
15d ago
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.
197ecaa2
Keenan Nemetz
18d ago
20 //
6c84d387
Keenan Nemetz
16d ago
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
62576fa8
Keenan Nemetz
15d ago
23 // same account name, and every authorization decision downstream uses that name
24 // rather than a second internal identifier.
31df8bae
Keenan Nemetz
18d ago
25 //
6c84d387
Keenan Nemetz
16d ago
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
2e024cc9
Keenan Nemetz
14d ago
29 // old copy should be followed by `vole admin account revoke-sessions`. Copy it with
6c84d387
Keenan Nemetz
16d ago
30 // Backup rather than cp: the database runs in WAL mode, so the newest committed
31 // rows may still be in the -wal file.
a5f19b3c
Keenan Nemetz
18d ago
32 package identity
33
34 import (
427c4761
Keenan Nemetz
17d ago
35 "database/sql"
62576fa8
Keenan Nemetz
15d ago
36 "encoding/json"
427c4761
Keenan Nemetz
17d ago
37 "errors"
a5f19b3c
Keenan Nemetz
18d ago
38 "fmt"
39 "os"
40 "path/filepath"
31df8bae
Keenan Nemetz
18d ago
41 "regexp"
a5f19b3c
Keenan Nemetz
18d ago
42 "sort"
43 "strings"
44 "sync/atomic"
45 "time"
46
95425af7
Keenan Nemetz
10d ago
47 _ "github.com/mattn/go-sqlite3"
427c4761
Keenan Nemetz
17d ago
48
a5f19b3c
Keenan Nemetz
18d ago
49 "golang.org/x/crypto/ssh"
d37d301f
Keenan Nemetz
21h ago
50 "rodent.software/vole/forge/access"
51 "rodent.software/vole/internal/audit"
52 "rodent.software/vole/internal/secure"
a5f19b3c
Keenan Nemetz
18d ago
53 )
54
31df8bae
Keenan Nemetz
18d ago
55 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
56
474a812d
Keenan Nemetz
16d ago
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.
31df8bae
Keenan Nemetz
18d ago
60 func 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
a5f19b3c
Keenan Nemetz
18d ago
65 }
66
67 type Key struct {
31df8bae
Keenan Nemetz
18d ago
68 Fingerprint string `json:"fingerprint"`
69 Type string `json:"type"`
70 Line string `json:"line"`
71 Comment string `json:"comment,omitempty"`
72 }
73
29a795b9
Keenan Nemetz
7d ago
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.
79 const (
80 KindPerson = "person"
81 KindBot = "bot"
82 )
83
84 // ValidKind reports whether a value is an account kind.
85 func 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
31df8bae
Keenan Nemetz
18d ago
93 type Account struct {
29a795b9
Keenan Nemetz
7d ago
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
5b814e99
Keenan Nemetz
14d ago
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
3abbe749
Keenan Nemetz
16d ago
105 Admin bool
31df8bae
Keenan Nemetz
18d ago
106 Created time.Time
107 }
108
109 func (a *Account) Active() bool { return a.Status == "active" }
110
29a795b9
Keenan Nemetz
7d ago
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.
113 func (a *Account) Bot() bool { return a.Kind == KindBot }
114
197ecaa2
Keenan Nemetz
18d ago
115 // Snapshot is an immutable view of the roster, swapped atomically so the SSH
31df8bae
Keenan Nemetz
18d ago
116 // auth callback stays a map lookup.
a5f19b3c
Keenan Nemetz
18d ago
117 type Snapshot struct {
62576fa8
Keenan Nemetz
15d ago
118 // ByKey maps a key fingerprint to an active account name. Retired accounts
119 // are absent, so revoking is immediate.
31df8bae
Keenan Nemetz
18d ago
120 ByKey map[string]string
474a812d
Keenan Nemetz
16d ago
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.
31df8bae
Keenan Nemetz
18d ago
123 ByName map[string]*Account
124
125 Count int // active accounts
a5f19b3c
Keenan Nemetz
18d ago
126 }
127
128 func (s *Snapshot) Lookup(fingerprint string) (string, bool) {
129 if s == nil {
130 return "", false
131 }
62576fa8
Keenan Nemetz
15d ago
132 name, ok := s.ByKey[fingerprint]
133 return name, ok
a5f19b3c
Keenan Nemetz
18d ago
134 }
135
136 type Store struct {
b1499c72
Keenan Nemetz
13d ago
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.
197ecaa2
Keenan Nemetz
18d ago
139 AuditPath string
427c4761
Keenan Nemetz
17d ago
140 db *sql.DB
141 path string
197ecaa2
Keenan Nemetz
18d ago
142 snap atomic.Pointer[Snapshot]
a5f19b3c
Keenan Nemetz
18d ago
143 }
144
427c4761
Keenan Nemetz
17d ago
145 const schema = `
146 CREATE TABLE IF NOT EXISTS users (
5b814e99
Keenan Nemetz
14d ago
147 name TEXT PRIMARY KEY,
29a795b9
Keenan Nemetz
7d ago
148 kind TEXT NOT NULL DEFAULT 'person' CHECK (kind IN ('person','bot')),
5b814e99
Keenan Nemetz
14d ago
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'
427c4761
Keenan Nemetz
17d ago
152 );
153 CREATE TABLE IF NOT EXISTS user_keys (
154 fingerprint TEXT PRIMARY KEY,
62576fa8
Keenan Nemetz
15d ago
155 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
427c4761
Keenan Nemetz
17d ago
156 type TEXT NOT NULL,
157 line TEXT NOT NULL,
158 comment TEXT NOT NULL DEFAULT ''
159 );
62576fa8
Keenan Nemetz
15d ago
160 CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_name);
6c84d387
Keenan Nemetz
16d ago
161 CREATE TABLE IF NOT EXISTS user_credentials (
162 credential_id TEXT PRIMARY KEY,
62576fa8
Keenan Nemetz
15d ago
163 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
6c84d387
Keenan Nemetz
16d ago
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 );
62576fa8
Keenan Nemetz
15d ago
176 CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_name);
6c84d387
Keenan Nemetz
16d ago
177 CREATE TABLE IF NOT EXISTS user_invites (
178 token_hash TEXT PRIMARY KEY,
62576fa8
Keenan Nemetz
15d ago
179 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
6c84d387
Keenan Nemetz
16d ago
180 created INTEGER NOT NULL,
181 expires INTEGER NOT NULL,
182 used_at INTEGER,
183 created_by TEXT NOT NULL DEFAULT ''
184 );
62576fa8
Keenan Nemetz
15d ago
185 CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_name);
6c84d387
Keenan Nemetz
16d ago
186 CREATE TABLE IF NOT EXISTS sessions (
187 token_hash TEXT PRIMARY KEY,
62576fa8
Keenan Nemetz
15d ago
188 user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE,
6c84d387
Keenan Nemetz
16d ago
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 );
62576fa8
Keenan Nemetz
15d ago
195 CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_name);
6c84d387
Keenan Nemetz
16d ago
196 CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);
427c4761
Keenan Nemetz
17d ago
197 `
198
199 func openDB(path string) (*sql.DB, error) {
200 // _txlock=immediate makes every write transaction take the write lock up
2e024cc9
Keenan Nemetz
14d ago
201 // front, so a second `vole admin account` process waits on busy_timeout instead of
427c4761
Keenan Nemetz
17d ago
202 // failing when it tries to upgrade a read lock mid-transaction.
95425af7
Keenan Nemetz
10d ago
203 db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=1&_txlock=immediate")
427c4761
Keenan Nemetz
17d ago
204 if err != nil {
205 return nil, err
31df8bae
Keenan Nemetz
18d ago
206 }
427c4761
Keenan Nemetz
17d ago
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.
213 func Init(path string) error {
ed181212
Keenan Nemetz
13d ago
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 {
31df8bae
Keenan Nemetz
18d ago
217 return err
218 }
427c4761
Keenan Nemetz
17d ago
219 db, err := openDB(path)
220 if err != nil {
221 return fmt.Errorf("identity: %w", err)
222 }
223 defer db.Close()
474a812d
Keenan Nemetz
16d ago
224 if err := migrate(db); err != nil {
225 return fmt.Errorf("identity: migrate roster: %w", err)
226 }
5b814e99
Keenan Nemetz
14d ago
227 if err := migrateInstanceRoles(db); err != nil {
228 return fmt.Errorf("identity: migrate instance roles: %w", err)
229 }
29a795b9
Keenan Nemetz
7d ago
230 if err := migrateAccountKind(db); err != nil {
231 return fmt.Errorf("identity: migrate account kind: %w", err)
232 }
62576fa8
Keenan Nemetz
15d ago
233 if _, err := db.Exec(schema); err != nil {
234 return fmt.Errorf("identity: create roster: %w", err)
235 }
31df8bae
Keenan Nemetz
18d ago
236 return nil
237 }
a5f19b3c
Keenan Nemetz
18d ago
238
474a812d
Keenan Nemetz
16d ago
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.
62576fa8
Keenan Nemetz
15d ago
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.
474a812d
Keenan Nemetz
16d ago
247 func migrate(db *sql.DB) error {
62576fa8
Keenan Nemetz
15d ago
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(`
281 CREATE TABLE users_new (
5b814e99
Keenan Nemetz
14d ago
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'
62576fa8
Keenan Nemetz
15d ago
286 );
287 CREATE 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 );
294 CREATE TABLE instance_admins_new (
295 user_name TEXT PRIMARY KEY REFERENCES users_new(name) ON DELETE CASCADE
296 );
297 CREATE 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 );
312 CREATE 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 );
320 CREATE 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)
336 SELECT 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)
340 SELECT 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(
344 credential_id, user_name, public_key, attestation_type, transports, flags, aaguid,
345 sign_count, clone_warning, attachment, label, created, last_used)
346 SELECT c.credential_id, u.name, c.public_key, c.attestation_type, c.transports, c.flags, c.aaguid,
347 c.sign_count, c.clone_warning, c.attachment, c.label, c.created, c.last_used
348 FROM 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)
352 SELECT i.token_hash, u.name, i.created, i.expires, i.used_at, i.created_by
353 FROM 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)
357 SELECT s.token_hash, u.name, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent
358 FROM 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(`
388 CREATE INDEX user_keys_owner ON user_keys(user_name);
389 CREATE INDEX user_credentials_owner ON user_credentials(user_name);
390 CREATE INDEX user_invites_owner ON user_invites(user_name);
391 CREATE INDEX sessions_user ON sessions(user_name);
392 CREATE INDEX sessions_expiry ON sessions(expires);`); err != nil {
393 return err
394 }
395 return tx.Commit()
396 }
397
29a795b9
Keenan Nemetz
7d ago
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.
401 func 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
5b814e99
Keenan Nemetz
14d ago
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.
422 func 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'
447 WHERE 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
62576fa8
Keenan Nemetz
15d ago
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.
457 func 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 {
474a812d
Keenan Nemetz
16d ago
465 return err
466 }
62576fa8
Keenan Nemetz
15d ago
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;
475 CREATE TABLE _user_id_map(old_id TEXT PRIMARY KEY, name TEXT NOT NULL);
476 INSERT 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)
521 WHERE 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.
532 func 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
568 func 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
574 func 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
599 func 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)
474a812d
Keenan Nemetz
16d ago
608 return err
609 }
610
31df8bae
Keenan Nemetz
18d ago
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.
427c4761
Keenan Nemetz
17d ago
614 func Open(path string) (*Store, error) {
615 if _, err := os.Stat(path); err != nil {
3abbe749
Keenan Nemetz
16d ago
616 return nil, fmt.Errorf("identity: %s is missing; run vole admin init", path)
a5f19b3c
Keenan Nemetz
18d ago
617 }
427c4761
Keenan Nemetz
17d ago
618 db, err := openDB(path)
619 if err != nil {
620 return nil, fmt.Errorf("identity: %w", err)
621 }
3abbe749
Keenan Nemetz
16d ago
622 // Applying the schema here keeps an existing roster usable when a new table
62576fa8
Keenan Nemetz
15d ago
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 {
3abbe749
Keenan Nemetz
16d ago
626 db.Close()
627 return nil, fmt.Errorf("identity: %w", err)
628 }
5b814e99
Keenan Nemetz
14d ago
629 if err := migrateInstanceRoles(db); err != nil {
630 db.Close()
631 return nil, fmt.Errorf("identity: %w", err)
632 }
29a795b9
Keenan Nemetz
7d ago
633 if err := migrateAccountKind(db); err != nil {
634 db.Close()
635 return nil, fmt.Errorf("identity: %w", err)
636 }
62576fa8
Keenan Nemetz
15d ago
637 if _, err := db.Exec(schema); err != nil {
474a812d
Keenan Nemetz
16d ago
638 db.Close()
639 return nil, fmt.Errorf("identity: %w", err)
640 }
427c4761
Keenan Nemetz
17d ago
641 s := &Store{db: db, path: path}
a5f19b3c
Keenan Nemetz
18d ago
642 if err := s.Reload(); err != nil {
427c4761
Keenan Nemetz
17d ago
643 db.Close()
a5f19b3c
Keenan Nemetz
18d ago
644 return nil, err
645 }
646 return s, nil
647 }
648
427c4761
Keenan Nemetz
17d ago
649 func (s *Store) Close() error { return s.db.Close() }
650
31df8bae
Keenan Nemetz
18d ago
651 func (s *Store) Snapshot() *Snapshot { return s.snap.Load() }
652
427c4761
Keenan Nemetz
17d ago
653 // Path is the roster database's path.
654 func (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.
659 func (s *Store) Backup(dst string) error {
660 if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
661 return err
662 }
ed181212
Keenan Nemetz
13d ago
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)
427c4761
Keenan Nemetz
17d ago
669 return err
670 }
31df8bae
Keenan Nemetz
18d ago
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.
a5f19b3c
Keenan Nemetz
18d ago
674 func (s *Store) Reload() error {
427c4761
Keenan Nemetz
17d ago
675 snap, err := s.load()
31df8bae
Keenan Nemetz
18d ago
676 if err != nil {
677 return err
678 }
679 s.snap.Store(snap)
680 return nil
681 }
682
427c4761
Keenan Nemetz
17d ago
683 func (s *Store) load() (*Snapshot, error) {
31df8bae
Keenan Nemetz
18d ago
684 snap := &Snapshot{
474a812d
Keenan Nemetz
16d ago
685 ByKey: map[string]string{},
686 ByName: map[string]*Account{},
31df8bae
Keenan Nemetz
18d ago
687 }
29a795b9
Keenan Nemetz
7d ago
688 rows, err := s.db.Query(`SELECT name, kind, status, created, instance_role FROM users ORDER BY name`)
427c4761
Keenan Nemetz
17d ago
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
5b814e99
Keenan Nemetz
14d ago
695 var status, role string
427c4761
Keenan Nemetz
17d ago
696 var created int64
29a795b9
Keenan Nemetz
7d ago
697 if err := rows.Scan(&a.Name, &a.Kind, &status, &created, &role); err != nil {
427c4761
Keenan Nemetz
17d ago
698 return nil, fmt.Errorf("identity: read roster: %w", err)
699 }
29a795b9
Keenan Nemetz
7d ago
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 }
427c4761
Keenan Nemetz
17d ago
706 if err := ValidateName(a.Name); err != nil {
62576fa8
Keenan Nemetz
15d ago
707 return nil, fmt.Errorf("identity: account %s: %w", a.Name, err)
474a812d
Keenan Nemetz
16d ago
708 }
427c4761
Keenan Nemetz
17d ago
709 switch status {
710 case "", "active":
711 status = "active"
712 case "retired":
713 default:
62576fa8
Keenan Nemetz
15d ago
714 return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.Name)
31df8bae
Keenan Nemetz
18d ago
715 }
5b814e99
Keenan Nemetz
14d ago
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 }
427c4761
Keenan Nemetz
17d ago
720 a.Status = status
5b814e99
Keenan Nemetz
14d ago
721 a.Role = r
722 a.Admin = r.AtLeast(access.Admin)
427c4761
Keenan Nemetz
17d ago
723 a.Created = time.Unix(created, 0).UTC()
62576fa8
Keenan Nemetz
15d ago
724 snap.ByName[a.Name] = &a
427c4761
Keenan Nemetz
17d ago
725 }
726 if err := rows.Err(); err != nil {
727 return nil, err
31df8bae
Keenan Nemetz
18d ago
728 }
427c4761
Keenan Nemetz
17d ago
729
62576fa8
Keenan Nemetz
15d ago
730 keys, err := s.db.Query(`SELECT fingerprint, user_name, type, line, comment FROM user_keys ORDER BY fingerprint`)
427c4761
Keenan Nemetz
17d ago
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 }
62576fa8
Keenan Nemetz
15d ago
741 a, ok := snap.ByName[owner]
427c4761
Keenan Nemetz
17d ago
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)
a5f19b3c
Keenan Nemetz
18d ago
747 }
427c4761
Keenan Nemetz
17d ago
748 a.Keys = append(a.Keys, k)
31df8bae
Keenan Nemetz
18d ago
749 if a.Active() {
427c4761
Keenan Nemetz
17d ago
750 snap.ByKey[k.Fingerprint] = owner
31df8bae
Keenan Nemetz
18d ago
751 }
427c4761
Keenan Nemetz
17d ago
752 }
753 if err := keys.Err(); err != nil {
754 return nil, err
755 }
756
62576fa8
Keenan Nemetz
15d ago
757 for _, a := range snap.ByName {
427c4761
Keenan Nemetz
17d ago
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++
31df8bae
Keenan Nemetz
18d ago
761 }
a5f19b3c
Keenan Nemetz
18d ago
762 }
31df8bae
Keenan Nemetz
18d ago
763 return snap, nil
a5f19b3c
Keenan Nemetz
18d ago
764 }
765
5b814e99
Keenan Nemetz
14d ago
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.
768 func (s *Store) InstanceRole(name string) access.Role {
3abbe749
Keenan Nemetz
16d ago
769 snap := s.Snapshot()
770 if snap == nil {
5b814e99
Keenan Nemetz
14d ago
771 return access.None
3abbe749
Keenan Nemetz
16d ago
772 }
62576fa8
Keenan Nemetz
15d ago
773 a, ok := snap.ByName[name]
5b814e99
Keenan Nemetz
14d ago
774 if !ok || !a.Active() {
775 return access.None
776 }
777 return a.Role
3abbe749
Keenan Nemetz
16d ago
778 }
779
62b59a76
Keenan Nemetz
13d ago
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.
783 func (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
5b814e99
Keenan Nemetz
14d ago
792 // IsAdmin reports whether an active account has the instance-wide Admin role,
793 // which is also the remote administration API's gate.
794 func (s *Store) IsAdmin(name string) bool {
795 return s.InstanceRole(name).AtLeast(access.Admin)
796 }
797
798 func (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)
3abbe749
Keenan Nemetz
16d ago
801 }
5b814e99
Keenan Nemetz
14d ago
802 err := s.mutate(action, actor, name, detail, func(tx *sql.Tx) error {
62576fa8
Keenan Nemetz
15d ago
803 a, err := findTx(tx, name)
3abbe749
Keenan Nemetz
16d ago
804 if err != nil {
805 return err
806 }
807 if !a.Active() {
808 return fmt.Errorf("identity: %s is retired", a.Name)
809 }
29a795b9
Keenan Nemetz
7d ago
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 }
5b814e99
Keenan Nemetz
14d ago
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 }
3abbe749
Keenan Nemetz
16d ago
821 }
5b814e99
Keenan Nemetz
14d ago
822 _, err = tx.Exec(`UPDATE users SET instance_role=? WHERE name=?`, role.String(), a.Name)
3abbe749
Keenan Nemetz
16d ago
823 return err
824 })
825 if err != nil {
826 return nil, err
827 }
62576fa8
Keenan Nemetz
15d ago
828 return s.Find(name)
3abbe749
Keenan Nemetz
16d ago
829 }
830
5b814e99
Keenan Nemetz
14d ago
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.
833 func (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.
839 func (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
62576fa8
Keenan Nemetz
15d ago
853 // Lookup resolves a key fingerprint to an active account name.
854 func (s *Store) Lookup(fingerprint string) (name string, ok bool) {
855 return s.Snapshot().Lookup(fingerprint)
31df8bae
Keenan Nemetz
18d ago
856 }
a5f19b3c
Keenan Nemetz
18d ago
857
62576fa8
Keenan Nemetz
15d ago
858 // Find resolves a name, including the name of a retired account.
859 func (s *Store) Find(name string) (*Account, error) {
860 if a, ok := s.Snapshot().ByName[name]; ok {
31df8bae
Keenan Nemetz
18d ago
861 return a, nil
862 }
62576fa8
Keenan Nemetz
15d ago
863 return nil, fmt.Errorf("identity: no account %q", name)
31df8bae
Keenan Nemetz
18d ago
864 }
865
866 // All returns every account, active first, then by name.
867 func (s *Store) All() []*Account {
868 snap := s.Snapshot()
62576fa8
Keenan Nemetz
15d ago
869 out := make([]*Account, 0, len(snap.ByName))
870 for _, a := range snap.ByName {
31df8bae
Keenan Nemetz
18d ago
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
a5f19b3c
Keenan Nemetz
18d ago
880 }
881
62576fa8
Keenan Nemetz
15d ago
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.
884 func (s *Store) Roster() []string {
885 var out []string
427c4761
Keenan Nemetz
17d ago
886 for _, a := range s.All() {
887 if a.Active() {
62576fa8
Keenan Nemetz
15d ago
888 out = append(out, a.Name)
427c4761
Keenan Nemetz
17d ago
889 }
890 }
891 return out
892 }
893
31df8bae
Keenan Nemetz
18d ago
894 // ---------- mutations ----------
895 //
427c4761
Keenan Nemetz
17d ago
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.
197ecaa2
Keenan Nemetz
18d ago
899
3abbe749
Keenan Nemetz
16d ago
900 func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx) error) error {
427c4761
Keenan Nemetz
17d ago
901 tx, err := s.db.Begin()
31df8bae
Keenan Nemetz
18d ago
902 if err != nil {
427c4761
Keenan Nemetz
17d ago
903 return fmt.Errorf("identity: %w", err)
904 }
905 defer tx.Rollback()
906 if err := fn(tx); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
907 return err
908 }
b1499c72
Keenan Nemetz
13d ago
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 }
427c4761
Keenan Nemetz
17d ago
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
31df8bae
Keenan Nemetz
18d ago
922 }
923
62576fa8
Keenan Nemetz
15d ago
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.
6c84d387
Keenan Nemetz
16d ago
927 //
928 // An account that will use a passkey instead is created with CreateWithInvite;
929 // the invite is the credential-shaped way in.
62576fa8
Keenan Nemetz
15d ago
930 func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) {
5b814e99
Keenan Nemetz
14d ago
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.
937 func (s *Store) CreateWithRole(name string, role access.Role, authorizedKey, actor string) (*Account, error) {
31df8bae
Keenan Nemetz
18d ago
938 if err := ValidateName(name); err != nil {
939 return nil, err
a5f19b3c
Keenan Nemetz
18d ago
940 }
c36d5073
Keenan Nemetz
16d ago
941 key, err := parseKey(authorizedKey)
942 if err != nil {
943 return nil, err
944 }
427c4761
Keenan Nemetz
17d ago
945 now := time.Now().UTC()
5b814e99
Keenan Nemetz
14d ago
946 err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint+" role="+role.String(), func(tx *sql.Tx) error {
29a795b9
Keenan Nemetz
7d ago
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.
964 func (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)
6c84d387
Keenan Nemetz
16d ago
983 })
984 if err != nil {
985 return nil, err
986 }
62576fa8
Keenan Nemetz
15d ago
987 return s.Find(name)
6c84d387
Keenan Nemetz
16d ago
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.
992 type pendingInvite struct {
993 tokenHash string
994 expires time.Time
995 createdBy string
996 }
997
29a795b9
Keenan Nemetz
7d ago
998 func createAccount(tx *sql.Tx, name, kind string, role access.Role, now time.Time, key *Key, invite *pendingInvite) error {
5b814e99
Keenan Nemetz
14d ago
999 if role < access.None || role > access.Admin {
1000 return fmt.Errorf("identity: invalid role %d", role)
1001 }
29a795b9
Keenan Nemetz
7d ago
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 }
474a812d
Keenan Nemetz
16d ago
1011 if owner, err := nameOwner(tx, name); err != nil {
6c84d387
Keenan Nemetz
16d ago
1012 return err
1013 } else if owner != "" {
474a812d
Keenan Nemetz
16d ago
1014 return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a name is never handed out twice", name, owner)
6c84d387
Keenan Nemetz
16d ago
1015 }
1016 if key != nil {
c36d5073
Keenan Nemetz
16d ago
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 }
6c84d387
Keenan Nemetz
16d ago
1022 }
29a795b9
Keenan Nemetz
7d ago
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 {
6c84d387
Keenan Nemetz
16d ago
1025 return fmt.Errorf("identity: create %s: %w", name, err)
1026 }
1027 if key != nil {
62576fa8
Keenan Nemetz
15d ago
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 {
c36d5073
Keenan Nemetz
16d ago
1030 return fmt.Errorf("identity: create %s: %w", name, err)
1031 }
6c84d387
Keenan Nemetz
16d ago
1032 }
62576fa8
Keenan Nemetz
15d ago
1033 return insertInvite(tx, name, invite, now)
6c84d387
Keenan Nemetz
16d ago
1034 }
1035
62576fa8
Keenan Nemetz
15d ago
1036 func insertInvite(tx *sql.Tx, name string, invite *pendingInvite, now time.Time) error {
6c84d387
Keenan Nemetz
16d ago
1037 if invite == nil {
31df8bae
Keenan Nemetz
18d ago
1038 return nil
1039 }
62576fa8
Keenan Nemetz
15d ago
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 {
6c84d387
Keenan Nemetz
16d ago
1042 return fmt.Errorf("identity: create invite: %w", err)
1043 }
1044 return nil
31df8bae
Keenan Nemetz
18d ago
1045 }
1046
1047 // Retire disables an account. Its keys stop authenticating at the next reload and
1048 // its name is never reissued.
62576fa8
Keenan Nemetz
15d ago
1049 func (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)
31df8bae
Keenan Nemetz
18d ago
1052 if err != nil {
1053 return err
1054 }
427c4761
Keenan Nemetz
17d ago
1055 if a.Status == "retired" {
1056 return fmt.Errorf("identity: %s is already retired", a.Name)
31df8bae
Keenan Nemetz
18d ago
1057 }
5b814e99
Keenan Nemetz
14d ago
1058 if a.Role.AtLeast(access.Admin) {
3abbe749
Keenan Nemetz
16d ago
1059 var activeAdmins int
5b814e99
Keenan Nemetz
14d ago
1060 if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil {
3abbe749
Keenan Nemetz
16d ago
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 }
62576fa8
Keenan Nemetz
15d ago
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)
427c4761
Keenan Nemetz
17d ago
1069 }
6c84d387
Keenan Nemetz
16d ago
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.
62576fa8
Keenan Nemetz
15d ago
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)
6c84d387
Keenan Nemetz
16d ago
1075 }
62576fa8
Keenan Nemetz
15d ago
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)
6c84d387
Keenan Nemetz
16d ago
1078 }
31df8bae
Keenan Nemetz
18d ago
1079 return nil
1080 })
1081 if err != nil {
1082 return nil, err
a5f19b3c
Keenan Nemetz
18d ago
1083 }
62576fa8
Keenan Nemetz
15d ago
1084 return s.Find(name)
a5f19b3c
Keenan Nemetz
18d ago
1085 }
1086
c36d5073
Keenan Nemetz
16d ago
1087 // parseKey validates one authorized_keys line and extracts what the roster stores.
1088 func parseKey(authorizedKey string) (Key, error) {
197ecaa2
Keenan Nemetz
18d ago
1089 line := strings.TrimSpace(authorizedKey)
c36d5073
Keenan Nemetz
16d ago
1090 if line == "" {
1091 return Key{}, errors.New("identity: no public key given")
1092 }
197ecaa2
Keenan Nemetz
18d ago
1093 pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
a5f19b3c
Keenan Nemetz
18d ago
1094 if err != nil {
c36d5073
Keenan Nemetz
16d ago
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.
1107 func keyOwner(tx *sql.Tx, fingerprint string) (string, error) {
1108 var owner string
62576fa8
Keenan Nemetz
15d ago
1109 err := tx.QueryRow(`SELECT user_name FROM user_keys WHERE fingerprint=?`, fingerprint).Scan(&owner)
c36d5073
Keenan Nemetz
16d ago
1110 if errors.Is(err, sql.ErrNoRows) {
1111 return "", nil
a5f19b3c
Keenan Nemetz
18d ago
1112 }
c36d5073
Keenan Nemetz
16d ago
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.
62576fa8
Keenan Nemetz
15d ago
1121 func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) {
c36d5073
Keenan Nemetz
16d ago
1122 key, err := parseKey(authorizedKey)
1123 if err != nil {
1124 return "", err
1125 }
62576fa8
Keenan Nemetz
15d ago
1126 err = s.mutate("identity.add-key", actor, name, key.Fingerprint+" "+key.Comment, func(tx *sql.Tx) error {
1127 a, err := findTx(tx, name)
31df8bae
Keenan Nemetz
18d ago
1128 if err != nil {
1129 return err
1130 }
c36d5073
Keenan Nemetz
16d ago
1131 owner, err := keyOwner(tx, key.Fingerprint)
1132 if err != nil {
1133 return err
1134 }
427c4761
Keenan Nemetz
17d ago
1135 switch {
c36d5073
Keenan Nemetz
16d ago
1136 case owner == a.Name:
427c4761
Keenan Nemetz
17d ago
1137 return fmt.Errorf("identity: %s already has that key", a.Name)
c36d5073
Keenan Nemetz
16d ago
1138 case owner != "":
31df8bae
Keenan Nemetz
18d ago
1139 return fmt.Errorf("identity: that key is already registered to %s", owner)
1140 }
62576fa8
Keenan Nemetz
15d ago
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 {
427c4761
Keenan Nemetz
17d ago
1143 return fmt.Errorf("identity: add key: %w", err)
31df8bae
Keenan Nemetz
18d ago
1144 }
1145 return nil
1146 })
a5f19b3c
Keenan Nemetz
18d ago
1147 if err != nil {
1148 return "", err
1149 }
c36d5073
Keenan Nemetz
16d ago
1150 return key.Fingerprint, nil
a5f19b3c
Keenan Nemetz
18d ago
1151 }
1152
31df8bae
Keenan Nemetz
18d ago
1153 // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted.
62576fa8
Keenan Nemetz
15d ago
1154 func (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)
31df8bae
Keenan Nemetz
18d ago
1157 if err != nil {
1158 return err
a5f19b3c
Keenan Nemetz
18d ago
1159 }
62576fa8
Keenan Nemetz
15d ago
1160 rows, err := tx.Query(`SELECT fingerprint FROM user_keys WHERE user_name=?`, a.Name)
427c4761
Keenan Nemetz
17d ago
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)
197ecaa2
Keenan Nemetz
18d ago
1170 }
427c4761
Keenan Nemetz
17d ago
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
a5f19b3c
Keenan Nemetz
18d ago
1178 }
31df8bae
Keenan Nemetz
18d ago
1179 switch {
427c4761
Keenan Nemetz
17d ago
1180 case len(matched) == 0:
31df8bae
Keenan Nemetz
18d ago
1181 return fmt.Errorf("identity: %s has no key %s", a.Name, fingerprint)
427c4761
Keenan Nemetz
17d ago
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)
31df8bae
Keenan Nemetz
18d ago
1184 }
427c4761
Keenan Nemetz
17d ago
1185 if _, err := tx.Exec(`DELETE FROM user_keys WHERE fingerprint=?`, matched[0]); err != nil {
1186 return fmt.Errorf("identity: remove key: %w", err)
31df8bae
Keenan Nemetz
18d ago
1187 }
1188 return nil
1189 })
a5f19b3c
Keenan Nemetz
18d ago
1190 }
1191
62576fa8
Keenan Nemetz
15d ago
1192 // nameOwner returns the account name when it is taken, or "" when it is unused.
474a812d
Keenan Nemetz
16d ago
1193 // Retired accounts count: their row is the reservation that stops a name being
1194 // reissued.
1195 func nameOwner(tx *sql.Tx, name string) (string, error) {
427c4761
Keenan Nemetz
17d ago
1196 var owner string
62576fa8
Keenan Nemetz
15d ago
1197 err := tx.QueryRow(`SELECT name FROM users WHERE name=?`, name).Scan(&owner)
427c4761
Keenan Nemetz
17d ago
1198 if errors.Is(err, sql.ErrNoRows) {
1199 return "", nil
a5f19b3c
Keenan Nemetz
18d ago
1200 }
427c4761
Keenan Nemetz
17d ago
1201 if err != nil {
1202 return "", fmt.Errorf("identity: read names: %w", err)
a5f19b3c
Keenan Nemetz
18d ago
1203 }
427c4761
Keenan Nemetz
17d ago
1204 return owner, nil
31df8bae
Keenan Nemetz
18d ago
1205 }
1206
62576fa8
Keenan Nemetz
15d ago
1207 // findTx resolves a name inside a transaction, including a retired one. Callers
1208 // that require an active account check the status themselves.
1209 func findTx(tx *sql.Tx, name string) (*Account, error) {
427c4761
Keenan Nemetz
17d ago
1210 var a Account
1211 var created int64
5b814e99
Keenan Nemetz
14d ago
1212 var role string
29a795b9
Keenan Nemetz
7d ago
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)
427c4761
Keenan Nemetz
17d ago
1215 if errors.Is(err, sql.ErrNoRows) {
62576fa8
Keenan Nemetz
15d ago
1216 return nil, fmt.Errorf("identity: no account %q", name)
427c4761
Keenan Nemetz
17d ago
1217 }
31df8bae
Keenan Nemetz
18d ago
1218 if err != nil {
427c4761
Keenan Nemetz
17d ago
1219 return nil, fmt.Errorf("identity: read roster: %w", err)
a5f19b3c
Keenan Nemetz
18d ago
1220 }
29a795b9
Keenan Nemetz
7d ago
1221 if a.Kind == "" {
1222 a.Kind = KindPerson
1223 }
5b814e99
Keenan Nemetz
14d ago
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)
427c4761
Keenan Nemetz
17d ago
1230 a.Created = time.Unix(created, 0).UTC()
1231 return &a, nil
a5f19b3c
Keenan Nemetz
18d ago
1232 }
1233
427c4761
Keenan Nemetz
17d ago
1234 // audit appends one line describing a roster change. The database holds the
b1499c72
Keenan Nemetz
13d ago
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.
1237 func (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 })
a5f19b3c
Keenan Nemetz
18d ago
1244 }