carlos / platform Public

svcrole: the closed role catalogue for service principals

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell 6a3d47b85c7c9b82e63de5615769668276a76a71 parent 4b34528
2 files changed, +204 −0
  • internal/svcrole/svcrole.go +122 −0
  • internal/svcrole/svcrole_test.go +82 −0
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go
new file mode 100644
index 000000000..ca0da7c81
--- /dev/null
+++ b/internal/svcrole/svcrole.go
@@ -0,0 +1,122 @@
+// Package svcrole is the closed role vocabulary for service principals: what
+// a credential held by a SERVER may ask the console API for.
+//
+// WHY NOT internal/caps. caps is the app-manifest vocabulary — systemd
+// directives, privileged ports, database paths, the host-role grant an
+// operator types on a box. It says what a UNIT may do ON A MACHINE. This says
+// what a CALLER may do AT A DOOR. Fusing them would couple a manifest format
+// to a permission model and give each a reason to change that has nothing to
+// do with the other, so they share a spirit and no types.
+//
+// This package deliberately knows NOTHING about routes. The door-to-role
+// binding lives at registration (internal/console's cliRoute), so there is
+// exactly one place a door's authority is written down, and no second table
+// here to drift from it.
+//
+// Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §3.
+package svcrole
+
+import "sort"
+
+// Role is one name from the closed catalogue. A person's cli_tokens row
+// carries the empty string, which is deliberately NOT a Role: authority for a
+// person is their membership set, evaluated per request, and no role name
+// must ever resolve to it.
+type Role string
+
+const (
+ // Publish is the deploy loop: claim an app, ship, promote, roll back,
+ // manage its hostnames, retire it.
+ Publish Role = "publish"
+ // Instance is the LEGACY app-token authority, defined by observation of
+ // today's call sites rather than by its name — it includes domains and
+ // logs. See the console's role table and spec §5.
+ Instance Role = "instance"
+ // Operate is runtime work with no release authority: restart, config,
+ // logs.
+ Operate Role = "operate"
+ // Admin is the union of the three above plus ledgers, store and place.
+ // It is NOT "everything harmless": store grant mints a path-scoped IAM
+ // user, which is a real handover.
+ Admin Role = "admin"
+)
+
+// catalogue is the whole vocabulary, in the order All returns it.
+var catalogue = []Role{Publish, Instance, Operate, Admin}
+
+// All returns the catalogue. The slice is copied so a caller cannot edit the
+// vocabulary by writing through it.
+func All() []Role {
+ out := make([]Role, len(catalogue))
+ copy(out, catalogue)
+ return out
+}
+
+// Parse resolves a stored role name. ok is false for anything outside the
+// catalogue, INCLUDING the empty string a person's row carries — a caller
+// that treats "unparseable" as "no authority" is then correct by default.
+func Parse(s string) (Role, bool) {
+ for _, r := range catalogue {
+ if string(r) == s {
+ return r, true
+ }
+ }
+ return "", false
+}
+
+// Set is the roles that reach one door. The zero value permits nothing,
+// which is the whole point: a door whose roles were never named refuses
+// every service principal rather than accidentally admitting one.
+type Set struct {
+ roles map[Role]bool
+}
+
+// Allow builds the Set for a door. Admin is never listed by a caller that
+// means "publish and above": it is added here, once, from the definition
+// that Admin is the union — so ~50 registrations cannot disagree with the
+// catalogue about what Admin is. A door reachable ONLY by an administrator
+// says Allow(Admin) explicitly and gets exactly that.
+func Allow(roles ...Role) Set {
+ if len(roles) == 0 {
+ return Set{}
+ }
+ s := Set{roles: make(map[Role]bool, len(roles)+1)}
+ for _, r := range roles {
+ if _, ok := Parse(string(r)); !ok {
+ // A typo'd constant is a programming error, and the safe
+ // direction is to drop it: the door ends up MORE closed, not
+ // silently open to a name nothing can hold anyway.
+ continue
+ }
+ s.roles[r] = true
+ }
+ if len(s.roles) == 0 {
+ return Set{}
+ }
+ s.roles[Admin] = true
+ return s
+}
+
+// Permits reports whether a principal holding r may pass this door.
+func (s Set) Permits(r Role) bool {
+ if s.roles == nil {
+ return false
+ }
+ return s.roles[r]
+}
+
+// Empty reports whether this Set permits nothing at all — what the
+// registration-coverage test asserts no door has.
+func (s Set) Empty() bool { return len(s.roles) == 0 }
+
+// Roles lists the Set's members, sorted, for test assertions and for the
+// door list the services API hands back so an operator can see what they
+// just granted.
+func (s Set) Roles() []Role {
+ out := make([]Role, 0, len(s.roles))
+ for r := range s.roles {
+ out = append(out, r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
diff --git a/internal/svcrole/svcrole_test.go b/internal/svcrole/svcrole_test.go
new file mode 100644
index 000000000..3dde663d4
--- /dev/null
+++ b/internal/svcrole/svcrole_test.go
@@ -0,0 +1,82 @@
+package svcrole
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ for _, want := range All() {
+ got, ok := Parse(string(want))
+ if !ok || got != want {
+ t.Errorf("Parse(%q) = %q,%v; want %q,true", want, got, ok, want)
+ }
+ }
+ // A role name that is not in the catalogue must not parse. "" is the
+ // value a person's row carries, and it must never resolve to a role.
+ for _, bad := range []string{"", "owner", "member", "Publish", "admin ", "root"} {
+ if got, ok := Parse(bad); ok {
+ t.Errorf("Parse(%q) = %q,true; want false", bad, got)
+ }
+ }
+}
+
+func TestAdminPermitsEveryNonEmptySet(t *testing.T) {
+ // Admin is defined as the union, so it reaches every door that names any
+ // role at all — including doors that name only Admin.
+ for _, s := range []Set{
+ Allow(Publish), Allow(Instance), Allow(Operate), Allow(Admin),
+ Allow(Publish, Operate),
+ } {
+ if !s.Permits(Admin) {
+ t.Errorf("Allow(%v).Permits(Admin) = false; want true", s.Roles())
+ }
+ }
+}
+
+func TestAllowIsExactForNonAdmin(t *testing.T) {
+ s := Allow(Publish)
+ if !s.Permits(Publish) {
+ t.Error("Allow(Publish).Permits(Publish) = false; want true")
+ }
+ for _, r := range []Role{Instance, Operate} {
+ if s.Permits(r) {
+ t.Errorf("Allow(Publish).Permits(%q) = true; want false", r)
+ }
+ }
+}
+
+func TestEmptySetPermitsNothingIncludingAdmin(t *testing.T) {
+ // The zero Set is what a door gets if someone constructs one by mistake.
+ // It must be closed, not open, and Admin must not be an escape hatch
+ // through it — otherwise "forgot to name a role" reads as "admin-only"
+ // rather than as the refusal it is.
+ var s Set
+ if !s.Empty() {
+ t.Fatal("zero Set.Empty() = false; want true")
+ }
+ for _, r := range append(All(), Role("")) {
+ if s.Permits(r) {
+ t.Errorf("zero Set.Permits(%q) = true; want false", r)
+ }
+ }
+}
+
+func TestUnknownRoleNeverPermitted(t *testing.T) {
+ s := Allow(Publish, Instance, Operate, Admin)
+ for _, r := range []Role{"", "owner", "wildcard", "*"} {
+ if s.Permits(r) {
+ t.Errorf("Permits(%q) = true; want false", r)
+ }
+ }
+}
+
+func TestRolesIsSortedAndDeduplicated(t *testing.T) {
+ // Admin is always present: Allow adds it from the union definition, so a
+ // door naming Publish twice is the set {admin, publish}, sorted.
+ got := Allow(Operate, Publish, Publish).Roles()
+ want := []Role{Admin, Operate, Publish}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Roles() = %v; want %v", got, want)
+ }
+}