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.
| 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. |
| 32 | package identity |
| 33 | |
| 34 | import ( |
| 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 | |
| 55 | var 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. |
| 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 |
| 65 | } |
| 66 | |
| 67 | type 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. |
| 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 | |
| 93 | type 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 | |
| 109 | func (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. |
| 113 | func (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. |
| 117 | type 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 | |
| 128 | func (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 | |
| 136 | type 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 | |
| 145 | const schema = ` |
| 146 | CREATE 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 | ); |
| 153 | CREATE 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 | ); |
| 160 | CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_name); |
| 161 | CREATE 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 | ); |
| 176 | CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_name); |
| 177 | CREATE 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 | ); |
| 185 | CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_name); |
| 186 | CREATE 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 | ); |
| 195 | CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_name); |
| 196 | CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires); |
| 197 | ` |
| 198 | |
| 199 | func 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. |
| 213 | func 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. |
| 247 | func 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(` |
| 281 | CREATE 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 | ); |
| 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 | |
| 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 | |
| 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 | |
| 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 { |
| 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; |
| 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, ¬null, &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) |
| 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. |
| 614 | func 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 | |
| 649 | func (s *Store) Close() error { return s.db.Close() } |
| 650 | |
| 651 | func (s *Store) Snapshot() *Snapshot { return s.snap.Load() } |
| 652 | |
| 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 | } |
| 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. |
| 674 | func (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 | |
| 683 | func (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. |
| 768 | func (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. |
| 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 | |
| 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) |
| 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. |
| 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 | |
| 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) |
| 856 | } |
| 857 | |
| 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 { |
| 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. |
| 867 | func (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. |
| 884 | func (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 | |
| 900 | func (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. |
| 930 | func (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. |
| 937 | func (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. |
| 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) |
| 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. |
| 992 | type pendingInvite struct { |
| 993 | tokenHash string |
| 994 | expires time.Time |
| 995 | createdBy string |
| 996 | } |
| 997 | |
| 998 | func 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 | |
| 1036 | func 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. |
| 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) |
| 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. |
| 1088 | func 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. |
| 1107 | func 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. |
| 1121 | func (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. |
| 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) |
| 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. |
| 1195 | func 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. |
| 1209 | func 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. |
| 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 | }) |
| 1244 | } |
| 1245 |