Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

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 }