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_test.go +69

@@ -508,6 +508,11 @@ INSERT INTO lfs_locks(id, repo_id, path, owner, locked_at) VALUES('lock-1', 'rep
508 508 if a.Name != "wilbur" || !a.Admin || len(a.Keys) != 1 {
509 509 t.Fatalf("upgraded account = %+v", a)
510 510 }
511 // An account that predates the kind column is a person: defaulting an upgrade
512 // to bot would turn every operator into an automation that cannot sign in.
513 if a.Kind != KindPerson || a.Bot() {
514 t.Fatalf("upgraded account kind = %q, want %q", a.Kind, KindPerson)
515 }
511 516 if name, ok := s.Lookup(a.Keys[0].Fingerprint); !ok || name != "wilbur" {
512 517 t.Fatalf("upgraded key resolves to %q,%v", name, ok)
513 518 }
@@ -751,3 +756,67 @@ func TestMutationsFailWhenTheLogCannotBeWritten(t *testing.T) {
751 756 t.Fatal("the account was created anyway")
752 757 }
753 758 }
759
760 // A bot is an account with no browser: it can hold a repository role and a key, it
761 // cannot be given a passkey or an enrollment link, and it cannot be an instance
762 // admin. The kind is fixed at creation, like the name.
763 func TestBotAccountsHaveNoBrowser(t *testing.T) {
764 t.Parallel()
765 s := newStore(t)
766 line, _ := pubLine(t)
767
768 bot, err := s.CreateBot("coding-agent", access.Contribute, line, "admin")
769 if err != nil {
770 t.Fatalf("create bot: %v", err)
771 }
772 if !bot.Bot() || bot.Kind != KindBot {
773 t.Fatalf("kind = %q, want %q", bot.Kind, KindBot)
774 }
775 // The roster carries the kind, so every reader of Snapshot sees it without a
776 // second lookup.
777 if got := s.Snapshot().ByName["coding-agent"]; got == nil || !got.Bot() {
778 t.Fatalf("the roster lost the kind: %+v", got)
779 }
780 // A person is unchanged: the default is what an upgrade gives every existing
781 // account.
782 person := mustCreate(t, s, "alice")
783 if person.Bot() || person.Kind != KindPerson {
784 t.Fatalf("a person became %q", person.Kind)
785 }
786
787 // No passkey, no invite link: a bot has no browser to sign in from, so a
788 // browser credential would be a secret nobody can use.
789 cred := testCredential(t, "phone")
790 if err := s.AddCredential("coding-agent", cred, "admin"); err == nil {
791 t.Fatal("a bot account was given a passkey")
792 }
793 if _, err := s.Invite("coding-agent", "admin", 0); err == nil {
794 t.Fatal("a bot account was given an enrollment link")
795 }
796
797 // An instance admin reaches every repository; a bot's role is a repository
798 // grant, so admin is refused at creation and when changing the role later.
799 if _, err := s.CreateBot("admin-bot", access.Admin, "", "admin"); err == nil {
800 t.Fatal("a bot account was created as an instance admin")
801 }
802 if _, err := s.SetInstanceRole("coding-agent", access.Admin, "admin"); err == nil {
803 t.Fatal("a bot account was promoted to instance admin")
804 }
805 }
806
807 // A bot with a repository role is otherwise an ordinary account: it can be
808 // assigned, mentioned, and found by name.
809 func TestABotIsAnAccount(t *testing.T) {
810 t.Parallel()
811 s := newStore(t)
812 if _, err := s.CreateBot("coding-agent", access.None, "", "admin"); err != nil {
813 t.Fatalf("create bot without a key: %v", err)
814 }
815 got, err := s.Find("coding-agent")
816 if err != nil || got == nil || !got.Bot() {
817 t.Fatalf("find bot: %+v %v", got, err)
818 }
819 if len(got.Keys) != 0 {
820 t.Fatalf("a keyless bot has keys: %v", got.Keys)
821 }
822 }