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.
Let an account be a bot
29a795b9d5b79cf5c8f2b7f3b4f6572f7cdff444 · Keenan Nemetz · 7d ago · parent e7aa0785
Let an account be a bot An automation needs an identity: something to assign an issue to, mention in a comment, and name in an approval and the audit log. An account already gives all of that, so this adds only the invariants a bot needs and the word for it. A bot is created with `vole admin account create --bot`, with the repository role the admin decides it should have. It has no browser sign-in — `account invite` and adding a passkey are refused — and it cannot hold the instance-admin role, because Admin reaches every repository and every instance surface while a bot is a repository automation. It is marked as a bot in the user directory and on its profile. Everything else is deliberately unchanged. A bot obeys the rules of its role: a contribute bot pushes only its own contributor branch and its CI waits for a maintainer to allow the tip; a maintain bot pushes shared branches, runs CI immediately, and its approval counts. The kind is fixed at creation, like the name, and a roster that predates it reads every account as a person. This is the first of three slices of #165. The per-run credential that lets a workflow act as a bot without a long-lived key in a repository secret is next; this slice changes no push rule and no permission.
forge/identity/identity.go +118−9
| @@ -71,8 +71,30 @@ type Key struct { | |||
| 71 | 71 | Comment string `json:"comment,omitempty"` | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | // Kinds of account. A bot is an automation identity: it holds repository roles and | ||
| 75 | // keys like a person, but it has no browser sign-in and cannot be an instance admin, | ||
| 76 | // because nothing it does needs either. The kind is fixed at creation for the same | ||
| 77 | // reason the name is — branches, audit lines and access rules already refer to the | ||
| 78 | // account — and there is no command that changes one. | ||
| 79 | const ( | ||
| 80 | KindPerson = "person" | ||
| 81 | KindBot = "bot" | ||
| 82 | ) | ||
| 83 | |||
| 84 | // ValidKind reports whether a value is an account kind. | ||
| 85 | func ValidKind(kind string) error { | ||
| 86 | switch kind { | ||
| 87 | case KindPerson, KindBot: | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | return fmt.Errorf("%q is not an account kind; use %q or %q", kind, KindPerson, KindBot) | ||
| 91 | } | ||
| 92 | |||
| 74 | 93 | type Account struct { | |
| 75 | Name string // fixed at creation; contributor branches are named after it | ||
| 94 | Name string // fixed at creation; contributor branches are named after it | ||
| 95 | // Kind is KindPerson or KindBot, fixed at creation. An account read from a | ||
| 96 | // roster that predates the column is a person. | ||
| 97 | Kind string | ||
| 76 | 98 | Status string // active | retired | |
| 77 | 99 | Keys []Key | |
| 78 | 100 | // Role is the account's instance-wide role. It applies to public | |
| @@ -86,6 +108,10 @@ type Account struct { | |||
| 86 | 108 | ||
| 87 | 109 | func (a *Account) Active() bool { return a.Status == "active" } | |
| 88 | 110 | ||
| 111 | // Bot reports whether this account is an automation identity. A zero Kind is a | ||
| 112 | // person: every roster before the column existed held people. | ||
| 113 | func (a *Account) Bot() bool { return a.Kind == KindBot } | ||
| 114 | |||
| 89 | 115 | // Snapshot is an immutable view of the roster, swapped atomically so the SSH | |
| 90 | 116 | // auth callback stays a map lookup. | |
| 91 | 117 | type Snapshot struct { | |
| @@ -119,6 +145,7 @@ type Store struct { | |||
| 119 | 145 | const schema = ` | |
| 120 | 146 | CREATE TABLE IF NOT EXISTS users ( | |
| 121 | 147 | name TEXT PRIMARY KEY, | |
| 148 | kind TEXT NOT NULL DEFAULT 'person' CHECK (kind IN ('person','bot')), | ||
| 122 | 149 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | |
| 123 | 150 | created INTEGER NOT NULL, | |
| 124 | 151 | instance_role TEXT NOT NULL DEFAULT 'none' | |
| @@ -200,6 +227,9 @@ func Init(path string) error { | |||
| 200 | 227 | if err := migrateInstanceRoles(db); err != nil { | |
| 201 | 228 | return fmt.Errorf("identity: migrate instance roles: %w", err) | |
| 202 | 229 | } | |
| 230 | if err := migrateAccountKind(db); err != nil { | ||
| 231 | return fmt.Errorf("identity: migrate account kind: %w", err) | ||
| 232 | } | ||
| 203 | 233 | if _, err := db.Exec(schema); err != nil { | |
| 204 | 234 | return fmt.Errorf("identity: create roster: %w", err) | |
| 205 | 235 | } | |
| @@ -365,6 +395,25 @@ CREATE INDEX sessions_expiry ON sessions(expires);`); err != nil { | |||
| 365 | 395 | return tx.Commit() | |
| 366 | 396 | } | |
| 367 | 397 | ||
| 398 | // migrateAccountKind adds the kind column to a roster that predates bots. Every | ||
| 399 | // existing account is a person, which is the default: a column that defaulted to | ||
| 400 | // anything else would turn every operator into an automation on upgrade. | ||
| 401 | func migrateAccountKind(db *sql.DB) error { | ||
| 402 | hasUsers, err := tableExists(db, "users") | ||
| 403 | if err != nil || !hasUsers { | ||
| 404 | return err | ||
| 405 | } | ||
| 406 | hasKind, err := columnExists(db, "users", "kind") | ||
| 407 | if err != nil { | ||
| 408 | return err | ||
| 409 | } | ||
| 410 | if hasKind { | ||
| 411 | return nil | ||
| 412 | } | ||
| 413 | _, err = db.Exec(`ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'person'`) | ||
| 414 | return err | ||
| 415 | } | ||
| 416 | |||
| 368 | 417 | // migrateInstanceRoles turns the old instance_admins table into the instance_role | |
| 369 | 418 | // column on users. An instance admin is now the Admin role in the same ladder as | |
| 370 | 419 | // every other instance-wide grant, so there is one answer to "what may this | |
| @@ -581,6 +630,10 @@ func Open(path string) (*Store, error) { | |||
| 581 | 630 | db.Close() | |
| 582 | 631 | return nil, fmt.Errorf("identity: %w", err) | |
| 583 | 632 | } | |
| 633 | if err := migrateAccountKind(db); err != nil { | ||
| 634 | db.Close() | ||
| 635 | return nil, fmt.Errorf("identity: %w", err) | ||
| 636 | } | ||
| 584 | 637 | if _, err := db.Exec(schema); err != nil { | |
| 585 | 638 | db.Close() | |
| 586 | 639 | return nil, fmt.Errorf("identity: %w", err) | |
| @@ -632,7 +685,7 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 632 | 685 | ByKey: map[string]string{}, | |
| 633 | 686 | ByName: map[string]*Account{}, | |
| 634 | 687 | } | |
| 635 | rows, err := s.db.Query(`SELECT name, status, created, instance_role FROM users ORDER BY name`) | ||
| 688 | rows, err := s.db.Query(`SELECT name, kind, status, created, instance_role FROM users ORDER BY name`) | ||
| 636 | 689 | if err != nil { | |
| 637 | 690 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 638 | 691 | } | |
| @@ -641,9 +694,15 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 641 | 694 | var a Account | |
| 642 | 695 | var status, role string | |
| 643 | 696 | var created int64 | |
| 644 | if err := rows.Scan(&a.Name, &status, &created, &role); err != nil { | ||
| 697 | if err := rows.Scan(&a.Name, &a.Kind, &status, &created, &role); err != nil { | ||
| 645 | 698 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 646 | 699 | } | |
| 700 | if a.Kind == "" { | ||
| 701 | a.Kind = KindPerson | ||
| 702 | } | ||
| 703 | if err := ValidKind(a.Kind); err != nil { | ||
| 704 | return nil, fmt.Errorf("identity: account %s: %w", a.Name, err) | ||
| 705 | } | ||
| 647 | 706 | if err := ValidateName(a.Name); err != nil { | |
| 648 | 707 | return nil, fmt.Errorf("identity: account %s: %w", a.Name, err) | |
| 649 | 708 | } | |
| @@ -748,6 +807,9 @@ func (s *Store) setInstanceRole(name string, role access.Role, actor, action, de | |||
| 748 | 807 | if !a.Active() { | |
| 749 | 808 | return fmt.Errorf("identity: %s is retired", a.Name) | |
| 750 | 809 | } | |
| 810 | if a.Bot() && role.AtLeast(access.Admin) { | ||
| 811 | return fmt.Errorf("identity: %s is a bot account and cannot be an instance admin", a.Name) | ||
| 812 | } | ||
| 751 | 813 | if a.Role.AtLeast(access.Admin) && role < access.Admin { | |
| 752 | 814 | var activeAdmins int | |
| 753 | 815 | if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE status='active' AND instance_role='admin'`).Scan(&activeAdmins); err != nil { | |
| @@ -882,7 +944,42 @@ func (s *Store) CreateWithRole(name string, role access.Role, authorizedKey, act | |||
| 882 | 944 | } | |
| 883 | 945 | now := time.Now().UTC() | |
| 884 | 946 | err = s.mutate("identity.create", actor, name, name+" "+key.Fingerprint+" role="+role.String(), func(tx *sql.Tx) error { | |
| 885 | return createAccount(tx, name, role, now, &key, nil) | ||
| 947 | return createAccount(tx, name, KindPerson, role, now, &key, nil) | ||
| 948 | }) | ||
| 949 | if err != nil { | ||
| 950 | return nil, err | ||
| 951 | } | ||
| 952 | return s.Find(name) | ||
| 953 | } | ||
| 954 | |||
| 955 | // CreateBot creates an automation account: an identity that can be assigned work, | ||
| 956 | // mentioned, and attributed, but cannot sign in to the browser. The role is the | ||
| 957 | // account's instance-wide standing, and the same rule as for a person applies: a | ||
| 958 | // bot may not be an instance admin. Repository roles are granted separately, by the | ||
| 959 | // admin who decides what this automation may do. | ||
| 960 | // | ||
| 961 | // A bot may be created without a key. That account cannot act until one is added or | ||
| 962 | // until the instance issues it a per-run credential, which is the point: a key is a | ||
| 963 | // long-lived grant and a per-run credential is not. | ||
| 964 | func (s *Store) CreateBot(name string, role access.Role, authorizedKey, actor string) (*Account, error) { | ||
| 965 | if err := ValidateName(name); err != nil { | ||
| 966 | return nil, err | ||
| 967 | } | ||
| 968 | var key *Key | ||
| 969 | if strings.TrimSpace(authorizedKey) != "" { | ||
| 970 | parsed, err := parseKey(authorizedKey) | ||
| 971 | if err != nil { | ||
| 972 | return nil, err | ||
| 973 | } | ||
| 974 | key = &parsed | ||
| 975 | } | ||
| 976 | now := time.Now().UTC() | ||
| 977 | detail := "kind=bot role=" + role.String() | ||
| 978 | if key != nil { | ||
| 979 | detail = key.Fingerprint + " " + detail | ||
| 980 | } | ||
| 981 | err := s.mutate("identity.create-bot", actor, name, detail, func(tx *sql.Tx) error { | ||
| 982 | return createAccount(tx, name, KindBot, role, now, key, nil) | ||
| 886 | 983 | }) | |
| 887 | 984 | if err != nil { | |
| 888 | 985 | return nil, err | |
| @@ -898,10 +995,19 @@ type pendingInvite struct { | |||
| 898 | 995 | createdBy string | |
| 899 | 996 | } | |
| 900 | 997 | ||
| 901 | func createAccount(tx *sql.Tx, name string, role access.Role, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 998 | func createAccount(tx *sql.Tx, name, kind string, role access.Role, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 902 | 999 | if role < access.None || role > access.Admin { | |
| 903 | 1000 | return fmt.Errorf("identity: invalid role %d", role) | |
| 904 | 1001 | } | |
| 1002 | if err := ValidKind(kind); err != nil { | ||
| 1003 | return fmt.Errorf("identity: %w", err) | ||
| 1004 | } | ||
| 1005 | // An instance admin reaches every repository and every admin surface; a bot | ||
| 1006 | // exists to act in repositories, so admin is not a role it may hold. Repository | ||
| 1007 | // roles are unaffected: a bot may be a maintainer of one repository. | ||
| 1008 | if kind == KindBot && role.AtLeast(access.Admin) { | ||
| 1009 | return fmt.Errorf("identity: %s would be an instance admin; a bot account cannot hold that role", name) | ||
| 1010 | } | ||
| 905 | 1011 | if owner, err := nameOwner(tx, name); err != nil { | |
| 906 | 1012 | return err | |
| 907 | 1013 | } else if owner != "" { | |
| @@ -914,8 +1020,8 @@ func createAccount(tx *sql.Tx, name string, role access.Role, now time.Time, key | |||
| 914 | 1020 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 915 | 1021 | } | |
| 916 | 1022 | } | |
| 917 | if _, err := tx.Exec(`INSERT INTO users(name, status, created, instance_role) VALUES(?, 'active', ?, ?)`, | ||
| 918 | name, now.Unix(), role.String()); err != nil { | ||
| 1023 | if _, err := tx.Exec(`INSERT INTO users(name, kind, status, created, instance_role) VALUES(?, ?, 'active', ?, ?)`, | ||
| 1024 | name, kind, now.Unix(), role.String()); err != nil { | ||
| 919 | 1025 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 920 | 1026 | } | |
| 921 | 1027 | if key != nil { | |
| @@ -1104,14 +1210,17 @@ func findTx(tx *sql.Tx, name string) (*Account, error) { | |||
| 1104 | 1210 | var a Account | |
| 1105 | 1211 | var created int64 | |
| 1106 | 1212 | var role string | |
| 1107 | err := tx.QueryRow(`SELECT name, status, created, instance_role FROM users WHERE name=?`, name). | ||
| 1108 | Scan(&a.Name, &a.Status, &created, &role) | ||
| 1213 | err := tx.QueryRow(`SELECT name, kind, status, created, instance_role FROM users WHERE name=?`, name). | ||
| 1214 | Scan(&a.Name, &a.Kind, &a.Status, &created, &role) | ||
| 1109 | 1215 | if errors.Is(err, sql.ErrNoRows) { | |
| 1110 | 1216 | return nil, fmt.Errorf("identity: no account %q", name) | |
| 1111 | 1217 | } | |
| 1112 | 1218 | if err != nil { | |
| 1113 | 1219 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 1114 | 1220 | } | |
| 1221 | if a.Kind == "" { | ||
| 1222 | a.Kind = KindPerson | ||
| 1223 | } | ||
| 1115 | 1224 | r, err := access.ParseRole(role) | |
| 1116 | 1225 | if err != nil { | |
| 1117 | 1226 | return nil, fmt.Errorf("identity: account %s: instance role: %w", a.Name, err) | |