svcrole: a Service role for apps calling platform services
An app, at runtime, asking the platform to do work on its behalf — on the app's own data, costing the deployment resources. Scanning an object is the first; PDF rendering is the next. Deliberately not a widening of Instance. That role's own comment calls it legacy and "defined by observation of today's call sites rather than by its name", and a token minted to read logs must not silently gain the ability to spend the deployment's CPU. TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly nine doors and fails with "widening the legacy role" — this role exists so that pin can keep holding. Nothing migrates and nothing else changes: the mint door, the browser form and doorsFor all read svcrole.All(), so an account owner can issue one immediately. The account-scope refusal lives in NewServicePrincipal, not in a handler. apiAccountAppBy binds a principal to its app only when App is non-empty, so an account-wide credential reaches every app in the account — tolerable for a role a person mints for their own tooling, not for one an app holds at runtime. There are two mint doors, and the browser form explicitly offers an account-wide credential, so a handler-level check would have left one open. The test drives the store directly for that reason, and asserts the other four roles still mint account-wide: this is a rule about Service, not a new rule for the vocabulary. Allow adds Admin to every set by construction, so an Admin credential reaches these doors too. That is stated in a test rather than fought.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+127
−5
internal/console/apiservices.go+6 −0internal/console/apiservices_test.go+35 −0internal/console/svcprincipal.go+19 −0internal/svcrole/service_role_test.go+51 −0internal/svcrole/svcrole.go+13 −2services.go+3 −3
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index 2447b6053..aa8a75bc0 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -217,6 +217,12 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusConflict, "a credential named "+name+" already exists in this account") |
| return |
| } |
| + if errors.Is(err, ErrAccountScopedService) { |
| + jsonError(w, http.StatusBadRequest, |
| + "a service-role credential must name one app (--app): it is held by an app at runtime, "+ |
| + "and an account-wide one would reach every app in the account") |
| + return |
| + } |
| log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) |
| jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again") |
| return |
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go| index f896bb8cc..eadb5b618 100644 |
| --- a/internal/console/apiservices_test.go |
| +++ b/internal/console/apiservices_test.go |
| @@ -2,6 +2,7 @@ package console |
| import ( |
| "encoding/json" |
| + "errors" |
| "net/http" |
| "strings" |
| "testing" |
| @@ -813,3 +814,37 @@ func TestDoorsForReadsTheGate(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A Service credential must name one app. |
| +// |
| +// apiAccountAppBy binds a principal to its app only when p.App != "", so an |
| +// account-scoped credential reaches every app in the account. That is |
| +// tolerable for a role a person mints for their own tooling; it is not for one |
| +// an APP holds at runtime to spend the deployment's CPU on its own data. |
| +// |
| +// The refusal lives in NewServicePrincipal rather than a handler because there |
| +// are two mint doors — this one and the browser form, which explicitly offers |
| +// an account-wide credential — and a handler check would leave one open. This |
| +// test drives the store directly for that reason. |
| +func TestServiceCredentialCannotBeAccountScoped(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + |
| + _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-wide", "", svcrole.Service, "op@keymail.dev", now) |
| + if !errors.Is(err, ErrAccountScopedService) { |
| + t.Fatalf("account-scoped Service credential = %v, want ErrAccountScopedService", err) |
| + } |
| + |
| + // Bound to an app, it mints. |
| + if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-app", "kass", svcrole.Service, "op@keymail.dev", now); err != nil { |
| + t.Fatalf("app-scoped Service credential: %v", err) |
| + } |
| + |
| + // Every other role still mints account-wide — this refusal is about |
| + // Service alone, not a new rule for the whole vocabulary. |
| + for _, r := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate, svcrole.Admin} { |
| + if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-"+string(r), "", r, "op@keymail.dev", now); err != nil { |
| + t.Fatalf("account-scoped %s credential: %v", r, err) |
| + } |
| + } |
| +} |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index 7d55d8f4f..d928bd1e8 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -163,6 +163,11 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // rotated_at is stamped equal to created_at at mint: the current secret |
| // began exactly when the credential did. RotateServicePrincipal advances it |
| // on every later rotation without touching created_at. |
| +// ErrAccountScopedService is a Service credential minted without an app. It is |
| +// its own sentinel because the mint doors match sentinels rather than strings, |
| +// and a scope refusal is the caller's to fix while a busy database is not. |
| +var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app") |
| + |
| func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) { |
| if name == "" { |
| return "", errors.New("console: a service credential needs a name") |
| @@ -170,6 +175,20 @@ func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcr |
| if _, ok := svcrole.Parse(string(role)); !ok { |
| return "", fmt.Errorf("console: unknown service role %q", role) |
| } |
| + // A Service credential must name one app. |
| + // |
| + // apiAccountAppBy binds a principal to its app only when p.App != "", so an |
| + // account-scoped credential reaches every app in the account — which is |
| + // tolerable for a role a person mints for their own tooling and not for one |
| + // an APP holds at runtime to spend the deployment's CPU on its own data. |
| + // |
| + // The check lives here rather than in a handler because there are two mint |
| + // doors, the API and the browser form, and the form explicitly offers an |
| + // account-wide credential. A handler-level check would leave one of them |
| + // open. |
| + if role == svcrole.Service && app == "" { |
| + return "", ErrAccountScopedService |
| + } |
| tx, err := s.db.Begin() |
| if err != nil { |
| return "", err |
diff --git a/internal/svcrole/service_role_test.go b/internal/svcrole/service_role_test.go| new file mode 100644 |
| index 000000000..7f3d82be5 |
| --- /dev/null |
| +++ b/internal/svcrole/service_role_test.go |
| @@ -0,0 +1,51 @@ |
| +package svcrole |
| + |
| +import "testing" |
| + |
| +// Service is mintable the moment it is in the catalogue: the mint door, the |
| +// mint form and doorsFor all read All(), so nothing else has to change for an |
| +// account owner to issue one. |
| +func TestServiceRoleIsMintable(t *testing.T) { |
| + got, ok := Parse("service") |
| + if !ok || got != Service { |
| + t.Fatalf("Parse(\"service\") = %q, %v", got, ok) |
| + } |
| + |
| + var found bool |
| + for _, r := range All() { |
| + if r == Service { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("Service is not in All(): %v", All()) |
| + } |
| +} |
| + |
| +// A Service door admits Service — and Admin, by construction. Allow adds Admin |
| +// to every set, so this is not a hole to fix but a fact to state: the |
| +// per-app isolation property is about role Service specifically. |
| +func TestServiceDoorAdmitsServiceAndAdmin(t *testing.T) { |
| + s := Allow(Service) |
| + if !s.Permits(Service) { |
| + t.Fatal("a Service door refuses Service") |
| + } |
| + if !s.Permits(Admin) { |
| + t.Fatal("Allow adds Admin to every set; that changed") |
| + } |
| + for _, r := range []Role{Publish, Instance, Operate} { |
| + if s.Permits(r) { |
| + t.Fatalf("a Service door admits %q", r) |
| + } |
| + } |
| +} |
| + |
| +// The mirror: adding Service must not widen the roles that already exist. A |
| +// Publish or Instance credential has no business spending deployment CPU. |
| +func TestExistingRolesDoNotReachServiceDoors(t *testing.T) { |
| + for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate)} { |
| + if door.Permits(Service) { |
| + t.Fatalf("%v admits Service", door.Roles()) |
| + } |
| + } |
| +} |
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go| index 80f9805e9..d0dc2a607 100644 |
| --- a/internal/svcrole/svcrole.go |
| +++ b/internal/svcrole/svcrole.go |
| @@ -38,14 +38,25 @@ const ( |
| // 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. |
| + // Service is an app calling a platform SERVICE at runtime — work the |
| + // platform performs for the app, on the app's own data, that costs the |
| + // deployment resources. Scanning an object is the first; PDF rendering is |
| + // the next. |
| + // |
| + // It is deliberately NOT Instance. That role is defined by observation of |
| + // its call sites rather than by its name, and a token minted to read logs |
| + // must not silently gain the ability to spend the deployment's CPU. |
| + // TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly the |
| + // doors it reaches today, and this role exists so that pin can hold. |
| + Service Role = "service" |
| + // Admin is the union of the four 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} |
| +var catalogue = []Role{Publish, Instance, Operate, Service, Admin} |
| // All returns the catalogue. The slice is copied so a caller cannot edit the |
| // vocabulary by writing through it. |
diff --git a/services.go b/services.go| index a5879e4c8..2b47884cb 100644 |
| --- a/services.go |
| +++ b/services.go |
| @@ -30,7 +30,7 @@ import ( |
| const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags] |
| - create --role <publish|instance|operate|admin> [--app <app>] [--account <sqid|name>] <name> |
| + create --role <publish|instance|operate|service|admin> [--app <app>] [--account <sqid|name>] <name> |
| mint a credential a SERVER holds, bound to this account — |
| not to you. Unlike your own CLI token, its reach is fixed |
| at mint: adding you to another account later does not widen |
| @@ -154,14 +154,14 @@ func validServiceHandle(handle string) error { |
| func runServicesCreate(args []string) { |
| fs := flag.NewFlagSet("services create", flag.ExitOnError) |
| - role := fs.String("role", "", "role: publish, instance, operate or admin (required)") |
| + role := fs.String("role", "", "role: publish, instance, operate, service or admin (required)") |
| app := appFlag(fs, "bind the credential to one app (default: account-wide)") |
| acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| - const usageMsg = "usage: carlos services create --role <publish|instance|operate|admin> [--app <app>] " + |
| + const usageMsg = "usage: carlos services create --role <publish|instance|operate|service|admin> [--app <app>] " + |
| "[--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>" |
| if fs.NArg() != 1 || strings.TrimSpace(*role) == "" { |
| log.Fatal(usageMsg) |