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.

Name branches after the account, and labels after the display

474a812d199a109ba894124cd6f26be12c73285d · Keenan Nemetz · 16d ago · parent ade6107e

Name branches after the account, and labels after the display

A contributor's folder was `refs/heads/contrib/<account-id>/`, which told a
reader nothing without decoding a ULID. It is now the account name,

    refs/heads/contrib/<name>/<branch>

and the name is therefore fixed at creation: renaming an account would strand
every ref under its old folder, so `vole admin user rename` is gone and the
users row itself — kept after retirement — is what stops a name being reissued.

What people see is now a separate, mutable display name, validated by the same
rules wherever it is set (CLI, remote admin, registration, `/account`) and shown
on pages, approvals, listings and refusals. Nothing is authorized by it and
nothing on disk is keyed by it: metadata authors stay account names because the
push boundary resolves them for ownership, and approvals stay account ids.

`user_names`, `Snapshot.Reserved` and `Account.Former` are dropped: with a fixed
name there are no former names to remember, and the UNIQUE column already does
the reserving. Opening an older roster adds the `display` column and drops the
alias table in place; contributor refs written under the old id namespace are
left where they are, readable, and landable by a maintainer.

forge/access/acl.go +19−23

@@ -198,26 +198,29 @@ func (a *ACL) RoleFor(user string) Role {
198 198 func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
199 199
200 200 // ContributorBranchPrefix is the only branch namespace a contributor may push.
201 // The account id is in the path, so one contributor cannot move another's branch
202 // and a maintainer can see whose work a ref belongs to without a join.
203 func ContributorBranchPrefix(accountID string) string {
204 return "refs/heads/contrib/" + accountID + "/"
201 // The account name is in the path: it is chosen by the person, fixed at creation
202 // and never reissued, so one contributor cannot move another's branch and a
203 // maintainer can see whose work a ref belongs to without a join. No rename can
204 // strand a branch, because there is nothing to rename.
205 func ContributorBranchPrefix(name string) string {
206 return "refs/heads/contrib/" + name + "/"
205 207 }
206 208
207 // IsContributorRef reports whether ref is a non-empty branch inside accountID's
208 // namespace.
209 func IsContributorRef(accountID, ref string) bool {
210 if accountID == "" {
209 // IsContributorRef reports whether ref is a non-empty branch inside name's
210 // namespace. name is the account name, never a display name.
211 func IsContributorRef(name, ref string) bool {
212 if name == "" {
211 213 return false
212 214 }
213 prefix := ContributorBranchPrefix(accountID)
215 prefix := ContributorBranchPrefix(name)
214 216 return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix)
215 217 }
216 218
217 // ContributorRefAccount returns the account id embedded in a contributor ref.
219 // ContributorRefName returns the account name embedded in a contributor ref.
218 220 // It is the reverse of ContributorBranchPrefix, used by the CI gate before it
219 // has a principal in hand.
220 func ContributorRefAccount(ref string) (accountID string, ok bool) {
221 // has a principal in hand. The name is a path component, so it is returned as
222 // written; resolving it to an account is the roster's job.
223 func ContributorRefName(ref string) (name string, ok bool) {
221 224 const prefix = "refs/heads/contrib/"
222 225 if !strings.HasPrefix(ref, prefix) {
223 226 return "", false
@@ -238,19 +241,12 @@ func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Cont
238 241 // branch the caller may push: the web checks the contributor namespace.
239 242 func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
240 243
241 // CanPush says the account may push somewhere. Use CanPushRef to ask about a
242 // particular ref; a contributor may only push their own namespace.
244 // CanPush says the account may push somewhere. Which refs a contributor may
245 // write is a namespace question and needs the account *name*, not the id the ACL
246 // is keyed by, so that half of the rule lives where the push is gate-kept: the
247 // pre-receive hook, which has the name the SSH key resolved to.
243 248 func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
244 249
245 // CanPushRef is the ref-level answer the pre-receive gate needs.
246 func (a *ACL) CanPushRef(user, ref string) bool {
247 role := a.RoleFor(user)
248 if role.AtLeast(Maintain) {
249 return true
250 }
251 return role == Contributor && IsContributorRef(user, ref)
252 }
253
254 250 // CanTriage covers changing somebody else's issue: labels, assignees, state.
255 251 func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
256 252