mail: the nine email doors go through the role gate, three of them closed
Merging main brought service principals (#227) into this branch, and with
it the rule that every /api/cli/ door names at registration which service
roles may reach it. The email family predated that and registered with
plain mux.HandleFunc, so all nine doors 404'd on the merge result:
apiPrincipalAccount refuses outright when the principal is not in the
request context, and only cliRoute puts it there. The failure was total
and silent on the branch alone — the branch has no cliRoute to bypass.
Six doors take Allow(Admin), matching the store family verb for verb:
declare, ensure, verify, enable, rotate and status all move an account's
backend estate, and enable mints a path-scoped IAM user exactly as store
grant does.
Three are closed to every role, admin included, each for its own reason:
- credentials create is the only door in the API that hands a plaintext
sending secret back to its caller. Store seals its secret into config
and keeps no key to open it; this one cannot, because a standalone
credential exists to be copied into an off-platform MTA. A service
credential reaching it would turn "may deploy this app" into "may
send as this account's domain", with no delivery step and no expiry.
- credentials revoke closes with it, as the services family does.
- pause already resolves an address against CARLOS_MAIL_OPERATORS,
which no service principal can hold, so naming a role would be a
registration that lies about who gets in. It raises the daily cap in
the same request besides — an authority grant, not a runtime toggle.
All three are pinned at the intent level (mintDoors, policyDoors) as well
as in closedDoors, so reopening one takes an edit that argues with the
words beside it.
Gate green: 31 packages, vet clean, gofmt clean, all three cross targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+90
−18
internal/console/apimail.go+53 −14internal/console/svcroutes_test.go+37 −4
CI passed — run details
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| index 6f52f4c21..2904f745d 100644 |
| --- a/internal/console/apimail.go |
| +++ b/internal/console/apimail.go |
| @@ -62,6 +62,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| // mailTimeout bounds a single round trip: declare, status, and a domain |
| @@ -441,21 +442,59 @@ func (s *Server) mailWalkReply(ctx context.Context, w http.ResponseWriter, acct |
| writeJSON(w, http.StatusOK, out) |
| } |
| -// registerEmailRoutes wires this file's nine doors onto s.mux. Every other |
| -// CLI route in the console registers centrally in registerCLIRoutes |
| -// (authcli.go); this one is separate because this task's file scope is |
| -// server.go (which calls it, beside registerCLIRoutes) and apimail.go — |
| -// not authcli.go. |
| +// registerEmailRoutes wires this file's nine doors onto s.mux, through the |
| +// role gate (cliRoute) like every other /api/cli/ door. It stays a separate |
| +// function from registerCLIRoutes only so the email family's reasoning lives |
| +// beside the handlers it describes; svcroutes_test's AST pass is |
| +// package-wide precisely so a second registration site is still covered. |
| +// |
| +// THE ROLE TABLE, and why each door reads as it does. |
| +// |
| +// The six OPEN doors take Allow(Admin), matching the store family verb for |
| +// verb — declare, ensure, verify, enable, rotate all move an account's |
| +// backend estate, and `enable` mints a path-scoped IAM user exactly as |
| +// `store grant` does. Admin is the union role, so Publish/Instance/Operate |
| +// are refused: a credential that may deploy an app must not thereby be able |
| +// to give it a sending identity on a domain the account owns. |
| +// |
| +// GET .../email is Admin rather than a read role for the same reason GET |
| +// .../store is: the status reply names the IAM user, the key id and the |
| +// verified domains — the estate, not just the app. |
| +// |
| +// The three CLOSED doors (svcrole.Set{} — closed to every role, admin |
| +// included) are each closed for their own reason: |
| +// |
| +// - credentials create is the ONLY door in either family that hands a |
| +// plaintext sending secret back to its caller. Store never does: its |
| +// secret goes into sealed config and the console keeps no key to open |
| +// it. This one must, because the whole point of a standalone credential |
| +// is a human copying it into an off-platform MTA (spec §6b). An |
| +// on-platform app never needs this door — it is delivered its own |
| +// credentials as env by `enable`. So the one caller this door exists |
| +// for is a person, and a service credential reaching it would turn |
| +// "may deploy" into "may send as this domain, indefinitely". |
| +// - credentials revoke is closed with it. Nothing a service could have |
| +// minted here exists for it to clean up, and the services family closes |
| +// create and revoke together for the same symmetry. |
| +// - pause already resolves an ADDRESS against CARLOS_MAIL_OPERATORS |
| +// (mailOperator), which no service principal can ever hold. Naming a |
| +// role here would be a registration that lies about who gets in — the |
| +// objection authcli.go raises against exactly that shape. It also |
| +// RAISES THE CAP, which is an authority grant, not a runtime toggle. |
| +// |
| +// All three are pinned at the intent level too (mintDoors, policyDoors in |
| +// svcroutes_test.go), so reopening one takes an edit that argues with the |
| +// words next to it, not just a line moved in closedDoors. |
| func (s *Server) registerEmailRoutes() { |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email", s.handleAPIEmailDeclare) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/email", s.handleAPIEmailStatus) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/domains", s.handleAPIEmailDomainEnsure) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", s.handleAPIEmailDomainVerify) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/enable", s.handleAPIEmailEnable) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/rotate", s.handleAPIEmailRotate) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/credentials", s.handleAPIEmailCredentialsCreate) |
| - s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", s.handleAPIEmailCredentialsRevoke) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/pause", s.handleAPIEmailPause) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDeclare) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailStatus) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainEnsure) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainVerify) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/enable", svcrole.Allow(svcrole.Admin), s.handleAPIEmailEnable) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIEmailRotate) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/credentials", svcrole.Set{}, s.handleAPIEmailCredentialsCreate) |
| + s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", svcrole.Set{}, s.handleAPIEmailCredentialsRevoke) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/email/pause", svcrole.Set{}, s.handleAPIEmailPause) |
| } |
| // ---- declare ------------------------------------------------------------ |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index ff83d4b07..3bcf125d1 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -47,10 +47,27 @@ import ( |
| // which domain, pool, regions and channel an app may ever be brought up on. |
| // A credential the app itself holds must not rewrite the bounds it runs |
| // inside, which is a fact about the door's subject and not about any role. |
| +// |
| +// The email family adds one entry to each list, and both are facts about the |
| +// door's subject rather than about any role: |
| +// |
| +// /email/credentials (create and revoke, one substring) is a MINT door. It |
| +// is the only door in the whole API that hands a plaintext sending secret |
| +// back to its caller — store's equivalent seals its secret into config and |
| +// the console keeps no key to open it. That reply is a standing authority to |
| +// send as the account's verified domain, it outlives the request, and no |
| +// on-platform app needs it (enable delivers credentials as env instead). So |
| +// it belongs beside instance-token and the services family: the caller it |
| +// exists for is a person. |
| +// |
| +// /email/pause is a POLICY door. It is the abuse brake, and the same request |
| +// raises the tenant's daily cap — the bound the tenant sends inside. A |
| +// credential must no more raise its own sending cap than rewrite the |
| +// manifest it runs under. |
| var ( |
| - mintDoors = []string{"/instance-token", "/api/cli/services/"} |
| + mintDoors = []string{"/instance-token", "/api/cli/services/", "/email/credentials"} |
| fleetDoors = []string{"/api/cli/fleets/"} |
| - policyDoors = []string{"/instances-manifest", "/{app}/routes"} |
| + policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause"} |
| ) |
| // closedDoors is the exact set of /api/cli/ patterns registered with the zero |
| @@ -91,6 +108,22 @@ var closedDoors = []string{ |
| "GET /api/cli/services/{account}", |
| "POST /api/cli/services/{account}/{name}/rotate", |
| "POST /api/cli/services/{account}/{name}/revoke", |
| + // Minting a STANDALONE SMTP credential is the same cut again, and the |
| + // sharpest case of it: this door's 200 body carries the password in |
| + // plaintext, because a standalone credential is displayed once and |
| + // nothing else ever will (spec §6b). A service credential that could |
| + // call it would convert "may deploy this app" into "may send as this |
| + // account's domain" — with no delivery step, no config seal, and no |
| + // expiry. Revoke is closed with it for the services family's symmetry: |
| + // nothing a service could have minted here exists for it to clean up. |
| + "POST /api/cli/apps/{account}/{app}/email/credentials", |
| + "DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", |
| + // The email abuse brake. It already resolves an ADDRESS against |
| + // CARLOS_MAIL_OPERATORS, which no service principal can hold, so any |
| + // role named here would be a registration that lies about who gets in. |
| + // It also raises the daily cap in the same request, which is an |
| + // authority grant rather than a runtime toggle. |
| + "POST /api/cli/apps/{account}/{app}/email/pause", |
| } |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| @@ -601,8 +634,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 13 { |
| - t.Errorf("walked %d closed doors, want all %d (and 13 today: instance-token, the routes door, instances-manifest, six fleets, four services)", |
| + if seen != len(closedDoors) || seen != 16 { |
| + t.Errorf("walked %d closed doors, want all %d (and 16 today: instance-token, the routes door, instances-manifest, six fleets, four services, two email credentials, email pause)", |
| seen, len(closedDoors)) |
| } |
| } |