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/passkey.go +15−2

@@ -102,7 +102,8 @@ func (s *Store) CreatePasskeyWithRole(name string, role access.Role, cred Creden
102 102 }
103 103 now := time.Now().UTC()
104 104 err := s.mutate("identity.create", actor, name, name+" passkey role="+role.String(), func(tx *sql.Tx) error {
105 if err := createAccount(tx, name, role, now, nil, nil); err != nil {
105 // A passkey is a browser credential, and a bot has no browser sign-in.
106 if err := createAccount(tx, name, KindPerson, role, now, nil, nil); err != nil {
106 107 return err
107 108 }
108 109 return insertCredential(tx, name, cred, now)
@@ -135,7 +136,7 @@ func (s *Store) CreateWithInviteRole(name string, role access.Role, actor string
135 136 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
136 137 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
137 138 err = s.mutate("identity.create", actor, name, name+" invite role="+role.String(), func(tx *sql.Tx) error {
138 return createAccount(tx, name, role, now, nil, pending)
139 return createAccount(tx, name, KindPerson, role, now, nil, pending)
139 140 })
140 141 if err != nil {
141 142 return nil, nil, err
@@ -165,6 +166,11 @@ func (s *Store) Invite(name, actor string, ttl time.Duration) (*Invite, error) {
165 166 if !a.Active() {
166 167 return fmt.Errorf("identity: %s is retired", a.Name)
167 168 }
169 // A bot has no browser sign-in, so an enrollment link would be a credential
170 // nobody can use and a secret in the roster for no reason.
171 if a.Bot() {
172 return fmt.Errorf("identity: %s is a bot account; it signs in with a key, not a browser", a.Name)
173 }
168 174 // One live link per account. Marking the old ones used rather than
169 175 // deleting them leaves an audit trail without leaving a usable secret.
170 176 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_name=? AND used_at IS NULL`, now.Unix(), a.Name); err != nil {
@@ -216,6 +222,10 @@ func (s *Store) AcceptInvite(token string, cred Credential, actor string) (*Acco
216 222 if err != nil {
217 223 return nil, err
218 224 }
225 if account.Bot() {
226 // No link should exist for a bot; refuse rather than spend one if one does.
227 return nil, fmt.Errorf("identity: %s is a bot account; it signs in with a key, not a browser", account.Name)
228 }
219 229 token = strings.TrimSpace(token)
220 230 now := time.Now().UTC()
221 231 accountName := account.Name
@@ -277,6 +287,9 @@ func (s *Store) AddCredential(name string, cred Credential, actor string) error
277 287 if err != nil {
278 288 return err
279 289 }
290 if a.Bot() {
291 return fmt.Errorf("identity: %s is a bot account; it has no browser sign-in", a.Name)
292 }
280 293 if !a.Active() {
281 294 return fmt.Errorf("identity: %s is retired", a.Name)
282 295 }