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.
Add remote administration and local workflow runs
3abbe749096f4d7ff76f4d468e4427829b8a6f95 · Keenan Nemetz · 16d ago · parent 520470e1
Add remote administration and local workflow runs Make `vole admin` the single namespace for user, repository, and access management, runnable locally or over SSH by instance admins. Instance-admin grants live in users.db, and the audit log names the actor separately from the account being changed. Add `vole workflow run` to execute workflow jobs in the working tree without a server or runner. Positional arguments select workflows, --job selects jobs, and the command reuses the push-time validator. Update the README and guides for the new command shapes.
forge/identity/identity.go +114−11
| @@ -64,6 +64,7 @@ type Account struct { | |||
| 64 | 64 | Status string // active | retired | |
| 65 | 65 | Former []string | |
| 66 | 66 | Keys []Key | |
| 67 | Admin bool | ||
| 67 | 68 | Created time.Time | |
| 68 | 69 | } | |
| 69 | 70 | ||
| @@ -82,6 +83,9 @@ type Snapshot struct { | |||
| 82 | 83 | // and retired accounts. A reserved name is never issued twice. | |
| 83 | 84 | Reserved map[string]string | |
| 84 | 85 | ||
| 86 | // Admins holds the account ids that may use the remote administration API. | ||
| 87 | Admins map[string]bool | ||
| 88 | |||
| 85 | 89 | Count int // active accounts | |
| 86 | 90 | } | |
| 87 | 91 | ||
| @@ -120,11 +124,14 @@ CREATE TABLE IF NOT EXISTS user_keys ( | |||
| 120 | 124 | comment TEXT NOT NULL DEFAULT '' | |
| 121 | 125 | ); | |
| 122 | 126 | CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_id); | |
| 127 | CREATE TABLE IF NOT EXISTS instance_admins ( | ||
| 128 | user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE | ||
| 129 | ); | ||
| 123 | 130 | ` | |
| 124 | 131 | ||
| 125 | 132 | func openDB(path string) (*sql.DB, error) { | |
| 126 | 133 | // _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 | ||
| 134 | // front, so a second `vole admin user` process waits on busy_timeout instead of | ||
| 128 | 135 | // failing when it tries to upgrade a read lock mid-transaction. | |
| 129 | 136 | db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate") | |
| 130 | 137 | if err != nil { | |
| @@ -156,12 +163,18 @@ func Init(path string) error { | |||
| 156 | 163 | // roster would silently change who can authenticate. | |
| 157 | 164 | func Open(path string) (*Store, error) { | |
| 158 | 165 | if _, err := os.Stat(path); err != nil { | |
| 159 | return nil, fmt.Errorf("identity: %s is missing; run vole init", path) | ||
| 166 | return nil, fmt.Errorf("identity: %s is missing; run vole admin init", path) | ||
| 160 | 167 | } | |
| 161 | 168 | db, err := openDB(path) | |
| 162 | 169 | if err != nil { | |
| 163 | 170 | return nil, fmt.Errorf("identity: %w", err) | |
| 164 | 171 | } | |
| 172 | // Applying the schema here keeps an existing roster usable when a new table | ||
| 173 | // is added; every statement is CREATE IF NOT EXISTS. | ||
| 174 | if _, err := db.Exec(schema); err != nil { | ||
| 175 | db.Close() | ||
| 176 | return nil, fmt.Errorf("identity: %w", err) | ||
| 177 | } | ||
| 165 | 178 | s := &Store{db: db, path: path} | |
| 166 | 179 | if err := s.Reload(); err != nil { | |
| 167 | 180 | db.Close() | |
| @@ -205,6 +218,7 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 205 | 218 | ByKey: map[string]string{}, | |
| 206 | 219 | ByName: map[string]*Account{}, | |
| 207 | 220 | Reserved: map[string]string{}, | |
| 221 | Admins: map[string]bool{}, | ||
| 208 | 222 | } | |
| 209 | 223 | rows, err := s.db.Query(`SELECT id, name, status, created FROM users ORDER BY id`) | |
| 210 | 224 | if err != nil { | |
| @@ -293,7 +307,27 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 293 | 307 | return nil, err | |
| 294 | 308 | } | |
| 295 | 309 | ||
| 310 | admins, err := s.db.Query(`SELECT user_id FROM instance_admins`) | ||
| 311 | if err != nil { | ||
| 312 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | ||
| 313 | } | ||
| 314 | defer admins.Close() | ||
| 315 | for admins.Next() { | ||
| 316 | var accountID string | ||
| 317 | if err := admins.Scan(&accountID); err != nil { | ||
| 318 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | ||
| 319 | } | ||
| 320 | if _, ok := snap.ByID[accountID]; !ok { | ||
| 321 | return nil, fmt.Errorf("identity: instance admin %s is not an account", accountID) | ||
| 322 | } | ||
| 323 | snap.Admins[accountID] = true | ||
| 324 | } | ||
| 325 | if err := admins.Err(); err != nil { | ||
| 326 | return nil, err | ||
| 327 | } | ||
| 328 | |||
| 296 | 329 | for _, a := range snap.ByID { | |
| 330 | a.Admin = snap.Admins[a.ID] | ||
| 297 | 331 | sort.Strings(a.Former) | |
| 298 | 332 | sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint }) | |
| 299 | 333 | if a.Active() { | |
| @@ -310,6 +344,60 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 310 | 344 | return snap, nil | |
| 311 | 345 | } | |
| 312 | 346 | ||
| 347 | // IsAdmin reports whether an active account may use the remote admin API. | ||
| 348 | func (s *Store) IsAdmin(accountID string) bool { | ||
| 349 | snap := s.Snapshot() | ||
| 350 | if snap == nil { | ||
| 351 | return false | ||
| 352 | } | ||
| 353 | a, ok := snap.ByID[accountID] | ||
| 354 | return ok && a.Active() && snap.Admins[accountID] | ||
| 355 | } | ||
| 356 | |||
| 357 | // SetAdmin grants or revokes instance-admin access. It refuses to remove the | ||
| 358 | // last active instance admin, so a remote mistake cannot lock everybody out of | ||
| 359 | // the remote API. | ||
| 360 | func (s *Store) SetAdmin(idOrName string, admin bool, actor string) (*Account, error) { | ||
| 361 | action := "identity.revoke-admin" | ||
| 362 | if admin { | ||
| 363 | action = "identity.grant-admin" | ||
| 364 | } | ||
| 365 | var accountID string | ||
| 366 | err := s.mutate(action, actor, idOrName, "", func(tx *sql.Tx) error { | ||
| 367 | a, err := findTx(tx, idOrName) | ||
| 368 | if err != nil { | ||
| 369 | return err | ||
| 370 | } | ||
| 371 | if !a.Active() { | ||
| 372 | return fmt.Errorf("identity: %s is retired", a.Name) | ||
| 373 | } | ||
| 374 | accountID = a.ID | ||
| 375 | if admin { | ||
| 376 | _, err := tx.Exec(`INSERT OR IGNORE INTO instance_admins(user_id) VALUES(?)`, a.ID) | ||
| 377 | return err | ||
| 378 | } | ||
| 379 | var existing int | ||
| 380 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_id=?`, a.ID).Scan(&existing); errors.Is(err, sql.ErrNoRows) { | ||
| 381 | return fmt.Errorf("identity: %s is not an instance admin", a.Name) | ||
| 382 | } else if err != nil { | ||
| 383 | return err | ||
| 384 | } | ||
| 385 | var activeAdmins int | ||
| 386 | 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 { | ||
| 387 | return err | ||
| 388 | } | ||
| 389 | if activeAdmins <= 1 { | ||
| 390 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before revoking this one", a.Name) | ||
| 391 | } | ||
| 392 | _, err = tx.Exec(`DELETE FROM instance_admins WHERE user_id=?`, a.ID) | ||
| 393 | return err | ||
| 394 | }) | ||
| 395 | if err != nil { | ||
| 396 | return nil, err | ||
| 397 | } | ||
| 398 | return s.Find(accountID) | ||
| 399 | } | ||
| 400 | |||
| 313 | 401 | // Lookup resolves a key fingerprint to an active account id and name. | |
| 314 | 402 | func (s *Store) Lookup(fingerprint string) (accountID, name string, ok bool) { | |
| 315 | 403 | a, ok := s.Snapshot().ByID[s.Snapshot().ByKey[fingerprint]] | |
| @@ -389,7 +477,7 @@ func (s *Store) Roster() []Account { | |||
| 389 | 477 | // matter: a name is issued once and a key belongs to one account. The audit log | |
| 390 | 478 | // is the only history — there is no commit trail behind the rows any more. | |
| 391 | 479 | ||
| 392 | func (s *Store) mutate(action, account, detail string, fn func(tx *sql.Tx) error) error { | ||
| 480 | func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx) error) error { | ||
| 393 | 481 | tx, err := s.db.Begin() | |
| 394 | 482 | if err != nil { | |
| 395 | 483 | return fmt.Errorf("identity: %w", err) | |
| @@ -404,7 +492,7 @@ func (s *Store) mutate(action, account, detail string, fn func(tx *sql.Tx) error | |||
| 404 | 492 | if err := s.Reload(); err != nil { | |
| 405 | 493 | return err | |
| 406 | 494 | } | |
| 407 | s.audit(action, account, detail) | ||
| 495 | s.audit(action, actor, account, detail) | ||
| 408 | 496 | return nil | |
| 409 | 497 | } | |
| 410 | 498 | ||
| @@ -418,7 +506,7 @@ func (s *Store) Create(name, actor string) (*Account, error) { | |||
| 418 | 506 | if err != nil { | |
| 419 | 507 | return nil, err | |
| 420 | 508 | } | |
| 421 | err = s.mutate("identity.create", accountID, name, func(tx *sql.Tx) error { | ||
| 509 | err = s.mutate("identity.create", actor, accountID, name, func(tx *sql.Tx) error { | ||
| 422 | 510 | if owner, err := reservedBy(tx, name); err != nil { | |
| 423 | 511 | return err | |
| 424 | 512 | } else if owner != "" { | |
| @@ -446,7 +534,7 @@ func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) { | |||
| 446 | 534 | return nil, err | |
| 447 | 535 | } | |
| 448 | 536 | var accountID string | |
| 449 | err := s.mutate("identity.rename", idOrName, newName, func(tx *sql.Tx) error { | ||
| 537 | err := s.mutate("identity.rename", actor, idOrName, newName, func(tx *sql.Tx) error { | ||
| 450 | 538 | a, err := findTx(tx, idOrName) | |
| 451 | 539 | if err != nil { | |
| 452 | 540 | return err | |
| @@ -478,7 +566,7 @@ func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) { | |||
| 478 | 566 | // its name is never reissued. | |
| 479 | 567 | func (s *Store) Retire(idOrName, actor string) (*Account, error) { | |
| 480 | 568 | var accountID string | |
| 481 | err := s.mutate("identity.retire", idOrName, "", func(tx *sql.Tx) error { | ||
| 569 | err := s.mutate("identity.retire", actor, idOrName, "", func(tx *sql.Tx) error { | ||
| 482 | 570 | a, err := findTx(tx, idOrName) | |
| 483 | 571 | if err != nil { | |
| 484 | 572 | return err | |
| @@ -486,6 +574,18 @@ func (s *Store) Retire(idOrName, actor string) (*Account, error) { | |||
| 486 | 574 | if a.Status == "retired" { | |
| 487 | 575 | return fmt.Errorf("identity: %s is already retired", a.Name) | |
| 488 | 576 | } | |
| 577 | var isAdmin bool | ||
| 578 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_id=?`, a.ID).Scan(&isAdmin); err == nil && isAdmin { | ||
| 579 | var activeAdmins int | ||
| 580 | 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 { | ||
| 581 | return err | ||
| 582 | } | ||
| 583 | if activeAdmins <= 1 { | ||
| 584 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before retiring this one", a.Name) | ||
| 585 | } | ||
| 586 | } else if err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| 587 | return err | ||
| 588 | } | ||
| 489 | 589 | if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE id=?`, a.ID); err != nil { | |
| 490 | 590 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | |
| 491 | 591 | } | |
| @@ -507,7 +607,7 @@ func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | |||
| 507 | 607 | return "", fmt.Errorf("identity: not an SSH public key: %w", err) | |
| 508 | 608 | } | |
| 509 | 609 | fp := ssh.FingerprintSHA256(pub) | |
| 510 | err = s.mutate("identity.add-key", idOrName, fp+" "+comment, func(tx *sql.Tx) error { | ||
| 610 | err = s.mutate("identity.add-key", actor, idOrName, fp+" "+comment, func(tx *sql.Tx) error { | ||
| 511 | 611 | a, err := findTx(tx, idOrName) | |
| 512 | 612 | if err != nil { | |
| 513 | 613 | return err | |
| @@ -536,7 +636,7 @@ func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | |||
| 536 | 636 | ||
| 537 | 637 | // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted. | |
| 538 | 638 | func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error { | |
| 539 | return s.mutate("identity.remove-key", idOrName, fingerprint, func(tx *sql.Tx) error { | ||
| 639 | return s.mutate("identity.remove-key", actor, idOrName, fingerprint, func(tx *sql.Tx) error { | ||
| 540 | 640 | a, err := findTx(tx, idOrName) | |
| 541 | 641 | if err != nil { | |
| 542 | 642 | return err | |
| @@ -608,14 +708,17 @@ func findTx(tx *sql.Tx, idOrName string) (*Account, error) { | |||
| 608 | 708 | ||
| 609 | 709 | // audit appends one line describing a roster change. The database holds the | |
| 610 | 710 | // roster; this file is the only record of who changed it and when. | |
| 611 | func (s *Store) audit(action, account, detail string) { | ||
| 711 | func (s *Store) audit(action, actor, account, detail string) { | ||
| 612 | 712 | if s.AuditPath == "" { | |
| 613 | 713 | return | |
| 614 | 714 | } | |
| 715 | if actor == "" { | ||
| 716 | actor = "unknown" | ||
| 717 | } | ||
| 615 | 718 | f, err := os.OpenFile(s.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |
| 616 | 719 | if err != nil { | |
| 617 | 720 | return | |
| 618 | 721 | } | |
| 619 | fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, account, detail) | ||
| 722 | fmt.Fprintf(f, "%s\t%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, actor, account, detail) | ||
| 620 | 723 | f.Close() | |
| 621 | 724 | } | |