svcrole: Allow refuses the run role, so no Allow set can carry Admin through a run door (PRO-6214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+45
−3
internal/svcrole/svcrole.go+8 −2internal/svcrole/svcrole_test.go+37 −1
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go| index a6f4da0b7..6c212162b 100644 |
| --- a/internal/svcrole/svcrole.go |
| +++ b/internal/svcrole/svcrole.go |
| @@ -57,8 +57,8 @@ const ( |
| Service Role = "service" |
| // Run is a credential that starts `carlos run` runs: one-off commands on |
| // the platform's own hosts. It is deliberately outside the union Admin |
| - // stands for. Allow never adds it to a set, and nothing that lists |
| - // Admin reaches it; a door admits it only by being registered through |
| + // stands for. Allow refuses it (it panics), because every Allow set |
| + // carries Admin; a door admits it only by being registered through |
| // Exactly (spec 2026-10-24-service-runs-design.md § 1). |
| Run Role = "run" |
| // Admin is the union of every role except Run, plus ledgers, store and |
| @@ -129,6 +129,12 @@ func Allow(roles ...Role) Set { |
| // compile error, which is why this costs nothing in practice. |
| panic("svcrole: Allow called with an unknown role " + strconv.Quote(string(r))) |
| } |
| + if r == Run { |
| + // Every Allow set carries Admin, so Allow(Run) would let an |
| + // administrator through a run door: the one thing Run exists |
| + // to prevent. Fail loud at registration, like a typo. |
| + panic("svcrole: Allow called with the run role, which Admin must not reach; use Exactly(Run)") |
| + } |
| s.roles[r] = true |
| } |
| s.roles[Admin] = true |
diff --git a/internal/svcrole/svcrole_test.go b/internal/svcrole/svcrole_test.go| index 180566e06..8b5412d48 100644 |
| --- a/internal/svcrole/svcrole_test.go |
| +++ b/internal/svcrole/svcrole_test.go |
| @@ -2,6 +2,7 @@ package svcrole |
| import ( |
| "reflect" |
| + "strings" |
| "testing" |
| ) |
| @@ -37,8 +38,11 @@ func TestAdminPermitsEveryNonEmptySet(t *testing.T) { |
| if Allow(Publish).Permits(Run) { |
| t.Error("Allow(Publish).Permits(Run) = true; want false") |
| } |
| +} |
| + |
| +func TestExactlySetsAdmitOnlyTheirRoles(t *testing.T) { |
| // An Exactly set admits only its listed roles, Admin included. |
| - for _, s := range []Set{Exactly(Run), Exactly(Run, Publish), Exactly(Publish)} { |
| + for _, s := range []Set{Exactly(Run), Exactly(Run, Publish), Exactly(Publish), Exactly(Admin)} { |
| for _, r := range All() { |
| listed := false |
| for _, l := range s.Roles() { |
| @@ -51,6 +55,38 @@ func TestAdminPermitsEveryNonEmptySet(t *testing.T) { |
| } |
| } |
| +func TestExactlyWithNoRolesIsEmptyAndClosed(t *testing.T) { |
| + s := Exactly() |
| + if !s.Empty() { |
| + t.Error("Exactly().Empty() = false; want true") |
| + } |
| + for _, r := range append(All(), Role("")) { |
| + if s.Permits(r) { |
| + t.Errorf("Exactly().Permits(%q) = true; want false", r) |
| + } |
| + } |
| +} |
| + |
| +func TestAllowRefusesRun(t *testing.T) { |
| + // Every Allow set carries Admin, so Allow(Run) would hand Admin a run |
| + // door. Both the bare and the mixed form must panic, and name Exactly. |
| + for _, roles := range [][]Role{{Run}, {Publish, Run}, {Run, Operate}} { |
| + func() { |
| + defer func() { |
| + got := recover() |
| + if got == nil { |
| + t.Errorf("Allow(%v) did not panic", roles) |
| + return |
| + } |
| + if msg, _ := got.(string); !strings.Contains(msg, "Exactly(Run)") { |
| + t.Errorf("Allow(%v) panic = %v; want it to say to use Exactly(Run)", roles, got) |
| + } |
| + }() |
| + Allow(roles...) |
| + }() |
| + } |
| +} |
| + |
| func TestRunIsInTheCatalogue(t *testing.T) { |
| got, ok := Parse("run") |
| if !ok || got != Run { |