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.
Identify accounts by their registered name
62576fa8157d431dcf2d360f3ebe481de77bda03 · Keenan Nemetz · 15d ago · parent 642e5994
Identify accounts by their registered name Drop the internal account id and display name. Users, keys, passkeys, invites, sessions, ACL entries, approvals, locks and CI records now all use the registered name as the single identity. The roster migrates in place: old id-keyed tables are rebuilt around the name, and access-rule/lock references are rewritten through the same mapping. Display-name editing and whoami --id are removed.
forge/identity/identity.go +425−266
| @@ -4,27 +4,24 @@ | |||
| 4 | 4 | // the CI queue (runs.db). A roster is small and written by a handful of operator | |
| 5 | 5 | // commands, so the whole thing is a handful of rows: | |
| 6 | 6 | // | |
| 7 | // users one row per account: stable id, fixed name, display, status, created | ||
| 7 | // users one row per account: fixed name, status, created | ||
| 8 | 8 | // user_keys one row per authorized SSH key, unique by fingerprint | |
| 9 | 9 | // user_credentials one row per passkey: the browser credential's public half | |
| 10 | 10 | // user_invites one-time enrollment tokens, stored only as hashes | |
| 11 | 11 | // sessions browser sessions, stored only as token hashes | |
| 12 | 12 | // | |
| 13 | // An account has a stable id and a name that is fixed the moment it is created. | ||
| 14 | // The name is what contributor branches and metadata authors are written in, so | ||
| 15 | // it can never move: a rename would strand every ref under the old folder. The | ||
| 16 | // id is what ACL entries and approval files are keyed by. Because the users row | ||
| 17 | // is kept after retirement, the UNIQUE constraint on the name stops a retired | ||
| 18 | // name being handed to a second account — which is what stops a new user from | ||
| 19 | // inheriting an old one's access and approvals. | ||
| 20 | // | ||
| 21 | // A display name is optional and mutable. It is a label for people, never an | ||
| 22 | // identity: nothing is authorized by it and nothing on disk is keyed by it. | ||
| 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. | ||
| 23 | 20 | // | |
| 24 | 21 | // The credentials anchor the account. An SSH key is resolved at the SSH | |
| 25 | 22 | // handshake; a passkey is resolved in the browser login ceremony. Both yield the | |
| 26 | // same account id, and every authorization decision downstream uses that id | ||
| 27 | // rather than a name somebody chose to type. | ||
| 23 | // same account name, and every authorization decision downstream uses that name | ||
| 24 | // rather than a second internal identifier. | ||
| 28 | 25 | // | |
| 29 | 26 | // Nothing stored here is a usable secret: public keys and public credential | |
| 30 | 27 | // halves, plus hashes of session and invite tokens. The database file can still | |
| @@ -36,6 +33,7 @@ package identity | |||
| 36 | 33 | ||
| 37 | 34 | import ( | |
| 38 | 35 | "database/sql" | |
| 36 | "encoding/json" | ||
| 39 | 37 | "errors" | |
| 40 | 38 | "fmt" | |
| 41 | 39 | "os" | |
| @@ -45,12 +43,9 @@ import ( | |||
| 45 | 43 | "strings" | |
| 46 | 44 | "sync/atomic" | |
| 47 | 45 | "time" | |
| 48 | "unicode" | ||
| 49 | "unicode/utf8" | ||
| 50 | 46 | ||
| 51 | 47 | _ "modernc.org/sqlite" | |
| 52 | 48 | ||
| 53 | "github.com/rodent-software/vole/core/id" | ||
| 54 | 49 | "golang.org/x/crypto/ssh" | |
| 55 | 50 | ) | |
| 56 | 51 | ||
| @@ -66,30 +61,6 @@ func ValidateName(s string) error { | |||
| 66 | 61 | return nil | |
| 67 | 62 | } | |
| 68 | 63 | ||
| 69 | // maxDisplayRunes bounds a display name. It is a label, not an identity, so the | ||
| 70 | // limit only has to keep a page or a table readable. | ||
| 71 | const maxDisplayRunes = 64 | ||
| 72 | |||
| 73 | // ValidateDisplay reports whether s may be a display name. Anything printable is | ||
| 74 | // allowed, including spaces, capitals and non-Latin scripts; control characters | ||
| 75 | // are not, because a newline or tab would break the one-line listings a display | ||
| 76 | // name shows up in. | ||
| 77 | func ValidateDisplay(s string) error { | ||
| 78 | s = strings.TrimSpace(s) | ||
| 79 | if s == "" { | ||
| 80 | return nil | ||
| 81 | } | ||
| 82 | if n := utf8.RuneCountInString(s); n > maxDisplayRunes { | ||
| 83 | return fmt.Errorf("display name is %d characters; the limit is %d", n, maxDisplayRunes) | ||
| 84 | } | ||
| 85 | for _, r := range s { | ||
| 86 | if unicode.IsControl(r) { | ||
| 87 | return fmt.Errorf("display name %q contains a control character", s) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | return nil | ||
| 91 | } | ||
| 92 | |||
| 93 | 64 | type Key struct { | |
| 94 | 65 | Fingerprint string `json:"fingerprint"` | |
| 95 | 66 | Type string `json:"type"` | |
| @@ -98,9 +69,7 @@ type Key struct { | |||
| 98 | 69 | } | |
| 99 | 70 | ||
| 100 | 71 | type Account struct { | |
| 101 | ID string | ||
| 102 | 72 | Name string // fixed at creation; contributor branches are named after it | |
| 103 | Display string // optional human-friendly label; may change | ||
| 104 | 73 | Status string // active | retired | |
| 105 | 74 | Keys []Key | |
| 106 | 75 | Admin bool | |
| @@ -109,30 +78,17 @@ type Account struct { | |||
| 109 | 78 | ||
| 110 | 79 | func (a *Account) Active() bool { return a.Status == "active" } | |
| 111 | 80 | ||
| 112 | // Label is what a person is shown: the display name when one is set, the | ||
| 113 | // immutable name otherwise. | ||
| 114 | func (a *Account) Label() string { | ||
| 115 | if a == nil { | ||
| 116 | return "" | ||
| 117 | } | ||
| 118 | if a.Display != "" { | ||
| 119 | return a.Display | ||
| 120 | } | ||
| 121 | return a.Name | ||
| 122 | } | ||
| 123 | |||
| 124 | 81 | // Snapshot is an immutable view of the roster, swapped atomically so the SSH | |
| 125 | 82 | // auth callback stays a map lookup. | |
| 126 | 83 | type Snapshot struct { | |
| 127 | ByID map[string]*Account | ||
| 128 | // ByKey maps a key fingerprint to an active account id. Retired accounts are | ||
| 129 | // absent, so revoking is immediate. | ||
| 84 | // ByKey maps a key fingerprint to an active account name. Retired accounts | ||
| 85 | // are absent, so revoking is immediate. | ||
| 130 | 86 | ByKey map[string]string | |
| 131 | 87 | // ByName maps a name to its account, including retired accounts: a name is | |
| 132 | 88 | // fixed at creation and remains attached to the account that took it. | |
| 133 | 89 | ByName map[string]*Account | |
| 134 | 90 | ||
| 135 | // Admins holds the account ids that may use the remote administration API. | ||
| 91 | // Admins holds the account names that may use the remote administration API. | ||
| 136 | 92 | Admins map[string]bool | |
| 137 | 93 | ||
| 138 | 94 | Count int // active accounts | |
| @@ -142,8 +98,8 @@ func (s *Snapshot) Lookup(fingerprint string) (string, bool) { | |||
| 142 | 98 | if s == nil { | |
| 143 | 99 | return "", false | |
| 144 | 100 | } | |
| 145 | a, ok := s.ByKey[fingerprint] | ||
| 146 | return a, ok | ||
| 101 | name, ok := s.ByKey[fingerprint] | ||
| 102 | return name, ok | ||
| 147 | 103 | } | |
| 148 | 104 | ||
| 149 | 105 | type Store struct { | |
| @@ -156,26 +112,24 @@ type Store struct { | |||
| 156 | 112 | ||
| 157 | 113 | const schema = ` | |
| 158 | 114 | CREATE TABLE IF NOT EXISTS users ( | |
| 159 | id TEXT PRIMARY KEY, | ||
| 160 | name TEXT NOT NULL UNIQUE, | ||
| 161 | display TEXT NOT NULL DEFAULT '', | ||
| 115 | name TEXT PRIMARY KEY, | ||
| 162 | 116 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | |
| 163 | 117 | created INTEGER NOT NULL | |
| 164 | 118 | ); | |
| 165 | 119 | CREATE TABLE IF NOT EXISTS user_keys ( | |
| 166 | 120 | fingerprint TEXT PRIMARY KEY, | |
| 167 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 121 | user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE, | ||
| 168 | 122 | type TEXT NOT NULL, | |
| 169 | 123 | line TEXT NOT NULL, | |
| 170 | 124 | comment TEXT NOT NULL DEFAULT '' | |
| 171 | 125 | ); | |
| 172 | CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_id); | ||
| 126 | CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_name); | ||
| 173 | 127 | CREATE TABLE IF NOT EXISTS instance_admins ( | |
| 174 | user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE | ||
| 128 | user_name TEXT PRIMARY KEY REFERENCES users(name) ON DELETE CASCADE | ||
| 175 | 129 | ); | |
| 176 | 130 | CREATE TABLE IF NOT EXISTS user_credentials ( | |
| 177 | 131 | credential_id TEXT PRIMARY KEY, | |
| 178 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 132 | user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE, | ||
| 179 | 133 | public_key BLOB NOT NULL, | |
| 180 | 134 | attestation_type TEXT NOT NULL DEFAULT '', | |
| 181 | 135 | transports TEXT NOT NULL DEFAULT '[]', | |
| @@ -188,26 +142,26 @@ CREATE TABLE IF NOT EXISTS user_credentials ( | |||
| 188 | 142 | created INTEGER NOT NULL, | |
| 189 | 143 | last_used INTEGER NOT NULL DEFAULT 0 | |
| 190 | 144 | ); | |
| 191 | CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_id); | ||
| 145 | CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_name); | ||
| 192 | 146 | CREATE TABLE IF NOT EXISTS user_invites ( | |
| 193 | 147 | token_hash TEXT PRIMARY KEY, | |
| 194 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 148 | user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE, | ||
| 195 | 149 | created INTEGER NOT NULL, | |
| 196 | 150 | expires INTEGER NOT NULL, | |
| 197 | 151 | used_at INTEGER, | |
| 198 | 152 | created_by TEXT NOT NULL DEFAULT '' | |
| 199 | 153 | ); | |
| 200 | CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_id); | ||
| 154 | CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_name); | ||
| 201 | 155 | CREATE TABLE IF NOT EXISTS sessions ( | |
| 202 | 156 | token_hash TEXT PRIMARY KEY, | |
| 203 | account_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 157 | user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE, | ||
| 204 | 158 | credential_id TEXT REFERENCES user_credentials(credential_id) ON DELETE CASCADE, | |
| 205 | 159 | created INTEGER NOT NULL, | |
| 206 | 160 | last_seen INTEGER NOT NULL, | |
| 207 | 161 | expires INTEGER NOT NULL, | |
| 208 | 162 | user_agent TEXT NOT NULL DEFAULT '' | |
| 209 | 163 | ); | |
| 210 | CREATE INDEX IF NOT EXISTS sessions_account ON sessions(account_id); | ||
| 164 | CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_name); | ||
| 211 | 165 | CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires); | |
| 212 | 166 | ` | |
| 213 | 167 | ||
| @@ -234,30 +188,327 @@ func Init(path string) error { | |||
| 234 | 188 | return fmt.Errorf("identity: %w", err) | |
| 235 | 189 | } | |
| 236 | 190 | defer db.Close() | |
| 237 | if _, err := db.Exec(schema); err != nil { | ||
| 238 | return fmt.Errorf("identity: create roster: %w", err) | ||
| 239 | } | ||
| 240 | 191 | if err := migrate(db); err != nil { | |
| 241 | 192 | return fmt.Errorf("identity: migrate roster: %w", err) | |
| 242 | 193 | } | |
| 194 | if _, err := db.Exec(schema); err != nil { | ||
| 195 | return fmt.Errorf("identity: create roster: %w", err) | ||
| 196 | } | ||
| 243 | 197 | return nil | |
| 244 | 198 | } | |
| 245 | 199 | ||
| 246 | 200 | // migrate upgrades a roster written by an older build in place. It is idempotent | |
| 247 | 201 | // and runs on every open, so an operator who restores an old backup does not | |
| 248 | 202 | // have to remember which version produced it. | |
| 203 | // | ||
| 204 | // Rosters used to key accounts by an internal id and carry a display label. | ||
| 205 | // Both are gone: the registered name is the identity. A roster with the old | ||
| 206 | // shape is rebuilt around the name, copying every row through the account it | ||
| 207 | // belonged to, so no key, passkey, invite, session or admin grant is lost. | ||
| 249 | 208 | func migrate(db *sql.DB) error { | |
| 250 | // Display names were added after the first release. A roster without the | ||
| 251 | // column is upgraded rather than rejected; a roster that already has it keeps | ||
| 252 | // its values. | ||
| 253 | if _, err := db.Exec(`ALTER TABLE users ADD COLUMN display TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column name") { | ||
| 209 | hasUsers, err := tableExists(db, "users") | ||
| 210 | if err != nil { | ||
| 211 | return err | ||
| 212 | } | ||
| 213 | if !hasUsers { | ||
| 214 | return nil | ||
| 215 | } | ||
| 216 | hasID, err := columnExists(db, "users", "id") | ||
| 217 | if err != nil { | ||
| 218 | return err | ||
| 219 | } | ||
| 220 | if !hasID { | ||
| 221 | // A name-keyed roster is current. The alias table from an even older | ||
| 222 | // build is redundant: the users row itself reserves the name. | ||
| 223 | _, err := db.Exec(`DROP TABLE IF EXISTS user_names`) | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | |||
| 227 | tx, err := db.Begin() | ||
| 228 | if err != nil { | ||
| 229 | return err | ||
| 230 | } | ||
| 231 | defer tx.Rollback() | ||
| 232 | |||
| 233 | // Build the replacement tables first, then copy through the old id so a | ||
| 234 | // reference becomes the name it resolved to. | ||
| 235 | for _, name := range []string{"users_new", "user_keys_new", "instance_admins_new", | ||
| 236 | "user_credentials_new", "user_invites_new", "sessions_new"} { | ||
| 237 | if _, err := tx.Exec(`DROP TABLE IF EXISTS ` + name); err != nil { | ||
| 238 | return err | ||
| 239 | } | ||
| 240 | } | ||
| 241 | if _, err := tx.Exec(` | ||
| 242 | CREATE TABLE users_new ( | ||
| 243 | name TEXT PRIMARY KEY, | ||
| 244 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | ||
| 245 | created INTEGER NOT NULL | ||
| 246 | ); | ||
| 247 | CREATE TABLE user_keys_new ( | ||
| 248 | fingerprint TEXT PRIMARY KEY, | ||
| 249 | user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE, | ||
| 250 | type TEXT NOT NULL, | ||
| 251 | line TEXT NOT NULL, | ||
| 252 | comment TEXT NOT NULL DEFAULT '' | ||
| 253 | ); | ||
| 254 | CREATE TABLE instance_admins_new ( | ||
| 255 | user_name TEXT PRIMARY KEY REFERENCES users_new(name) ON DELETE CASCADE | ||
| 256 | ); | ||
| 257 | CREATE TABLE user_credentials_new ( | ||
| 258 | credential_id TEXT PRIMARY KEY, | ||
| 259 | user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE, | ||
| 260 | public_key BLOB NOT NULL, | ||
| 261 | attestation_type TEXT NOT NULL DEFAULT '', | ||
| 262 | transports TEXT NOT NULL DEFAULT '[]', | ||
| 263 | flags INTEGER NOT NULL DEFAULT 0, | ||
| 264 | aaguid BLOB, | ||
| 265 | sign_count INTEGER NOT NULL DEFAULT 0, | ||
| 266 | clone_warning INTEGER NOT NULL DEFAULT 0, | ||
| 267 | attachment TEXT NOT NULL DEFAULT '', | ||
| 268 | label TEXT NOT NULL DEFAULT '', | ||
| 269 | created INTEGER NOT NULL, | ||
| 270 | last_used INTEGER NOT NULL DEFAULT 0 | ||
| 271 | ); | ||
| 272 | CREATE TABLE user_invites_new ( | ||
| 273 | token_hash TEXT PRIMARY KEY, | ||
| 274 | user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE, | ||
| 275 | created INTEGER NOT NULL, | ||
| 276 | expires INTEGER NOT NULL, | ||
| 277 | used_at INTEGER, | ||
| 278 | created_by TEXT NOT NULL DEFAULT '' | ||
| 279 | ); | ||
| 280 | CREATE TABLE sessions_new ( | ||
| 281 | token_hash TEXT PRIMARY KEY, | ||
| 282 | user_name TEXT NOT NULL REFERENCES users_new(name) ON DELETE CASCADE, | ||
| 283 | credential_id TEXT REFERENCES user_credentials_new(credential_id) ON DELETE CASCADE, | ||
| 284 | created INTEGER NOT NULL, | ||
| 285 | last_seen INTEGER NOT NULL, | ||
| 286 | expires INTEGER NOT NULL, | ||
| 287 | user_agent TEXT NOT NULL DEFAULT '' | ||
| 288 | );`); err != nil { | ||
| 289 | return err | ||
| 290 | } | ||
| 291 | |||
| 292 | if _, err := tx.Exec(`INSERT INTO users_new(name, status, created) SELECT name, status, created FROM users`); err != nil { | ||
| 293 | return err | ||
| 294 | } | ||
| 295 | if err := copyIfTable(tx, "user_keys", `INSERT INTO user_keys_new(fingerprint, user_name, type, line, comment) | ||
| 296 | 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 { | ||
| 297 | return err | ||
| 298 | } | ||
| 299 | if err := copyIfTable(tx, "instance_admins", `INSERT INTO instance_admins_new(user_name) | ||
| 300 | SELECT u.name FROM instance_admins ia JOIN users u ON u.id = ia.user_id`); err != nil { | ||
| 301 | return err | ||
| 302 | } | ||
| 303 | if err := copyIfTable(tx, "user_credentials", `INSERT INTO user_credentials_new( | ||
| 304 | credential_id, user_name, public_key, attestation_type, transports, flags, aaguid, | ||
| 305 | sign_count, clone_warning, attachment, label, created, last_used) | ||
| 306 | SELECT c.credential_id, u.name, c.public_key, c.attestation_type, c.transports, c.flags, c.aaguid, | ||
| 307 | c.sign_count, c.clone_warning, c.attachment, c.label, c.created, c.last_used | ||
| 308 | FROM user_credentials c JOIN users u ON u.id = c.user_id`); err != nil { | ||
| 309 | return err | ||
| 310 | } | ||
| 311 | if err := copyIfTable(tx, "user_invites", `INSERT INTO user_invites_new(token_hash, user_name, created, expires, used_at, created_by) | ||
| 312 | SELECT i.token_hash, u.name, i.created, i.expires, i.used_at, i.created_by | ||
| 313 | FROM user_invites i JOIN users u ON u.id = i.user_id`); err != nil { | ||
| 314 | return err | ||
| 315 | } | ||
| 316 | if err := copyIfTable(tx, "sessions", `INSERT INTO sessions_new(token_hash, user_name, credential_id, created, last_seen, expires, user_agent) | ||
| 317 | SELECT s.token_hash, u.name, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent | ||
| 318 | FROM sessions s JOIN users u ON u.id = s.account_id`); err != nil { | ||
| 319 | return err | ||
| 320 | } | ||
| 321 | |||
| 322 | // Access rules and file locks live in the same database and name accounts by | ||
| 323 | // the old id. Rewrite them through the same mapping before the ids disappear. | ||
| 324 | if err := migrateUserReferences(tx); err != nil { | ||
| 325 | return err | ||
| 326 | } | ||
| 327 | |||
| 328 | // Drop the old tables children-first, then put the replacements in place. | ||
| 329 | for _, name := range []string{"sessions", "user_invites", "user_credentials", | ||
| 330 | "instance_admins", "user_keys", "user_names", "users"} { | ||
| 331 | if _, err := tx.Exec(`DROP TABLE IF EXISTS ` + name); err != nil { | ||
| 332 | return err | ||
| 333 | } | ||
| 334 | } | ||
| 335 | for _, rename := range [][2]string{ | ||
| 336 | {"users_new", "users"}, | ||
| 337 | {"user_credentials_new", "user_credentials"}, | ||
| 338 | {"user_keys_new", "user_keys"}, | ||
| 339 | {"instance_admins_new", "instance_admins"}, | ||
| 340 | {"user_invites_new", "user_invites"}, | ||
| 341 | {"sessions_new", "sessions"}, | ||
| 342 | } { | ||
| 343 | if _, err := tx.Exec(`ALTER TABLE ` + rename[0] + ` RENAME TO ` + rename[1]); err != nil { | ||
| 344 | return err | ||
| 345 | } | ||
| 346 | } | ||
| 347 | if _, err := tx.Exec(` | ||
| 348 | CREATE INDEX user_keys_owner ON user_keys(user_name); | ||
| 349 | CREATE INDEX user_credentials_owner ON user_credentials(user_name); | ||
| 350 | CREATE INDEX user_invites_owner ON user_invites(user_name); | ||
| 351 | CREATE INDEX sessions_user ON sessions(user_name); | ||
| 352 | CREATE INDEX sessions_expiry ON sessions(expires);`); err != nil { | ||
| 353 | return err | ||
| 354 | } | ||
| 355 | return tx.Commit() | ||
| 356 | } | ||
| 357 | |||
| 358 | // migrateUserReferences rewrites the tables that store an account key as plain | ||
| 359 | // text rather than a foreign key. Only access rules and locks live that way; a | ||
| 360 | // missing table is normal on a database that never used those features. | ||
| 361 | func migrateUserReferences(tx *sql.Tx) error { | ||
| 362 | tableExists := func(name string) (bool, error) { | ||
| 363 | var n int | ||
| 364 | err := tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n) | ||
| 365 | return n > 0, err | ||
| 366 | } | ||
| 367 | hasACL, err := tableExists("repo_acl") | ||
| 368 | if err != nil { | ||
| 254 | 369 | return err | |
| 255 | 370 | } | |
| 256 | // Names used to be mutable, so every name an account had ever held was kept | ||
| 257 | // in a separate table to stop it being reissued. A name is now fixed at | ||
| 258 | // creation and the users row outlives retirement, so that table is redundant | ||
| 259 | // and its contents would otherwise be a list of aliases nothing resolves. | ||
| 260 | _, err := db.Exec(`DROP TABLE IF EXISTS user_names`) | ||
| 371 | hasLocks, err := tableExists("lfs_locks") | ||
| 372 | if err != nil { | ||
| 373 | return err | ||
| 374 | } | ||
| 375 | if !hasACL && !hasLocks { | ||
| 376 | return nil | ||
| 377 | } | ||
| 378 | if _, err := tx.Exec(`DROP TABLE IF EXISTS _user_id_map; | ||
| 379 | CREATE TABLE _user_id_map(old_id TEXT PRIMARY KEY, name TEXT NOT NULL); | ||
| 380 | INSERT INTO _user_id_map(old_id, name) SELECT id, name FROM users`); err != nil { | ||
| 381 | return err | ||
| 382 | } | ||
| 383 | if hasACL { | ||
| 384 | type aclRow struct { | ||
| 385 | repoID string | ||
| 386 | raw string | ||
| 387 | } | ||
| 388 | rows, err := tx.Query(`SELECT repo_id, acl FROM repo_acl`) | ||
| 389 | if err != nil { | ||
| 390 | return err | ||
| 391 | } | ||
| 392 | var updates []aclRow | ||
| 393 | for rows.Next() { | ||
| 394 | var r aclRow | ||
| 395 | if err := rows.Scan(&r.repoID, &r.raw); err != nil { | ||
| 396 | rows.Close() | ||
| 397 | return err | ||
| 398 | } | ||
| 399 | updates = append(updates, r) | ||
| 400 | } | ||
| 401 | rows.Close() | ||
| 402 | if err := rows.Err(); err != nil { | ||
| 403 | return err | ||
| 404 | } | ||
| 405 | for _, u := range updates { | ||
| 406 | rewritten, err := rewriteACLNames(tx, u.raw) | ||
| 407 | if err != nil { | ||
| 408 | return err | ||
| 409 | } | ||
| 410 | if rewritten == u.raw { | ||
| 411 | continue | ||
| 412 | } | ||
| 413 | if _, err := tx.Exec(`UPDATE repo_acl SET acl=? WHERE repo_id=?`, rewritten, u.repoID); err != nil { | ||
| 414 | return err | ||
| 415 | } | ||
| 416 | } | ||
| 417 | if _, err := tx.Exec(`UPDATE repo_acl SET updated_by=COALESCE( | ||
| 418 | (SELECT name FROM _user_id_map WHERE old_id=repo_acl.updated_by), updated_by)`); err != nil { | ||
| 419 | return err | ||
| 420 | } | ||
| 421 | } | ||
| 422 | if hasLocks { | ||
| 423 | if _, err := tx.Exec(`UPDATE lfs_locks SET owner=COALESCE( | ||
| 424 | (SELECT name FROM _user_id_map WHERE old_id=lfs_locks.owner), owner) | ||
| 425 | WHERE owner IN (SELECT old_id FROM _user_id_map)`); err != nil { | ||
| 426 | return err | ||
| 427 | } | ||
| 428 | } | ||
| 429 | _, err = tx.Exec(`DROP TABLE _user_id_map`) | ||
| 430 | return err | ||
| 431 | } | ||
| 432 | |||
| 433 | // rewriteACLNames replaces account ids with names inside an access-rule document. | ||
| 434 | // The fields are handled generically so a rule set written by a newer build keeps | ||
| 435 | // every setting this one does not know about. | ||
| 436 | func rewriteACLNames(tx *sql.Tx, raw string) (string, error) { | ||
| 437 | var doc map[string]any | ||
| 438 | if err := json.Unmarshal([]byte(raw), &doc); err != nil { | ||
| 439 | return "", fmt.Errorf("identity: read access rules during migration: %w", err) | ||
| 440 | } | ||
| 441 | lookup := func(id string) string { | ||
| 442 | var name string | ||
| 443 | // The mapping is in the transaction's table; a small query per key is | ||
| 444 | // fine because an ACL has a handful of entries. | ||
| 445 | if err := tx.QueryRow(`SELECT name FROM _user_id_map WHERE old_id=?`, id).Scan(&name); err == nil && name != "" { | ||
| 446 | return name | ||
| 447 | } | ||
| 448 | return id | ||
| 449 | } | ||
| 450 | if entries, ok := doc["entries"].(map[string]any); ok { | ||
| 451 | next := make(map[string]any, len(entries)) | ||
| 452 | for id, role := range entries { | ||
| 453 | next[lookup(id)] = role | ||
| 454 | } | ||
| 455 | doc["entries"] = next | ||
| 456 | } | ||
| 457 | if runners, ok := doc["runners"].([]any); ok { | ||
| 458 | for i, r := range runners { | ||
| 459 | if id, ok := r.(string); ok { | ||
| 460 | runners[i] = lookup(id) | ||
| 461 | } | ||
| 462 | } | ||
| 463 | doc["runners"] = runners | ||
| 464 | } | ||
| 465 | out, err := json.MarshalIndent(doc, "", " ") | ||
| 466 | if err != nil { | ||
| 467 | return "", err | ||
| 468 | } | ||
| 469 | return string(out) + "\n", nil | ||
| 470 | } | ||
| 471 | |||
| 472 | func tableExists(db *sql.DB, table string) (bool, error) { | ||
| 473 | var n int | ||
| 474 | err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n) | ||
| 475 | return n > 0, err | ||
| 476 | } | ||
| 477 | |||
| 478 | func columnExists(db *sql.DB, table, column string) (bool, error) { | ||
| 479 | rows, err := db.Query(`PRAGMA table_info(` + table + `)`) | ||
| 480 | if err != nil { | ||
| 481 | return false, err | ||
| 482 | } | ||
| 483 | defer rows.Close() | ||
| 484 | for rows.Next() { | ||
| 485 | var ( | ||
| 486 | cid int | ||
| 487 | name string | ||
| 488 | typ string | ||
| 489 | notnull int | ||
| 490 | dflt any | ||
| 491 | pk int | ||
| 492 | ) | ||
| 493 | if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { | ||
| 494 | return false, err | ||
| 495 | } | ||
| 496 | if name == column { | ||
| 497 | return true, nil | ||
| 498 | } | ||
| 499 | } | ||
| 500 | return false, rows.Err() | ||
| 501 | } | ||
| 502 | |||
| 503 | func copyIfTable(tx *sql.Tx, table, statement string) error { | ||
| 504 | var n int | ||
| 505 | if err := tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n); err != nil { | ||
| 506 | return err | ||
| 507 | } | ||
| 508 | if n == 0 { | ||
| 509 | return nil | ||
| 510 | } | ||
| 511 | _, err := tx.Exec(statement) | ||
| 261 | 512 | return err | |
| 262 | 513 | } | |
| 263 | 514 | ||
| @@ -273,12 +524,13 @@ func Open(path string) (*Store, error) { | |||
| 273 | 524 | return nil, fmt.Errorf("identity: %w", err) | |
| 274 | 525 | } | |
| 275 | 526 | // Applying the schema here keeps an existing roster usable when a new table | |
| 276 | // is added; every statement is CREATE IF NOT EXISTS. | ||
| 277 | if _, err := db.Exec(schema); err != nil { | ||
| 527 | // is added; every statement is CREATE IF NOT EXISTS. Migration runs first so | ||
| 528 | // the new indexes are only applied to tables that already have the new shape. | ||
| 529 | if err := migrate(db); err != nil { | ||
| 278 | 530 | db.Close() | |
| 279 | 531 | return nil, fmt.Errorf("identity: %w", err) | |
| 280 | 532 | } | |
| 281 | if err := migrate(db); err != nil { | ||
| 533 | if _, err := db.Exec(schema); err != nil { | ||
| 282 | 534 | db.Close() | |
| 283 | 535 | return nil, fmt.Errorf("identity: %w", err) | |
| 284 | 536 | } | |
| @@ -321,12 +573,11 @@ func (s *Store) Reload() error { | |||
| 321 | 573 | ||
| 322 | 574 | func (s *Store) load() (*Snapshot, error) { | |
| 323 | 575 | snap := &Snapshot{ | |
| 324 | ByID: map[string]*Account{}, | ||
| 325 | 576 | ByKey: map[string]string{}, | |
| 326 | 577 | ByName: map[string]*Account{}, | |
| 327 | 578 | Admins: map[string]bool{}, | |
| 328 | 579 | } | |
| 329 | rows, err := s.db.Query(`SELECT id, name, display, status, created FROM users ORDER BY id`) | ||
| 580 | rows, err := s.db.Query(`SELECT name, status, created FROM users ORDER BY name`) | ||
| 330 | 581 | if err != nil { | |
| 331 | 582 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 332 | 583 | } | |
| @@ -335,34 +586,28 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 335 | 586 | var a Account | |
| 336 | 587 | var status string | |
| 337 | 588 | var created int64 | |
| 338 | if err := rows.Scan(&a.ID, &a.Name, &a.Display, &status, &created); err != nil { | ||
| 589 | if err := rows.Scan(&a.Name, &status, &created); err != nil { | ||
| 339 | 590 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 340 | 591 | } | |
| 341 | if !id.Valid(a.ID) { | ||
| 342 | return nil, fmt.Errorf("identity: %q is not a valid account id", a.ID) | ||
| 343 | } | ||
| 344 | 592 | if err := ValidateName(a.Name); err != nil { | |
| 345 | return nil, fmt.Errorf("identity: account %s: %w", a.ID, err) | ||
| 346 | } | ||
| 347 | if err := ValidateDisplay(a.Display); err != nil { | ||
| 348 | return nil, fmt.Errorf("identity: account %s: %w", a.ID, err) | ||
| 593 | return nil, fmt.Errorf("identity: account %s: %w", a.Name, err) | ||
| 349 | 594 | } | |
| 350 | 595 | switch status { | |
| 351 | 596 | case "", "active": | |
| 352 | 597 | status = "active" | |
| 353 | 598 | case "retired": | |
| 354 | 599 | default: | |
| 355 | return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.ID) | ||
| 600 | return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.Name) | ||
| 356 | 601 | } | |
| 357 | 602 | a.Status = status | |
| 358 | 603 | a.Created = time.Unix(created, 0).UTC() | |
| 359 | snap.ByID[a.ID] = &a | ||
| 604 | snap.ByName[a.Name] = &a | ||
| 360 | 605 | } | |
| 361 | 606 | if err := rows.Err(); err != nil { | |
| 362 | 607 | return nil, err | |
| 363 | 608 | } | |
| 364 | 609 | ||
| 365 | keys, err := s.db.Query(`SELECT fingerprint, user_id, type, line, comment FROM user_keys ORDER BY fingerprint`) | ||
| 610 | keys, err := s.db.Query(`SELECT fingerprint, user_name, type, line, comment FROM user_keys ORDER BY fingerprint`) | ||
| 366 | 611 | if err != nil { | |
| 367 | 612 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 368 | 613 | } | |
| @@ -373,7 +618,7 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 373 | 618 | if err := keys.Scan(&k.Fingerprint, &owner, &k.Type, &k.Line, &k.Comment); err != nil { | |
| 374 | 619 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 375 | 620 | } | |
| 376 | a, ok := snap.ByID[owner] | ||
| 621 | a, ok := snap.ByName[owner] | ||
| 377 | 622 | if !ok { | |
| 378 | 623 | return nil, fmt.Errorf("identity: key %s belongs to unknown account %s", k.Fingerprint, owner) | |
| 379 | 624 | } | |
| @@ -389,34 +634,28 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 389 | 634 | return nil, err | |
| 390 | 635 | } | |
| 391 | 636 | ||
| 392 | admins, err := s.db.Query(`SELECT user_id FROM instance_admins`) | ||
| 637 | admins, err := s.db.Query(`SELECT user_name FROM instance_admins`) | ||
| 393 | 638 | if err != nil { | |
| 394 | 639 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | |
| 395 | 640 | } | |
| 396 | 641 | defer admins.Close() | |
| 397 | 642 | for admins.Next() { | |
| 398 | var accountID string | ||
| 399 | if err := admins.Scan(&accountID); err != nil { | ||
| 643 | var name string | ||
| 644 | if err := admins.Scan(&name); err != nil { | ||
| 400 | 645 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | |
| 401 | 646 | } | |
| 402 | if _, ok := snap.ByID[accountID]; !ok { | ||
| 403 | return nil, fmt.Errorf("identity: instance admin %s is not an account", accountID) | ||
| 647 | if _, ok := snap.ByName[name]; !ok { | ||
| 648 | return nil, fmt.Errorf("identity: instance admin %s is not an account", name) | ||
| 404 | 649 | } | |
| 405 | snap.Admins[accountID] = true | ||
| 650 | snap.Admins[name] = true | ||
| 406 | 651 | } | |
| 407 | 652 | if err := admins.Err(); err != nil { | |
| 408 | 653 | return nil, err | |
| 409 | 654 | } | |
| 410 | 655 | ||
| 411 | for _, a := range snap.ByID { | ||
| 412 | a.Admin = snap.Admins[a.ID] | ||
| 656 | for _, a := range snap.ByName { | ||
| 657 | a.Admin = snap.Admins[a.Name] | ||
| 413 | 658 | sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint }) | |
| 414 | // Every account, retired or not, keeps its name: the users row is the | ||
| 415 | // reservation, so a name can never be issued twice. | ||
| 416 | if other, ok := snap.ByName[a.Name]; ok { | ||
| 417 | return nil, fmt.Errorf("identity: two accounts are called %q (%s and %s)", a.Name, other.ID, a.ID) | ||
| 418 | } | ||
| 419 | snap.ByName[a.Name] = a | ||
| 420 | 659 | if a.Active() { | |
| 421 | 660 | snap.Count++ | |
| 422 | 661 | } | |
| @@ -425,115 +664,75 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 425 | 664 | } | |
| 426 | 665 | ||
| 427 | 666 | // IsAdmin reports whether an active account may use the remote admin API. | |
| 428 | func (s *Store) IsAdmin(accountID string) bool { | ||
| 667 | func (s *Store) IsAdmin(name string) bool { | ||
| 429 | 668 | snap := s.Snapshot() | |
| 430 | 669 | if snap == nil { | |
| 431 | 670 | return false | |
| 432 | 671 | } | |
| 433 | a, ok := snap.ByID[accountID] | ||
| 434 | return ok && a.Active() && snap.Admins[accountID] | ||
| 672 | a, ok := snap.ByName[name] | ||
| 673 | return ok && a.Active() && snap.Admins[name] | ||
| 435 | 674 | } | |
| 436 | 675 | ||
| 437 | 676 | // SetAdmin grants or revokes instance-admin access. It refuses to remove the | |
| 438 | 677 | // last active instance admin, so a remote mistake cannot lock everybody out of | |
| 439 | 678 | // the remote API. | |
| 440 | func (s *Store) SetAdmin(idOrName string, admin bool, actor string) (*Account, error) { | ||
| 679 | func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error) { | ||
| 441 | 680 | action := "identity.revoke-admin" | |
| 442 | 681 | if admin { | |
| 443 | 682 | action = "identity.grant-admin" | |
| 444 | 683 | } | |
| 445 | var accountID string | ||
| 446 | err := s.mutate(action, actor, idOrName, "", func(tx *sql.Tx) error { | ||
| 447 | a, err := findTx(tx, idOrName) | ||
| 684 | err := s.mutate(action, actor, name, "", func(tx *sql.Tx) error { | ||
| 685 | a, err := findTx(tx, name) | ||
| 448 | 686 | if err != nil { | |
| 449 | 687 | return err | |
| 450 | 688 | } | |
| 451 | 689 | if !a.Active() { | |
| 452 | 690 | return fmt.Errorf("identity: %s is retired", a.Name) | |
| 453 | 691 | } | |
| 454 | accountID = a.ID | ||
| 455 | 692 | if admin { | |
| 456 | _, err := tx.Exec(`INSERT OR IGNORE INTO instance_admins(user_id) VALUES(?)`, a.ID) | ||
| 693 | _, err := tx.Exec(`INSERT OR IGNORE INTO instance_admins(user_name) VALUES(?)`, a.Name) | ||
| 457 | 694 | return err | |
| 458 | 695 | } | |
| 459 | 696 | var existing int | |
| 460 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_id=?`, a.ID).Scan(&existing); errors.Is(err, sql.ErrNoRows) { | ||
| 697 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_name=?`, a.Name).Scan(&existing); errors.Is(err, sql.ErrNoRows) { | ||
| 461 | 698 | return fmt.Errorf("identity: %s is not an instance admin", a.Name) | |
| 462 | 699 | } else if err != nil { | |
| 463 | 700 | return err | |
| 464 | 701 | } | |
| 465 | 702 | var activeAdmins int | |
| 466 | if err := tx.QueryRow(`SELECT COUNT(*) FROM instance_admins ia JOIN users u ON u.id=ia.user_id WHERE u.status='active'`).Scan(&activeAdmins); err != nil { | ||
| 703 | if err := tx.QueryRow(`SELECT COUNT(*) FROM instance_admins ia JOIN users u ON u.name=ia.user_name WHERE u.status='active'`).Scan(&activeAdmins); err != nil { | ||
| 467 | 704 | return err | |
| 468 | 705 | } | |
| 469 | 706 | if activeAdmins <= 1 { | |
| 470 | 707 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before revoking this one", a.Name) | |
| 471 | 708 | } | |
| 472 | _, err = tx.Exec(`DELETE FROM instance_admins WHERE user_id=?`, a.ID) | ||
| 709 | _, err = tx.Exec(`DELETE FROM instance_admins WHERE user_name=?`, a.Name) | ||
| 473 | 710 | return err | |
| 474 | 711 | }) | |
| 475 | 712 | if err != nil { | |
| 476 | 713 | return nil, err | |
| 477 | 714 | } | |
| 478 | return s.Find(accountID) | ||
| 715 | return s.Find(name) | ||
| 479 | 716 | } | |
| 480 | 717 | ||
| 481 | // Lookup resolves a key fingerprint to an active account id and name. | ||
| 482 | func (s *Store) Lookup(fingerprint string) (accountID, name string, ok bool) { | ||
| 483 | a, ok := s.Snapshot().ByID[s.Snapshot().ByKey[fingerprint]] | ||
| 484 | if !ok { | ||
| 485 | return "", "", false | ||
| 486 | } | ||
| 487 | return a.ID, a.Name, true | ||
| 718 | // Lookup resolves a key fingerprint to an active account name. | ||
| 719 | func (s *Store) Lookup(fingerprint string) (name string, ok bool) { | ||
| 720 | return s.Snapshot().Lookup(fingerprint) | ||
| 488 | 721 | } | |
| 489 | 722 | ||
| 490 | // ResolveID maps an active name to its account id. A retired name resolves to | ||
| 491 | // nothing: the account is still known, but it is not a principal any more. | ||
| 492 | func (s *Store) ResolveID(name string) (string, bool) { | ||
| 493 | a, ok := s.Snapshot().ByName[name] | ||
| 494 | if !ok || !a.Active() { | ||
| 495 | return "", false | ||
| 496 | } | ||
| 497 | return a.ID, true | ||
| 498 | } | ||
| 499 | |||
| 500 | // Name returns the immutable name for an account id, or the id itself when the | ||
| 501 | // account is unknown. This is the identifier that may be written into a ref or a | ||
| 502 | // metadata file. | ||
| 503 | func (s *Store) Name(accountID string) string { | ||
| 504 | if a, ok := s.Snapshot().ByID[accountID]; ok { | ||
| 505 | return a.Name | ||
| 506 | } | ||
| 507 | return accountID | ||
| 508 | } | ||
| 509 | |||
| 510 | // Label returns the human-friendly form for an account id: the display name when | ||
| 511 | // one is set, the immutable name otherwise, and the id when the account is | ||
| 512 | // unknown. Nothing is authorized by the result, so it is only used for output. | ||
| 513 | func (s *Store) Label(accountID string) string { | ||
| 514 | if a, ok := s.Snapshot().ByID[accountID]; ok { | ||
| 515 | return a.Label() | ||
| 516 | } | ||
| 517 | return accountID | ||
| 518 | } | ||
| 519 | |||
| 520 | // Find resolves an id or a name, including the name of a retired account. | ||
| 521 | func (s *Store) Find(idOrName string) (*Account, error) { | ||
| 522 | snap := s.Snapshot() | ||
| 523 | if a, ok := snap.ByID[idOrName]; ok { | ||
| 723 | // Find resolves a name, including the name of a retired account. | ||
| 724 | func (s *Store) Find(name string) (*Account, error) { | ||
| 725 | if a, ok := s.Snapshot().ByName[name]; ok { | ||
| 524 | 726 | return a, nil | |
| 525 | 727 | } | |
| 526 | if a, ok := snap.ByName[idOrName]; ok { | ||
| 527 | return a, nil | ||
| 528 | } | ||
| 529 | return nil, fmt.Errorf("identity: no account %q", idOrName) | ||
| 728 | return nil, fmt.Errorf("identity: no account %q", name) | ||
| 530 | 729 | } | |
| 531 | 730 | ||
| 532 | 731 | // All returns every account, active first, then by name. | |
| 533 | 732 | func (s *Store) All() []*Account { | |
| 534 | 733 | snap := s.Snapshot() | |
| 535 | out := make([]*Account, 0, len(snap.ByID)) | ||
| 536 | for _, a := range snap.ByID { | ||
| 734 | out := make([]*Account, 0, len(snap.ByName)) | ||
| 735 | for _, a := range snap.ByName { | ||
| 537 | 736 | out = append(out, a) | |
| 538 | 737 | } | |
| 539 | 738 | sort.Slice(out, func(i, j int) bool { | |
| @@ -545,14 +744,13 @@ func (s *Store) All() []*Account { | |||
| 545 | 744 | return out | |
| 546 | 745 | } | |
| 547 | 746 | ||
| 548 | // Roster returns the active accounts as id/name pairs, sorted by name. The SSH | ||
| 549 | // server serves this to authenticated clients so `vole change` can turn a name | ||
| 550 | // into the account id that ACLs and approvals store. | ||
| 551 | func (s *Store) Roster() []Account { | ||
| 552 | var out []Account | ||
| 747 | // Roster returns the active account names, sorted. The SSH server serves this to | ||
| 748 | // authenticated clients so `vole change` can check that a name is a real account. | ||
| 749 | func (s *Store) Roster() []string { | ||
| 750 | var out []string | ||
| 553 | 751 | for _, a := range s.All() { | |
| 554 | 752 | if a.Active() { | |
| 555 | out = append(out, Account{ID: a.ID, Name: a.Name}) | ||
| 753 | out = append(out, a.Name) | ||
| 556 | 754 | } | |
| 557 | 755 | } | |
| 558 | 756 | return out | |
| @@ -583,36 +781,28 @@ func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx | |||
| 583 | 781 | return nil | |
| 584 | 782 | } | |
| 585 | 783 | ||
| 586 | // Create adds an account with a fresh id, a fixed name, an optional display name | ||
| 587 | // and its first SSH key. The account, the name and the key are written in one | ||
| 588 | // transaction, so a name is never claimed by an account that cannot authenticate. | ||
| 784 | // Create adds an account with a fixed name and its first SSH key. The account, | ||
| 785 | // the name and the key are written in one transaction, so a name is never claimed | ||
| 786 | // by an account that cannot authenticate. | ||
| 589 | 787 | // | |
| 590 | 788 | // An account that will use a passkey instead is created with CreateWithInvite; | |
| 591 | 789 | // the invite is the credential-shaped way in. | |
| 592 | func (s *Store) Create(name, display, authorizedKey, actor string) (*Account, error) { | ||
| 790 | func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | ||
| 593 | 791 | if err := ValidateName(name); err != nil { | |
| 594 | 792 | return nil, err | |
| 595 | 793 | } | |
| 596 | display = strings.TrimSpace(display) | ||
| 597 | if err := ValidateDisplay(display); err != nil { | ||
| 598 | return nil, err | ||
| 599 | } | ||
| 600 | 794 | key, err := parseKey(authorizedKey) | |
| 601 | 795 | if err != nil { | |
| 602 | 796 | return nil, err | |
| 603 | 797 | } | |
| 604 | 798 | now := time.Now().UTC() | |
| 605 | accountID, err := id.New(now) | ||
| 606 | if err != nil { | ||
| 607 | return nil, err | ||
| 608 | } | ||
| 609 | err = s.mutate("identity.create", actor, accountID, name+" "+key.Fingerprint, func(tx *sql.Tx) error { | ||
| 610 | return createAccount(tx, name, display, accountID, now, &key, nil) | ||
| 799 | err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint, func(tx *sql.Tx) error { | ||
| 800 | return createAccount(tx, name, now, &key, nil) | ||
| 611 | 801 | }) | |
| 612 | 802 | if err != nil { | |
| 613 | 803 | return nil, err | |
| 614 | 804 | } | |
| 615 | return s.Find(accountID) | ||
| 805 | return s.Find(name) | ||
| 616 | 806 | } | |
| 617 | 807 | ||
| 618 | 808 | // pendingInvite is the row half of an enrollment link. The plaintext token is | |
| @@ -623,7 +813,7 @@ type pendingInvite struct { | |||
| 623 | 813 | createdBy string | |
| 624 | 814 | } | |
| 625 | 815 | ||
| 626 | func createAccount(tx *sql.Tx, name, display, accountID string, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 816 | func createAccount(tx *sql.Tx, name string, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 627 | 817 | if owner, err := nameOwner(tx, name); err != nil { | |
| 628 | 818 | return err | |
| 629 | 819 | } else if owner != "" { | |
| @@ -636,62 +826,35 @@ func createAccount(tx *sql.Tx, name, display, accountID string, now time.Time, k | |||
| 636 | 826 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 637 | 827 | } | |
| 638 | 828 | } | |
| 639 | if _, err := tx.Exec(`INSERT INTO users(id, name, display, status, created) VALUES(?,?,?, 'active', ?)`, | ||
| 640 | accountID, name, display, now.Unix()); err != nil { | ||
| 829 | if _, err := tx.Exec(`INSERT INTO users(name, status, created) VALUES(?, 'active', ?)`, | ||
| 830 | name, now.Unix()); err != nil { | ||
| 641 | 831 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 642 | 832 | } | |
| 643 | 833 | if key != nil { | |
| 644 | if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line, comment) VALUES(?,?,?,?,?)`, | ||
| 645 | key.Fingerprint, accountID, key.Type, key.Line, key.Comment); err != nil { | ||
| 834 | if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_name, type, line, comment) VALUES(?,?,?,?,?)`, | ||
| 835 | key.Fingerprint, name, key.Type, key.Line, key.Comment); err != nil { | ||
| 646 | 836 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 647 | 837 | } | |
| 648 | 838 | } | |
| 649 | return insertInvite(tx, accountID, invite, now) | ||
| 839 | return insertInvite(tx, name, invite, now) | ||
| 650 | 840 | } | |
| 651 | 841 | ||
| 652 | func insertInvite(tx *sql.Tx, accountID string, invite *pendingInvite, now time.Time) error { | ||
| 842 | func insertInvite(tx *sql.Tx, name string, invite *pendingInvite, now time.Time) error { | ||
| 653 | 843 | if invite == nil { | |
| 654 | 844 | return nil | |
| 655 | 845 | } | |
| 656 | if _, err := tx.Exec(`INSERT INTO user_invites(token_hash, user_id, created, expires, created_by) VALUES(?,?,?,?,?)`, | ||
| 657 | invite.tokenHash, accountID, now.Unix(), invite.expires.Unix(), invite.createdBy); err != nil { | ||
| 846 | if _, err := tx.Exec(`INSERT INTO user_invites(token_hash, user_name, created, expires, created_by) VALUES(?,?,?,?,?)`, | ||
| 847 | invite.tokenHash, name, now.Unix(), invite.expires.Unix(), invite.createdBy); err != nil { | ||
| 658 | 848 | return fmt.Errorf("identity: create invite: %w", err) | |
| 659 | 849 | } | |
| 660 | 850 | return nil | |
| 661 | 851 | } | |
| 662 | 852 | ||
| 663 | // SetDisplay replaces the human-friendly label shown next to an account's fixed | ||
| 664 | // name. An empty display clears it and the name is shown instead. The name is | ||
| 665 | // not touched: it is what refs and metadata files were written with. | ||
| 666 | func (s *Store) SetDisplay(idOrName, display, actor string) (*Account, error) { | ||
| 667 | display = strings.TrimSpace(display) | ||
| 668 | if err := ValidateDisplay(display); err != nil { | ||
| 669 | return nil, err | ||
| 670 | } | ||
| 671 | var accountID string | ||
| 672 | err := s.mutate("identity.display", actor, idOrName, display, func(tx *sql.Tx) error { | ||
| 673 | a, err := findTx(tx, idOrName) | ||
| 674 | if err != nil { | ||
| 675 | return err | ||
| 676 | } | ||
| 677 | if _, err := tx.Exec(`UPDATE users SET display=? WHERE id=?`, display, a.ID); err != nil { | ||
| 678 | return fmt.Errorf("identity: display %s: %w", a.ID, err) | ||
| 679 | } | ||
| 680 | accountID = a.ID | ||
| 681 | return nil | ||
| 682 | }) | ||
| 683 | if err != nil { | ||
| 684 | return nil, err | ||
| 685 | } | ||
| 686 | return s.Find(accountID) | ||
| 687 | } | ||
| 688 | |||
| 689 | 853 | // Retire disables an account. Its keys stop authenticating at the next reload and | |
| 690 | 854 | // its name is never reissued. | |
| 691 | func (s *Store) Retire(idOrName, actor string) (*Account, error) { | ||
| 692 | var accountID string | ||
| 693 | err := s.mutate("identity.retire", actor, idOrName, "", func(tx *sql.Tx) error { | ||
| 694 | a, err := findTx(tx, idOrName) | ||
| 855 | func (s *Store) Retire(name, actor string) (*Account, error) { | ||
| 856 | err := s.mutate("identity.retire", actor, name, "", func(tx *sql.Tx) error { | ||
| 857 | a, err := findTx(tx, name) | ||
| 695 | 858 | if err != nil { | |
| 696 | 859 | return err | |
| 697 | 860 | } | |
| @@ -699,9 +862,9 @@ func (s *Store) Retire(idOrName, actor string) (*Account, error) { | |||
| 699 | 862 | return fmt.Errorf("identity: %s is already retired", a.Name) | |
| 700 | 863 | } | |
| 701 | 864 | var isAdmin bool | |
| 702 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_id=?`, a.ID).Scan(&isAdmin); err == nil && isAdmin { | ||
| 865 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_name=?`, a.Name).Scan(&isAdmin); err == nil && isAdmin { | ||
| 703 | 866 | var activeAdmins int | |
| 704 | if err := tx.QueryRow(`SELECT COUNT(*) FROM instance_admins ia JOIN users u ON u.id=ia.user_id WHERE u.status='active'`).Scan(&activeAdmins); err != nil { | ||
| 867 | if err := tx.QueryRow(`SELECT COUNT(*) FROM instance_admins ia JOIN users u ON u.name=ia.user_name WHERE u.status='active'`).Scan(&activeAdmins); err != nil { | ||
| 705 | 868 | return err | |
| 706 | 869 | } | |
| 707 | 870 | if activeAdmins <= 1 { | |
| @@ -710,25 +873,24 @@ func (s *Store) Retire(idOrName, actor string) (*Account, error) { | |||
| 710 | 873 | } else if err != nil && !errors.Is(err, sql.ErrNoRows) { | |
| 711 | 874 | return err | |
| 712 | 875 | } | |
| 713 | if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE id=?`, a.ID); err != nil { | ||
| 714 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | ||
| 876 | if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE name=?`, a.Name); err != nil { | ||
| 877 | return fmt.Errorf("identity: retire %s: %w", a.Name, err) | ||
| 715 | 878 | } | |
| 716 | 879 | // Retiring is the one act that must end a browser session immediately. | |
| 717 | 880 | // The session lookup checks status too, but deleting here also clears the | |
| 718 | 881 | // rows and any outstanding enrollment links. | |
| 719 | if _, err := tx.Exec(`DELETE FROM sessions WHERE account_id=?`, a.ID); err != nil { | ||
| 720 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | ||
| 882 | if _, err := tx.Exec(`DELETE FROM sessions WHERE user_name=?`, a.Name); err != nil { | ||
| 883 | return fmt.Errorf("identity: retire %s: %w", a.Name, err) | ||
| 721 | 884 | } | |
| 722 | if _, err := tx.Exec(`DELETE FROM user_invites WHERE user_id=?`, a.ID); err != nil { | ||
| 723 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | ||
| 885 | if _, err := tx.Exec(`DELETE FROM user_invites WHERE user_name=?`, a.Name); err != nil { | ||
| 886 | return fmt.Errorf("identity: retire %s: %w", a.Name, err) | ||
| 724 | 887 | } | |
| 725 | accountID = a.ID | ||
| 726 | 888 | return nil | |
| 727 | 889 | }) | |
| 728 | 890 | if err != nil { | |
| 729 | 891 | return nil, err | |
| 730 | 892 | } | |
| 731 | return s.Find(accountID) | ||
| 893 | return s.Find(name) | ||
| 732 | 894 | } | |
| 733 | 895 | ||
| 734 | 896 | // parseKey validates one authorized_keys line and extracts what the roster stores. | |
| @@ -753,7 +915,7 @@ func parseKey(authorizedKey string) (Key, error) { | |||
| 753 | 915 | // the key is unclaimed. | |
| 754 | 916 | func keyOwner(tx *sql.Tx, fingerprint string) (string, error) { | |
| 755 | 917 | var owner string | |
| 756 | err := tx.QueryRow(`SELECT u.name FROM user_keys k JOIN users u ON u.id=k.user_id WHERE k.fingerprint=?`, fingerprint).Scan(&owner) | ||
| 918 | err := tx.QueryRow(`SELECT user_name FROM user_keys WHERE fingerprint=?`, fingerprint).Scan(&owner) | ||
| 757 | 919 | if errors.Is(err, sql.ErrNoRows) { | |
| 758 | 920 | return "", nil | |
| 759 | 921 | } | |
| @@ -765,13 +927,13 @@ func keyOwner(tx *sql.Tx, fingerprint string) (string, error) { | |||
| 765 | 927 | ||
| 766 | 928 | // AddKey attaches an authorized_keys line to an account and returns its | |
| 767 | 929 | // fingerprint. Creation requires a key; this is for adding another one. | |
| 768 | func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | ||
| 930 | func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) { | ||
| 769 | 931 | key, err := parseKey(authorizedKey) | |
| 770 | 932 | if err != nil { | |
| 771 | 933 | return "", err | |
| 772 | 934 | } | |
| 773 | err = s.mutate("identity.add-key", actor, idOrName, key.Fingerprint+" "+key.Comment, func(tx *sql.Tx) error { | ||
| 774 | a, err := findTx(tx, idOrName) | ||
| 935 | err = s.mutate("identity.add-key", actor, name, key.Fingerprint+" "+key.Comment, func(tx *sql.Tx) error { | ||
| 936 | a, err := findTx(tx, name) | ||
| 775 | 937 | if err != nil { | |
| 776 | 938 | return err | |
| 777 | 939 | } | |
| @@ -785,8 +947,8 @@ func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | |||
| 785 | 947 | case owner != "": | |
| 786 | 948 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 787 | 949 | } | |
| 788 | if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line, comment) VALUES(?,?,?,?,?)`, | ||
| 789 | key.Fingerprint, a.ID, key.Type, key.Line, key.Comment); err != nil { | ||
| 950 | if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_name, type, line, comment) VALUES(?,?,?,?,?)`, | ||
| 951 | key.Fingerprint, a.Name, key.Type, key.Line, key.Comment); err != nil { | ||
| 790 | 952 | return fmt.Errorf("identity: add key: %w", err) | |
| 791 | 953 | } | |
| 792 | 954 | return nil | |
| @@ -798,13 +960,13 @@ func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | |||
| 798 | 960 | } | |
| 799 | 961 | ||
| 800 | 962 | // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted. | |
| 801 | func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error { | ||
| 802 | return s.mutate("identity.remove-key", actor, idOrName, fingerprint, func(tx *sql.Tx) error { | ||
| 803 | a, err := findTx(tx, idOrName) | ||
| 963 | func (s *Store) RemoveKey(name, fingerprint, actor string) error { | ||
| 964 | return s.mutate("identity.remove-key", actor, name, fingerprint, func(tx *sql.Tx) error { | ||
| 965 | a, err := findTx(tx, name) | ||
| 804 | 966 | if err != nil { | |
| 805 | 967 | return err | |
| 806 | 968 | } | |
| 807 | rows, err := tx.Query(`SELECT fingerprint FROM user_keys WHERE user_id=?`, a.ID) | ||
| 969 | rows, err := tx.Query(`SELECT fingerprint FROM user_keys WHERE user_name=?`, a.Name) | ||
| 808 | 970 | if err != nil { | |
| 809 | 971 | return fmt.Errorf("identity: read keys: %w", err) | |
| 810 | 972 | } | |
| @@ -836,12 +998,12 @@ func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error { | |||
| 836 | 998 | }) | |
| 837 | 999 | } | |
| 838 | 1000 | ||
| 839 | // nameOwner returns the account id holding name, or "" when the name is unused. | ||
| 1001 | // nameOwner returns the account name when it is taken, or "" when it is unused. | ||
| 840 | 1002 | // Retired accounts count: their row is the reservation that stops a name being | |
| 841 | 1003 | // reissued. | |
| 842 | 1004 | func nameOwner(tx *sql.Tx, name string) (string, error) { | |
| 843 | 1005 | var owner string | |
| 844 | err := tx.QueryRow(`SELECT id FROM users WHERE name=?`, name).Scan(&owner) | ||
| 1006 | err := tx.QueryRow(`SELECT name FROM users WHERE name=?`, name).Scan(&owner) | ||
| 845 | 1007 | if errors.Is(err, sql.ErrNoRows) { | |
| 846 | 1008 | return "", nil | |
| 847 | 1009 | } | |
| @@ -851,18 +1013,15 @@ func nameOwner(tx *sql.Tx, name string) (string, error) { | |||
| 851 | 1013 | return owner, nil | |
| 852 | 1014 | } | |
| 853 | 1015 | ||
| 854 | // findTx resolves an id (any status) or an active name inside a transaction. | ||
| 855 | func findTx(tx *sql.Tx, idOrName string) (*Account, error) { | ||
| 1016 | // findTx resolves a name inside a transaction, including a retired one. Callers | ||
| 1017 | // that require an active account check the status themselves. | ||
| 1018 | func findTx(tx *sql.Tx, name string) (*Account, error) { | ||
| 856 | 1019 | var a Account | |
| 857 | 1020 | var created int64 | |
| 858 | err := tx.QueryRow(`SELECT id, name, status, created FROM users WHERE id=?`, idOrName). | ||
| 859 | Scan(&a.ID, &a.Name, &a.Status, &created) | ||
| 860 | if errors.Is(err, sql.ErrNoRows) { | ||
| 861 | err = tx.QueryRow(`SELECT id, name, status, created FROM users WHERE name=? AND status='active'`, idOrName). | ||
| 862 | Scan(&a.ID, &a.Name, &a.Status, &created) | ||
| 863 | } | ||
| 1021 | err := tx.QueryRow(`SELECT name, status, created FROM users WHERE name=?`, name). | ||
| 1022 | Scan(&a.Name, &a.Status, &created) | ||
| 864 | 1023 | if errors.Is(err, sql.ErrNoRows) { | |
| 865 | return nil, fmt.Errorf("identity: no active account %q", idOrName) | ||
| 1024 | return nil, fmt.Errorf("identity: no account %q", name) | ||
| 866 | 1025 | } | |
| 867 | 1026 | if err != nil { | |
| 868 | 1027 | return nil, fmt.Errorf("identity: read roster: %w", err) | |