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 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 }