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 instance-wide roles and refine access UI
5b814e9958090cfd1fa826efb2d375bf613547c1 · Keenan Nemetz · 14d ago · parent 9579c1f9
Add instance-wide roles and refine access UI Instance roles now live on accounts, apply to public repositories, and the Admin role is the unified instance-admin permission with access to every repository. Repository ACLs can raise an instance role but not lower it. Adds role assignment to the users UI and CLI, open-registration role config, a reusable role picker, and separates adding a repository account from saving access rules.
forge/identity/identity.go +146−81
| @@ -4,7 +4,7 @@ | |||
| 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: fixed name, status, created | ||
| 7 | // users one row per account: fixed name, status, instance role, 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 | |
| @@ -46,6 +46,7 @@ import ( | |||
| 46 | 46 | ||
| 47 | 47 | _ "modernc.org/sqlite" | |
| 48 | 48 | ||
| 49 | "github.com/rodent-software/vole/forge/access" | ||
| 49 | 50 | "golang.org/x/crypto/ssh" | |
| 50 | 51 | ) | |
| 51 | 52 | ||
| @@ -69,9 +70,14 @@ type Key struct { | |||
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | type Account struct { | |
| 72 | Name string // fixed at creation; contributor branches are named after it | ||
| 73 | Status string // active | retired | ||
| 74 | Keys []Key | ||
| 73 | Name string // fixed at creation; contributor branches are named after it | ||
| 74 | Status string // active | retired | ||
| 75 | Keys []Key | ||
| 76 | // Role is the account's instance-wide role. It applies to public | ||
| 77 | // repositories; Admin applies to every repository, private ones included. | ||
| 78 | // Admin is kept as a derived convenience for callers that only ask whether | ||
| 79 | // the account may use the instance-admin surfaces. | ||
| 80 | Role access.Role | ||
| 75 | 81 | Admin bool | |
| 76 | 82 | Created time.Time | |
| 77 | 83 | } | |
| @@ -88,9 +94,6 @@ type Snapshot struct { | |||
| 88 | 94 | // fixed at creation and remains attached to the account that took it. | |
| 89 | 95 | ByName map[string]*Account | |
| 90 | 96 | ||
| 91 | // Admins holds the account names that may use the remote administration API. | ||
| 92 | Admins map[string]bool | ||
| 93 | |||
| 94 | 97 | Count int // active accounts | |
| 95 | 98 | } | |
| 96 | 99 | ||
| @@ -112,9 +115,10 @@ type Store struct { | |||
| 112 | 115 | ||
| 113 | 116 | const schema = ` | |
| 114 | 117 | CREATE TABLE IF NOT EXISTS users ( | |
| 115 | name TEXT PRIMARY KEY, | ||
| 116 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | ||
| 117 | created INTEGER NOT NULL | ||
| 118 | name TEXT PRIMARY KEY, | ||
| 119 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | ||
| 120 | created INTEGER NOT NULL, | ||
| 121 | instance_role TEXT NOT NULL DEFAULT 'none' | ||
| 118 | 122 | ); | |
| 119 | 123 | CREATE TABLE IF NOT EXISTS user_keys ( | |
| 120 | 124 | fingerprint TEXT PRIMARY KEY, | |
| @@ -124,9 +128,6 @@ CREATE TABLE IF NOT EXISTS user_keys ( | |||
| 124 | 128 | comment TEXT NOT NULL DEFAULT '' | |
| 125 | 129 | ); | |
| 126 | 130 | CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_name); | |
| 127 | CREATE TABLE IF NOT EXISTS instance_admins ( | ||
| 128 | user_name TEXT PRIMARY KEY REFERENCES users(name) ON DELETE CASCADE | ||
| 129 | ); | ||
| 130 | 131 | CREATE TABLE IF NOT EXISTS user_credentials ( | |
| 131 | 132 | credential_id TEXT PRIMARY KEY, | |
| 132 | 133 | user_name TEXT NOT NULL REFERENCES users(name) ON DELETE CASCADE, | |
| @@ -191,6 +192,9 @@ func Init(path string) error { | |||
| 191 | 192 | if err := migrate(db); err != nil { | |
| 192 | 193 | return fmt.Errorf("identity: migrate roster: %w", err) | |
| 193 | 194 | } | |
| 195 | if err := migrateInstanceRoles(db); err != nil { | ||
| 196 | return fmt.Errorf("identity: migrate instance roles: %w", err) | ||
| 197 | } | ||
| 194 | 198 | if _, err := db.Exec(schema); err != nil { | |
| 195 | 199 | return fmt.Errorf("identity: create roster: %w", err) | |
| 196 | 200 | } | |
| @@ -240,9 +244,10 @@ func migrate(db *sql.DB) error { | |||
| 240 | 244 | } | |
| 241 | 245 | if _, err := tx.Exec(` | |
| 242 | 246 | 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 | ||
| 247 | name TEXT PRIMARY KEY, | ||
| 248 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | ||
| 249 | created INTEGER NOT NULL, | ||
| 250 | instance_role TEXT NOT NULL DEFAULT 'none' | ||
| 246 | 251 | ); | |
| 247 | 252 | CREATE TABLE user_keys_new ( | |
| 248 | 253 | fingerprint TEXT PRIMARY KEY, | |
| @@ -355,6 +360,43 @@ CREATE INDEX sessions_expiry ON sessions(expires);`); err != nil { | |||
| 355 | 360 | return tx.Commit() | |
| 356 | 361 | } | |
| 357 | 362 | ||
| 363 | // migrateInstanceRoles turns the old instance_admins table into the instance_role | ||
| 364 | // column on users. An instance admin is now the Admin role in the same ladder as | ||
| 365 | // every other instance-wide grant, so there is one answer to "what may this | ||
| 366 | // account do here". The old rows are copied and then cleared: leaving them would | ||
| 367 | // re-grant admin on the next open after somebody revoked it. | ||
| 368 | func migrateInstanceRoles(db *sql.DB) error { | ||
| 369 | hasUsers, err := tableExists(db, "users") | ||
| 370 | if err != nil || !hasUsers { | ||
| 371 | return err | ||
| 372 | } | ||
| 373 | hasRole, err := columnExists(db, "users", "instance_role") | ||
| 374 | if err != nil { | ||
| 375 | return err | ||
| 376 | } | ||
| 377 | if !hasRole { | ||
| 378 | if _, err := db.Exec(`ALTER TABLE users ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'none'`); err != nil { | ||
| 379 | return err | ||
| 380 | } | ||
| 381 | } | ||
| 382 | // contributor was the historical spelling of contribute. Normalise the | ||
| 383 | // stored value so a raw look at the roster shows the same role the UI and | ||
| 384 | // CLI do. | ||
| 385 | if _, err := db.Exec(`UPDATE users SET instance_role='contribute' WHERE instance_role IN ('contributor','contrib')`); err != nil { | ||
| 386 | return err | ||
| 387 | } | ||
| 388 | hasAdmins, err := tableExists(db, "instance_admins") | ||
| 389 | if err != nil || !hasAdmins { | ||
| 390 | return err | ||
| 391 | } | ||
| 392 | if _, err := db.Exec(`UPDATE users SET instance_role='admin' | ||
| 393 | WHERE instance_role='none' AND name IN (SELECT user_name FROM instance_admins)`); err != nil { | ||
| 394 | return err | ||
| 395 | } | ||
| 396 | _, err = db.Exec(`DELETE FROM instance_admins`) | ||
| 397 | return err | ||
| 398 | } | ||
| 399 | |||
| 358 | 400 | // migrateUserReferences rewrites the tables that store an account key as plain | |
| 359 | 401 | // text rather than a foreign key. Only access rules and locks live that way; a | |
| 360 | 402 | // missing table is normal on a database that never used those features. | |
| @@ -530,6 +572,10 @@ func Open(path string) (*Store, error) { | |||
| 530 | 572 | db.Close() | |
| 531 | 573 | return nil, fmt.Errorf("identity: %w", err) | |
| 532 | 574 | } | |
| 575 | if err := migrateInstanceRoles(db); err != nil { | ||
| 576 | db.Close() | ||
| 577 | return nil, fmt.Errorf("identity: %w", err) | ||
| 578 | } | ||
| 533 | 579 | if _, err := db.Exec(schema); err != nil { | |
| 534 | 580 | db.Close() | |
| 535 | 581 | return nil, fmt.Errorf("identity: %w", err) | |
| @@ -575,18 +621,17 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 575 | 621 | snap := &Snapshot{ | |
| 576 | 622 | ByKey: map[string]string{}, | |
| 577 | 623 | ByName: map[string]*Account{}, | |
| 578 | Admins: map[string]bool{}, | ||
| 579 | 624 | } | |
| 580 | rows, err := s.db.Query(`SELECT name, status, created FROM users ORDER BY name`) | ||
| 625 | rows, err := s.db.Query(`SELECT name, status, created, instance_role FROM users ORDER BY name`) | ||
| 581 | 626 | if err != nil { | |
| 582 | 627 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 583 | 628 | } | |
| 584 | 629 | defer rows.Close() | |
| 585 | 630 | for rows.Next() { | |
| 586 | 631 | var a Account | |
| 587 | var status string | ||
| 632 | var status, role string | ||
| 588 | 633 | var created int64 | |
| 589 | if err := rows.Scan(&a.Name, &status, &created); err != nil { | ||
| 634 | if err := rows.Scan(&a.Name, &status, &created, &role); err != nil { | ||
| 590 | 635 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 591 | 636 | } | |
| 592 | 637 | if err := ValidateName(a.Name); err != nil { | |
| @@ -599,7 +644,13 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 599 | 644 | default: | |
| 600 | 645 | return nil, fmt.Errorf("identity: account %s: status must be active or retired", a.Name) | |
| 601 | 646 | } | |
| 647 | r, err := access.ParseRole(role) | ||
| 648 | if err != nil { | ||
| 649 | return nil, fmt.Errorf("identity: account %s: instance role: %w", a.Name, err) | ||
| 650 | } | ||
| 602 | 651 | a.Status = status | |
| 652 | a.Role = r | ||
| 653 | a.Admin = r.AtLeast(access.Admin) | ||
| 603 | 654 | a.Created = time.Unix(created, 0).UTC() | |
| 604 | 655 | snap.ByName[a.Name] = &a | |
| 605 | 656 | } | |
| @@ -634,27 +685,7 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 634 | 685 | return nil, err | |
| 635 | 686 | } | |
| 636 | 687 | ||
| 637 | admins, err := s.db.Query(`SELECT user_name FROM instance_admins`) | ||
| 638 | if err != nil { | ||
| 639 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | ||
| 640 | } | ||
| 641 | defer admins.Close() | ||
| 642 | for admins.Next() { | ||
| 643 | var name string | ||
| 644 | if err := admins.Scan(&name); err != nil { | ||
| 645 | return nil, fmt.Errorf("identity: read instance admins: %w", err) | ||
| 646 | } | ||
| 647 | if _, ok := snap.ByName[name]; !ok { | ||
| 648 | return nil, fmt.Errorf("identity: instance admin %s is not an account", name) | ||
| 649 | } | ||
| 650 | snap.Admins[name] = true | ||
| 651 | } | ||
| 652 | if err := admins.Err(); err != nil { | ||
| 653 | return nil, err | ||
| 654 | } | ||
| 655 | |||
| 656 | 688 | for _, a := range snap.ByName { | |
| 657 | a.Admin = snap.Admins[a.Name] | ||
| 658 | 689 | sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint }) | |
| 659 | 690 | if a.Active() { | |
| 660 | 691 | snap.Count++ | |
| @@ -663,25 +694,31 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 663 | 694 | return snap, nil | |
| 664 | 695 | } | |
| 665 | 696 | ||
| 666 | // IsAdmin reports whether an active account may use the remote admin API. | ||
| 667 | func (s *Store) IsAdmin(name string) bool { | ||
| 697 | // InstanceRole returns an account's instance-wide role. Retired and unknown | ||
| 698 | // accounts have none, so a revoked account cannot keep a public repository open. | ||
| 699 | func (s *Store) InstanceRole(name string) access.Role { | ||
| 668 | 700 | snap := s.Snapshot() | |
| 669 | 701 | if snap == nil { | |
| 670 | return false | ||
| 702 | return access.None | ||
| 671 | 703 | } | |
| 672 | 704 | a, ok := snap.ByName[name] | |
| 673 | return ok && a.Active() && snap.Admins[name] | ||
| 705 | if !ok || !a.Active() { | ||
| 706 | return access.None | ||
| 707 | } | ||
| 708 | return a.Role | ||
| 674 | 709 | } | |
| 675 | 710 | ||
| 676 | // SetAdmin grants or revokes instance-admin access. It refuses to remove the | ||
| 677 | // last active instance admin, so a remote mistake cannot lock everybody out of | ||
| 678 | // the remote API. | ||
| 679 | func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error) { | ||
| 680 | action := "identity.revoke-admin" | ||
| 681 | if admin { | ||
| 682 | action = "identity.grant-admin" | ||
| 711 | // IsAdmin reports whether an active account has the instance-wide Admin role, | ||
| 712 | // which is also the remote administration API's gate. | ||
| 713 | func (s *Store) IsAdmin(name string) bool { | ||
| 714 | return s.InstanceRole(name).AtLeast(access.Admin) | ||
| 715 | } | ||
| 716 | |||
| 717 | func (s *Store) setInstanceRole(name string, role access.Role, actor, action, detail string) (*Account, error) { | ||
| 718 | if role < access.None || role > access.Admin { | ||
| 719 | return nil, fmt.Errorf("identity: invalid role %d", role) | ||
| 683 | 720 | } | |
| 684 | err := s.mutate(action, actor, name, "", func(tx *sql.Tx) error { | ||
| 721 | err := s.mutate(action, actor, name, detail, func(tx *sql.Tx) error { | ||
| 685 | 722 | a, err := findTx(tx, name) | |
| 686 | 723 | if err != nil { | |
| 687 | 724 | return err | |
| @@ -689,24 +726,16 @@ func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error | |||
| 689 | 726 | if !a.Active() { | |
| 690 | 727 | return fmt.Errorf("identity: %s is retired", a.Name) | |
| 691 | 728 | } | |
| 692 | if admin { | ||
| 693 | _, err := tx.Exec(`INSERT OR IGNORE INTO instance_admins(user_name) VALUES(?)`, a.Name) | ||
| 694 | return err | ||
| 695 | } | ||
| 696 | var existing int | ||
| 697 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_name=?`, a.Name).Scan(&existing); errors.Is(err, sql.ErrNoRows) { | ||
| 698 | return fmt.Errorf("identity: %s is not an instance admin", a.Name) | ||
| 699 | } else if err != nil { | ||
| 700 | return err | ||
| 701 | } | ||
| 702 | var activeAdmins int | ||
| 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 { | ||
| 704 | return err | ||
| 705 | } | ||
| 706 | if activeAdmins <= 1 { | ||
| 707 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before revoking this one", a.Name) | ||
| 729 | if a.Role.AtLeast(access.Admin) && role < access.Admin { | ||
| 730 | var activeAdmins int | ||
| 731 | if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil { | ||
| 732 | return err | ||
| 733 | } | ||
| 734 | if activeAdmins <= 1 { | ||
| 735 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before revoking this one", a.Name) | ||
| 736 | } | ||
| 708 | 737 | } | |
| 709 | _, err = tx.Exec(`DELETE FROM instance_admins WHERE user_name=?`, a.Name) | ||
| 738 | _, err = tx.Exec(`UPDATE users SET instance_role=? WHERE name=?`, role.String(), a.Name) | ||
| 710 | 739 | return err | |
| 711 | 740 | }) | |
| 712 | 741 | if err != nil { | |
| @@ -715,6 +744,28 @@ func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error | |||
| 715 | 744 | return s.Find(name) | |
| 716 | 745 | } | |
| 717 | 746 | ||
| 747 | // SetInstanceRole changes an account's instance-wide role. It refuses to remove | ||
| 748 | // the last active instance admin, so a remote mistake cannot lock everybody out. | ||
| 749 | func (s *Store) SetInstanceRole(name string, role access.Role, actor string) (*Account, error) { | ||
| 750 | return s.setInstanceRole(name, role, actor, "identity.set-role", "role="+role.String()) | ||
| 751 | } | ||
| 752 | |||
| 753 | // SetAdmin is the historical spelling of setting or clearing the Admin role. It | ||
| 754 | // stays so existing CLI scripts and remote callers keep working. | ||
| 755 | func (s *Store) SetAdmin(name string, admin bool, actor string) (*Account, error) { | ||
| 756 | if admin { | ||
| 757 | return s.setInstanceRole(name, access.Admin, actor, "identity.grant-admin", "role=admin") | ||
| 758 | } | ||
| 759 | a, err := s.Find(name) | ||
| 760 | if err != nil { | ||
| 761 | return nil, err | ||
| 762 | } | ||
| 763 | if !a.Role.AtLeast(access.Admin) { | ||
| 764 | return nil, fmt.Errorf("identity: %s is not an instance admin", a.Name) | ||
| 765 | } | ||
| 766 | return s.setInstanceRole(name, access.None, actor, "identity.revoke-admin", "role=none") | ||
| 767 | } | ||
| 768 | |||
| 718 | 769 | // Lookup resolves a key fingerprint to an active account name. | |
| 719 | 770 | func (s *Store) Lookup(fingerprint string) (name string, ok bool) { | |
| 720 | 771 | return s.Snapshot().Lookup(fingerprint) | |
| @@ -788,6 +839,13 @@ func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx | |||
| 788 | 839 | // An account that will use a passkey instead is created with CreateWithInvite; | |
| 789 | 840 | // the invite is the credential-shaped way in. | |
| 790 | 841 | func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | |
| 842 | return s.CreateWithRole(name, access.None, authorizedKey, actor) | ||
| 843 | } | ||
| 844 | |||
| 845 | // CreateWithRole creates an account with an instance-wide role and its first SSH | ||
| 846 | // key in one transaction. The role is account standing, not a repository rule: | ||
| 847 | // it applies to public repositories, while Admin applies to every repository. | ||
| 848 | func (s *Store) CreateWithRole(name string, role access.Role, authorizedKey, actor string) (*Account, error) { | ||
| 791 | 849 | if err := ValidateName(name); err != nil { | |
| 792 | 850 | return nil, err | |
| 793 | 851 | } | |
| @@ -796,8 +854,8 @@ func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | |||
| 796 | 854 | return nil, err | |
| 797 | 855 | } | |
| 798 | 856 | now := time.Now().UTC() | |
| 799 | err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint, func(tx *sql.Tx) error { | ||
| 800 | return createAccount(tx, name, now, &key, nil) | ||
| 857 | err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint+" role="+role.String(), func(tx *sql.Tx) error { | ||
| 858 | return createAccount(tx, name, role, now, &key, nil) | ||
| 801 | 859 | }) | |
| 802 | 860 | if err != nil { | |
| 803 | 861 | return nil, err | |
| @@ -813,7 +871,10 @@ type pendingInvite struct { | |||
| 813 | 871 | createdBy string | |
| 814 | 872 | } | |
| 815 | 873 | ||
| 816 | func createAccount(tx *sql.Tx, name string, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 874 | func createAccount(tx *sql.Tx, name string, role access.Role, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 875 | if role < access.None || role > access.Admin { | ||
| 876 | return fmt.Errorf("identity: invalid role %d", role) | ||
| 877 | } | ||
| 817 | 878 | if owner, err := nameOwner(tx, name); err != nil { | |
| 818 | 879 | return err | |
| 819 | 880 | } else if owner != "" { | |
| @@ -826,8 +887,8 @@ func createAccount(tx *sql.Tx, name string, now time.Time, key *Key, invite *pen | |||
| 826 | 887 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 827 | 888 | } | |
| 828 | 889 | } | |
| 829 | if _, err := tx.Exec(`INSERT INTO users(name, status, created) VALUES(?, 'active', ?)`, | ||
| 830 | name, now.Unix()); err != nil { | ||
| 890 | if _, err := tx.Exec(`INSERT INTO users(name, status, created, instance_role) VALUES(?, 'active', ?, ?)`, | ||
| 891 | name, now.Unix(), role.String()); err != nil { | ||
| 831 | 892 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 832 | 893 | } | |
| 833 | 894 | if key != nil { | |
| @@ -861,17 +922,14 @@ func (s *Store) Retire(name, actor string) (*Account, error) { | |||
| 861 | 922 | if a.Status == "retired" { | |
| 862 | 923 | return fmt.Errorf("identity: %s is already retired", a.Name) | |
| 863 | 924 | } | |
| 864 | var isAdmin bool | ||
| 865 | if err := tx.QueryRow(`SELECT 1 FROM instance_admins WHERE user_name=?`, a.Name).Scan(&isAdmin); err == nil && isAdmin { | ||
| 925 | if a.Role.AtLeast(access.Admin) { | ||
| 866 | 926 | var activeAdmins int | |
| 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 { | ||
| 927 | if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil { | ||
| 868 | 928 | return err | |
| 869 | 929 | } | |
| 870 | 930 | if activeAdmins <= 1 { | |
| 871 | 931 | return fmt.Errorf("identity: %s is the last instance admin; grant another admin before retiring this one", a.Name) | |
| 872 | 932 | } | |
| 873 | } else if err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| 874 | return err | ||
| 875 | 933 | } | |
| 876 | 934 | if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE name=?`, a.Name); err != nil { | |
| 877 | 935 | return fmt.Errorf("identity: retire %s: %w", a.Name, err) | |
| @@ -1018,14 +1076,21 @@ func nameOwner(tx *sql.Tx, name string) (string, error) { | |||
| 1018 | 1076 | func findTx(tx *sql.Tx, name string) (*Account, error) { | |
| 1019 | 1077 | var a Account | |
| 1020 | 1078 | var created int64 | |
| 1021 | err := tx.QueryRow(`SELECT name, status, created FROM users WHERE name=?`, name). | ||
| 1022 | Scan(&a.Name, &a.Status, &created) | ||
| 1079 | var role string | ||
| 1080 | err := tx.QueryRow(`SELECT name, status, created, instance_role FROM users WHERE name=?`, name). | ||
| 1081 | Scan(&a.Name, &a.Status, &created, &role) | ||
| 1023 | 1082 | if errors.Is(err, sql.ErrNoRows) { | |
| 1024 | 1083 | return nil, fmt.Errorf("identity: no account %q", name) | |
| 1025 | 1084 | } | |
| 1026 | 1085 | if err != nil { | |
| 1027 | 1086 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 1028 | 1087 | } | |
| 1088 | r, err := access.ParseRole(role) | ||
| 1089 | if err != nil { | ||
| 1090 | return nil, fmt.Errorf("identity: account %s: instance role: %w", a.Name, err) | ||
| 1091 | } | ||
| 1092 | a.Role = r | ||
| 1093 | a.Admin = r.AtLeast(access.Admin) | ||
| 1029 | 1094 | a.Created = time.Unix(created, 0).UTC() | |
| 1030 | 1095 | return &a, nil | |
| 1031 | 1096 | } | |