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