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.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

forge/access/acl_test.go +89−7

@@ -4,7 +4,8 @@ import "testing"
4 4
5 5 func TestRoleOrdering(t *testing.T) {
6 6 t.Parallel()
7 if !Admin.AtLeast(Write) || !Write.AtLeast(Read) || Read.AtLeast(Write) {
7 if !Admin.AtLeast(Maintain) || !Maintain.AtLeast(Contributor) || !Contributor.AtLeast(Read) ||
8 Read.AtLeast(Contributor) || Contributor.AtLeast(Maintain) || Maintain != Write {
8 9 t.Fatal("role ordering is wrong")
9 10 }
10 11 for _, c := range []struct {
@@ -15,7 +16,12 @@ func TestRoleOrdering(t *testing.T) {
15 16 {"none", None, true},
16 17 {"", None, true},
17 18 {"read", Read, true},
18 {"write", Write, true},
19 {"contributor", Contributor, true},
20 {"contribute", Contributor, true},
21 {"contrib", Contributor, true},
22 {"maintain", Maintain, true},
23 {"maintainer", Maintain, true},
24 {"write", Maintain, true},
19 25 {"admin", Admin, true},
20 26 {"owner", Admin, true},
21 27 {"ADMIN", Admin, true},
@@ -30,17 +36,31 @@ func TestRoleOrdering(t *testing.T) {
30 36 }
31 37 }
32 38 }
39 func TestWriteIsTheHistoricalSpellingOfMaintain(t *testing.T) {
40 t.Parallel()
41 if got, err := ParseRole("write"); err != nil || got != Maintain || got.String() != "maintain" {
42 t.Fatalf("ParseRole(write) = %v, %v", got, err)
43 }
44 a, err := Parse([]byte(`{"version":1,"default":"write","entries":{"alice":"write"}}`))
45 if err != nil {
46 t.Fatal(err)
47 }
48 if a.Default != "maintain" || a.Entries["alice"] != "maintain" {
49 t.Fatalf("old spelling was not normalized: %+v", a)
50 }
51 }
52
33 53 func TestRoleFor(t *testing.T) {
34 54 t.Parallel()
35 55 a := &ACL{Default: "none", Entries: map[string]string{
36 "owner": "admin", "dev": "write", "guest": "read",
56 "owner": "admin", "dev": "write", "guest": "read", "contributor": "contributor",
37 57 }}
38 58 cases := []struct {
39 59 user string
40 60 want Role
41 61 }{
42 {"owner", Admin}, {"dev", Write}, {"guest", Read},
43 {"stranger", None}, {"", None},
62 {"owner", Admin}, {"dev", Maintain}, {"guest", Read},
63 {"contributor", Contributor}, {"stranger", None}, {"", None},
44 64 }
45 65 for _, c := range cases {
46 66 if got := a.RoleFor(c.user); got != c.want {
@@ -48,16 +68,78 @@ func TestRoleFor(t *testing.T) {
48 68 }
49 69 }
50 70 }
71 func TestCapabilitiesFollowTheRole(t *testing.T) {
72 t.Parallel()
73 a := &ACL{Default: "read", Entries: map[string]string{
74 "owner": "admin", "dev": "write", "guest": "read", "contributor": "contributor",
75 }}
76 for _, c := range []struct {
77 user string
78 discuss, push, triage, approve bool
79 }{
80 {"owner", true, true, true, true},
81 {"dev", true, true, true, true},
82 {"contributor", true, true, false, false},
83 {"guest", false, false, false, false},
84 {"stranger", false, false, false, false},
85 } {
86 if got := a.CanDiscuss(c.user); got != c.discuss {
87 t.Errorf("CanDiscuss(%q)=%v want %v", c.user, got, c.discuss)
88 }
89 if got := a.CanOpenRequest(c.user); got != c.discuss {
90 t.Errorf("CanOpenRequest(%q)=%v want %v", c.user, got, c.discuss)
91 }
92 if got := a.CanPush(c.user); got != c.push {
93 t.Errorf("CanPush(%q)=%v want %v", c.user, got, c.push)
94 }
95 if got := a.CanTriage(c.user); got != c.triage {
96 t.Errorf("CanTriage(%q)=%v want %v", c.user, got, c.triage)
97 }
98 if got := a.CanApprove(c.user); got != c.approve {
99 t.Errorf("CanApprove(%q)=%v want %v", c.user, got, c.approve)
100 }
101 }
102 }
103
104 func TestContributorRefNamespace(t *testing.T) {
105 t.Parallel()
106 a := &ACL{Entries: map[string]string{"alice": "contributor"}}
107 own := "refs/heads/contrib/alice/topic"
108 if id, ok := ContributorRefAccount(own); !ok || id != "alice" {
109 t.Fatalf("ContributorRefAccount(%q)=%q,%v", own, id, ok)
110 }
111 if _, ok := ContributorRefAccount("refs/heads/contrib/alice"); ok {
112 t.Fatal("an empty contributor namespace parsed as a branch")
113 }
114 if !a.CanPushRef("alice", own) {
115 t.Fatalf("contributor cannot push %s", own)
116 }
117 for _, ref := range []string{
118 "refs/heads/main",
119 "refs/heads/contrib/bob/topic",
120 "refs/heads/contrib/alice",
121 "refs/tags/contrib/alice/v1",
122 "refs/heads/vole",
123 } {
124 if a.CanPushRef("alice", ref) {
125 t.Errorf("contributor was allowed to push %s", ref)
126 }
127 }
128 if a.CanPushRef("bob", own) {
129 t.Fatal("a different account could push alice's namespace")
130 }
131 }
132
51 133 func TestDefaultAppliesToUnknownUsers(t *testing.T) {
52 134 t.Parallel()
53 135 a := &ACL{Default: "read", Entries: map[string]string{"owner": "admin"}}
54 136 if !a.Can("anyone", Read) {
55 137 t.Fatal("default read should grant read to an unknown user")
56 138 }
57 if a.Can("anyone", Write) {
139 if a.Can("anyone", Maintain) {
58 140 t.Fatal("default read must not grant write")
59 141 }
60 if !a.Can("owner", Write) {
142 if !a.Can("owner", Maintain) {
61 143 t.Fatal("explicit entry should win")
62 144 }
63 145 }