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