carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 1287c30a5e4cd0c91810f4820cefd55c54366a5d parent 42b3663
6 files changed, +127 −5
  • internal/console/apiservices.go +6 −0
  • internal/console/apiservices_test.go +35 −0
  • internal/console/svcprincipal.go +19 −0
  • internal/svcrole/service_role_test.go +51 −0
  • internal/svcrole/svcrole.go +13 −2
  • services.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)