Authorization & tenant-isolation regression matrix¶
secsy-pki exposes ~116 REST routes plus a gRPC PKIService. Every one is an
access-control surface. To stop a new route from silently shipping without an
RBAC/tenant decision — the classic OWASP broken/ missing function-level
authorization and broken object-level authorization failure — the codebase
carries a table-driven authorization regression matrix that is checked in
CI on every change.
- REST:
server/internal/handlers/authz_matrix_test.go - gRPC:
server/internal/grpcapi/authz_matrix_test.go
Both are //go:build sqlite (they run under the same tag as the rest of the
handler suite; no HSM required — a software key provider backs them).
What it guarantees¶
For every route/RPC the matrix drives the real router + auth middleware with four principals and asserts the four contract points:
| # | Principal | Expectation |
|---|---|---|
| (a) | unauthenticated | 401 (REST) / Unauthenticated (gRPC) on protected routes |
| (b) | authenticated but lacking the capability | 403/404, never 2xx |
| (c) | a tenant-scoped principal touching another tenant's resource | 403/404, no cross-tenant data in the body |
| (d) | a correctly-capable principal | not denied (not 401/403) |
Principals are minted through the real middleware via a mock OIDC verifier plus
the production role resolvers, so the test exercises the genuine authentication →
authorization path, not a shortcut. admin is allow-all, so a tenant-a admin is
the universal capable witness for tenant-scoped routes, and a role-less
authenticated principal ("none") is the universal denied witness.
The forcing function¶
TestAuthzMatrixCoversRegisteredRoutes parses RegisterRoutes in
handlers.go with go/ast and fails the build if any registered
mux.Handle/mux.HandleFunc route has no matrix entry (and if any matrix
entry is stale). TestGRPCAuthzMatrixCoversMethods does the same for every
PKIService method via pkiv1.PKIService_ServiceDesc.Methods.
Consequence: you cannot add a route without declaring its access-control
intent. A new mux.Handle(...) turns the suite red until you add a row.
Adding a route — what to do¶
Add exactly one row to authzMatrix() (REST) or grpcAuthzMatrix() (gRPC),
using the constructor that matches the handler's gate:
| Constructor | Gate the handler uses | Capable witness |
|---|---|---|
caRd / caRdBody |
authorizeCARead (read + tenant member) |
tenant-a auditor; cross-tenant → 404 |
rdG |
canRead (any assigned role) |
tenant-a auditor |
platRd |
a.can(audit:read) (platform role) |
platform auditor (a tenant auditor is denied) |
iss |
canIssueOn (per-CA issue) |
tenant-a admin; cross-tenant → 403 |
caMg / caMgBody |
canInTenant(ca:manage) |
tenant-a admin |
platAdm |
isPlatformAdmin / root-only |
root |
memRd |
tenant-member read (non-member → 404) |
tenant-a admin |
tokMg / tokMgScoped |
canInTenant(token:manage) |
tenant-a admin |
secretR |
canInTenant(secret:*), tenant from X-Secsy-Tenant |
tenant-a admin |
signR |
canInTenant(artifact:sign) |
tenant-a admin |
aprRd / aprGet / aprDec |
approval read/approve |
tenant-a auditor / admin |
rbacMg |
a.can(rbac:manage) (platform) |
platform admin |
cfgCA |
root or per-CA CONFIGURE_CA |
root |
hsmMg |
a.can(hsm:manage) (platform) |
root |
platIss |
a.can(cert:issue) (platform) |
root |
Two explicit allowlists exist for the endpoints that are intentionally open:
pub(...)— unauthenticated by design: CRL, delta/shard CRL, OCSP, the AIA chain / cross-sign chains, SSH CA public key + KRL, the SVID trust bundle, health, and the OpenAPI/Redoc docs. (ACME, EST, SCEP, CMP, TSA,/healthz,/readyzand the ACME directory are mounted on a separate router and are the public protocol surface; they are not registered inhandlers.go.)authed(...)— requires authentication but no capability, by design:/api/me,/api/profiles,/api/secret/info,/api/hsm/info,/api/keys/{id}/my-restrictions(each returns the caller's own view or non-sensitive service metadata).
If a genuinely new capability class appears, add a constructor next to the
others rather than hand-rolling an rc{...} literal, so the table stays
declarative.
What the matrix caught¶
Building the matrix uncovered one real cross-tenant gap: CRLStatus
(GET /api/ca/{id}/crl/status) gated only on canRead — any role-holder in
any tenant — and skipped the tenant-membership check that every other
CA-scoped read performs. A tenant-B auditor could read tenant-A's CRL freshness
and revocation counts. Fixed by routing it through the shared authorizeCARead
guard (which enforces membership and answers 404 for non-members), closing the
leak and bringing it in line with the rest of the CA inventory.
Keeping it green¶
- Run locally:
go test -tags sqlite ./internal/handlers/ ./internal/grpcapi/. - If
TestAuthzMatrixCoversRegisteredRoutesfails with "route X has no authorization-matrix entry", add the row — do not delete the assertion. - If a
(b)/(c)assertion fails with a2xx, you have found a missing authorization or tenant check in the handler. Fix the handler, not the test.