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.

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)