svcrole: Self, outside the catalogue, and AllowSelf (#155)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+77
−0
internal/svcrole/svcrole.go+23 −0internal/svcrole/svcrole_test.go+54 −0
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go| index 9c0820a95..9df952fea 100644 |
| --- a/internal/svcrole/svcrole.go |
| +++ b/internal/svcrole/svcrole.go |
| @@ -64,6 +64,29 @@ const ( |
| // catalogue is the whole vocabulary, in the order All returns it. |
| var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin} |
| +// Self is what a kind='instance' credential resolves to: the authority of |
| +// one instance over itself (spec 2026-10-16-instance-bound-tokens §3.3). |
| +// |
| +// It is deliberately NOT in the catalogue. Parse refuses it, so no service |
| +// row can be minted with it, and All omits it, so no mint door or form can |
| +// offer it. Allow refuses it too, because Allow adds Admin to every set and |
| +// Admin is app-wide: a door opened to Self through Allow would open to every |
| +// Admin credential, which has no host to be checked against. AllowSelf is |
| +// the only way a door admits it. |
| +const Self Role = "self" |
| + |
| +// AllowSelf returns base with Self added and nothing else: AllowSelf(Set{}) |
| +// admits Self alone (not Admin), and AllowSelf(Allow(Instance)) admits |
| +// Instance, Admin and Self. base is not modified. |
| +func AllowSelf(base Set) Set { |
| + s := Set{roles: make(map[Role]bool, len(base.roles)+1)} |
| + for r := range base.roles { |
| + s.roles[r] = true |
| + } |
| + s.roles[Self] = true |
| + return s |
| +} |
| + |
| // All returns the catalogue. The slice is copied so a caller cannot edit the |
| // vocabulary by writing through it. |
| func All() []Role { |
diff --git a/internal/svcrole/svcrole_test.go b/internal/svcrole/svcrole_test.go| index a5775141f..03e84f4ed 100644 |
| --- a/internal/svcrole/svcrole_test.go |
| +++ b/internal/svcrole/svcrole_test.go |
| @@ -133,3 +133,57 @@ func TestDeployIsMintableAndNarrow(t *testing.T) { |
| t.Fatal("Allow adds Admin to every set; that changed") |
| } |
| } |
| + |
| +// Self is what an instance-bound credential resolves to (spec |
| +// 2026-10-16-instance-bound-tokens §3.3). It must never be storable on a |
| +// service row, nor offered by a mint door or form. |
| +func TestSelfIsNotMintable(t *testing.T) { |
| + if _, ok := Parse("self"); ok { |
| + t.Fatal(`Parse("self") succeeded; Self must never be stored on a service row`) |
| + } |
| + for _, r := range All() { |
| + if r == Self { |
| + t.Fatal("Self is in All(); the mint door and form would offer it") |
| + } |
| + } |
| +} |
| + |
| +// Allow adds Admin to every set, so a door opened to Self through Allow |
| +// would open to every Admin credential too, which has no host to check. |
| +func TestAllowRefusesSelf(t *testing.T) { |
| + defer func() { |
| + if recover() == nil { |
| + t.Fatal("Allow(Self) did not panic; it would admit Admin with it") |
| + } |
| + }() |
| + Allow(Self) |
| +} |
| + |
| +func TestAllowSelfAddsOnlySelf(t *testing.T) { |
| + closed := AllowSelf(Set{}) |
| + if !closed.Permits(Self) { |
| + t.Fatal("AllowSelf(Set{}) refuses Self") |
| + } |
| + for _, r := range All() { |
| + if closed.Permits(r) { |
| + t.Fatalf("AllowSelf(Set{}) admits %q", r) |
| + } |
| + } |
| + if closed.Empty() { |
| + t.Fatal("AllowSelf(Set{}) reads as a closed door") |
| + } |
| + open := AllowSelf(Allow(Instance)) |
| + for _, r := range []Role{Self, Instance, Admin} { |
| + if !open.Permits(r) { |
| + t.Fatalf("AllowSelf(Allow(Instance)) refuses %q", r) |
| + } |
| + } |
| + if open.Permits(Publish) { |
| + t.Fatal("AllowSelf widened the base set") |
| + } |
| + base := Allow(Instance) |
| + _ = AllowSelf(base) |
| + if base.Permits(Self) { |
| + t.Fatal("AllowSelf mutated its argument") |
| + } |
| +} |