feat(builder-api): tenant-scoped usage and allowance admin API - #81
Conversation
b3e7722 to
cdcc1d4
Compare
140c91f to
7590264
Compare
7590264 to
119b6e6
Compare
cdcc1d4 to
56b9a57
Compare
There was a problem hiding this comment.
Pull request overview
Adds a tenant-isolated admin surface to the builder-api so usage queries and allowance/entitlement operations can be performed against a single shared OpenMeter/Konnect organization while enforcing per-tenant boundaries in the clearinghouse layer.
Changes:
- Introduces tenant-scoped authentication/authorization (
internal/tenantauth) and wires it into HTTP handlers viaServer.authorizeTenant. - Adds new admin endpoints for usage queries, access reads, and allowance grants, including defense-in-depth row filtering and bounded customer scans.
- Updates operator and API documentation (TENANT-ISOLATION doc, README,
.env.example) and extends tests with a dedicated boundary suite plus OpenMeter client unit tests.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| openmeter-collector/builder-api/README.md | Documents the new admin surface and routes. |
| openmeter-collector/builder-api/internal/tenantauth/tenantauth.go | Adds tenant/platform principal authentication and tenant authorization checks. |
| openmeter-collector/builder-api/internal/tenantauth/tenantauth_test.go | Unit tests for tenant/platform auth behavior and TENANT_ADMIN_KEYS parsing. |
| openmeter-collector/builder-api/internal/openmeter/usage.go | Adds OpenMeter usage querying and bounded customer-key scanning helpers. |
| openmeter-collector/builder-api/internal/openmeter/usage_test.go | Tests for empty-subject safety, prefix correctness, and scan bounds. |
| openmeter-collector/builder-api/internal/httpapi/token_test.go | Updates server constructor usage to match the new signature. |
| openmeter-collector/builder-api/internal/httpapi/server.go | Wires new admin endpoints and switches create-user to tenant authorization path. |
| openmeter-collector/builder-api/internal/httpapi/boundary_test.go | End-to-end and invariant tests for tenant boundary enforcement across all routes. |
| openmeter-collector/builder-api/internal/httpapi/admin.go | Implements admin handlers (usage/access/grants) and tenant authorization helper. |
| openmeter-collector/builder-api/internal/config/config.go | Adds TENANT_ADMIN_KEYS to service config. |
| openmeter-collector/builder-api/docs/TENANT-ISOLATION.md | Operator-facing explanation of isolation model + manual prerequisites. |
| openmeter-collector/builder-api/cmd/builder-api/openapi.json | Adds new endpoints and tenantBasic security scheme; updates spec formatting. |
| openmeter-collector/builder-api/cmd/builder-api/main.go | Wires tenant auth + OpenMeter admin client based on env/config. |
| .env.example | Documents TENANT_ADMIN_KEYS env configuration for tenant admin credentials. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
119b6e6 to
201204c
Compare
56b9a57 to
91accd0
Compare
91accd0 to
dd5d6eb
Compare
201204c to
4ef89be
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (6)
openmeter-collector/builder-api/internal/httpapi/admin.go:166
- filterRowsToTenant currently keeps rows with an empty Subject ("row.Subject == """). If the metering backend ever returns aggregated/invalid rows with a blank subject, those rows would be returned to the caller even though they can’t be proven tenant-scoped, which weakens the isolation boundary.
func filterRowsToTenant(rows []openmeter.UsageRow, clientID string) []openmeter.UsageRow {
prefix := clientID + ":"
out := make([]openmeter.UsageRow, 0, len(rows))
for _, row := range rows {
if row.Subject == "" || strings.HasPrefix(row.Subject, prefix) {
out = append(out, row)
openmeter-collector/builder-api/internal/httpapi/admin.go:275
- handleGrantAllowance returns amountUsdMicros as a JSON string (via strconv.FormatInt), but the request schema (and typical API expectations) use a numeric int64. Returning a different type makes clients brittle and breaks round-tripping.
"featureKey": featureKey,
"grantKey": grantKey,
"amountUsdMicros": strconv.FormatInt(body.AmountMicros, 10),
})
openmeter-collector/builder-api/cmd/builder-api/openapi.json:33
- The create/upsert user route now goes through Server.authorizeTenant (so it can be called with tenantBasic as well as the platform M2M credential), but the OpenAPI spec still declares only m2mBasic. This makes generated clients and docs incorrect relative to the actual handler behavior and PR description.
"security": [
{
"m2mBasic": []
}
],
openmeter-collector/builder-api/internal/openmeter/usage.go:188
- ListCustomerKeysForClient treats any JSON decode failure as an empty page (decodeCustomerList returns nil), which will silently truncate the customer scan and can produce incomplete subject lists / under-reported usage. Decode errors should be surfaced as errors instead of being interpreted as end-of-list.
batch := decodeCustomerList(body)
for _, cust := range batch {
if strings.HasPrefix(cust.Key, prefix) {
keys = append(keys, cust.Key)
}
openmeter-collector/builder-api/internal/tenantauth/tenantauth.go:86
- The comment says “Both branches use constant-time comparison”, but the tenant branch first does a map lookup on clientID (which is not constant-time with respect to whether a clientID exists). The code does use constant-time comparison for the platform ID/secret and the tenant secret itself, so the comment should be narrowed to match reality.
// Authenticate resolves credentials to a Principal, returning a zero Principal
// when they match nothing. Both branches use constant-time comparison.
func (a *Authenticator) Authenticate(clientID, secret string) Principal {
openmeter-collector/builder-api/internal/httpapi/server.go:110
- The isolation model/documentation forbids ':' in externalUserId because customer keys are constructed as "clientId:externalUserId". handleCreateUser still only checks for empty externalUserId; it should also reject ':' to keep the key unambiguous and consistent with the admin routes’ validation.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
clientID, ok := s.authorizeTenant(w, r)
if !ok {
return
}
4ef89be to
42e050a
Compare
dd5d6eb to
a9a0290
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (5)
openmeter-collector/builder-api/cmd/builder-api/openapi.json:345
- The OpenAPI spec for POST /api/v1/apps/{clientId}/users/{externalUserId}/grants omits the 503 response that the handler returns when the metering backend is not configured (admin client is nil). Documenting this status code keeps the spec aligned with runtime behavior.
"502": {
"description": "Grant failed"
}
openmeter-collector/builder-api/internal/openmeter/usage.go:190
- ListCustomerKeysForClient treats an unparseable /customers response the same as an empty page (decodeCustomerList returns nil), which can silently truncate the subject list and make usage queries return incomplete/empty results. It’s safer to distinguish "empty page" from "unexpected shape" and fail fast on the latter.
batch := decodeCustomerList(body)
for _, cust := range batch {
if strings.HasPrefix(cust.Key, prefix) {
keys = append(keys, cust.Key)
}
openmeter-collector/builder-api/cmd/builder-api/main.go:81
- main.go constructs a second OpenMeter client for admin routes and has a conditional "disabled" branch that can never be reached because config.Load requires OPENMETER_URL and OPENMETER_API_KEY. This is dead code and duplicates clients; reusing the already-constructed omClient makes intent clearer.
// Admin routes read the shared OpenMeter tenant with one platform
// credential and enforce the per-tenant boundary themselves. Left nil when
// unconfigured so the routes answer 503 instead of panicking.
var adminAPI httpapi.OpenMeterAdmin
if cfg.OpenMeterURL != "" && cfg.OpenMeterAPIKey != "" {
openmeter-collector/builder-api/cmd/builder-api/openapi.json:270
- The OpenAPI spec for GET /api/v1/apps/{clientId}/users/{externalUserId}/access omits the 503 response that the handler returns when the metering backend is not configured. This should be documented to avoid client-side surprises.
This issue also appears on line 343 of the same file.
"502": {
"description": "Metering backend unavailable"
}
openmeter-collector/builder-api/internal/httpapi/server.go:110
- Now that create-user is tenant-scoped via authorizeTenant, externalUserId validation needs to match the tenant-isolation model enforced on the admin routes: externalUserId must not contain ':', since it becomes part of the "clientId:externalUserId" customer key/subject and colons make the identifier ambiguous.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
clientID, ok := s.authorizeTenant(w, r)
if !ok {
return
}
42e050a to
9b735ec
Compare
a9a0290 to
bbdf4a9
Compare
9b735ec to
dbff9fe
Compare
bbdf4a9 to
9f9c278
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (1)
openmeter-collector/builder-api/internal/tenantauth/tenantauth.go:104
Authenticateclaims to use constant-time comparison for both branches, but the platform-admin check short-circuits: if the clientId comparison fails, the secret comparison is skipped. That creates timing variance and undermines the stated goal of not leaking which client IDs exist (404 vs 403). Consider evaluating both comparisons unconditionally (and update the comment to avoid implying the whole function is constant-time, since the tenantSecrets map lookup is also keyed by clientId).
// Authenticate resolves credentials to a Principal, returning a zero Principal
// when they match nothing. Both branches use constant-time comparison.
func (a *Authenticator) Authenticate(clientID, secret string) Principal {
clientID = strings.TrimSpace(clientID)
secret = strings.TrimSpace(secret)
if clientID == "" || secret == "" {
return Principal{}
}
if a.platformClientID != "" && a.platformSecret != "" &&
constantTimeEqual(clientID, a.platformClientID) &&
constantTimeEqual(secret, a.platformSecret) {
return Principal{Kind: KindPlatformAdmin}
}
if expected, ok := a.tenantSecrets[clientID]; ok && constantTimeEqual(secret, expected) {
return Principal{Kind: KindTenant, ClientID: clientID}
}
472897d to
564915d
Compare
9f9c278 to
a9ba4d4
Compare
Closes #12. Substantially closes #10 (usage querying and entitlement management; plan config and subscription provisioning follow separately). Before this change the admin surface authenticated against a single global M2M credential and then read the tenant from the request path, so any holder of the platform secret could address any tenant. There was no per-tenant boundary anywhere in the service. One shared OpenMeter organization, one platform credential held only by the clearinghouse, and the admin API as the sole path to usage data. Konnect metering roles are org-wide, so a SPAT in a shared org can read every customer — which is exactly why no tenant gets one. The boundary sits above it, in four layers: 1. tenantauth - unknown/mismatched credentials reach nothing 2. authorizeTenant - a tenant may only address its own clientId 3. scope construction - subjects are built from the *authorized* client id, never from caller input 4. filterRowsToTenant - drops foreign rows if the backend ignores the filter Deliberate choices: cross-tenant access answers 404 rather than 403, so a shared tenant does not confirm which client ids exist; externalUserId may not contain ':' because it becomes half of a customer key; prefix matching includes the separator so "acme" never matches "acme-corp:eve"; an empty subject list returns no rows rather than querying the meter unscoped. GET /api/v1/apps/{clientId}/usage GET /api/v1/apps/{clientId}/users/{externalUserId}/access POST /api/v1/apps/{clientId}/users/{externalUserId}/grants The pre-existing create-user route now goes through the same boundary instead of the bare global M2M check. Boundary suite covers every admin route against cross-tenant access, unauthenticated access, colon smuggling through both the path segment and the query parameter, neighbouring client ids, a backend that ignores the subject filter, and an unconfigured authenticator failing closed. An end-to-end test wires the real OpenMeter client against a fake Konnect backend and asserts on the wire request, not just handler intent. Mutation-checked: disabling the tenant comparison fails 3 tests, removing the row filter fails 1. gofmt, vet, build, and all 5 packages green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a9ba4d4 to
7920128
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (4)
openmeter-collector/builder-api/internal/openmeter/usage.go:190
decodeCustomerListsilently returns nil when the response JSON matches neither the paged nor list shape. In that caseListCustomerKeysForClienttreats the page as empty and returns a successful (but incomplete) key list, which can make usage look like “no data” during backend/schema issues. This should surface as an error so callers can fail loudly instead of masking incidents.
batch := decodeCustomerList(body)
for _, cust := range batch {
if strings.HasPrefix(cust.Key, prefix) {
keys = append(keys, cust.Key)
}
openmeter-collector/builder-api/internal/tenantauth/tenantauth.go:132
subtle.ConstantTimeComparereturns immediately when the byte-slice lengths differ, soconstantTimeEqualis not actually constant-time for mismatched lengths. Since this is used for credential checks, it’s better to implement a comparison that runs in time proportional to the max length (or otherwise avoid early length short-circuit).
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
openmeter-collector/builder-api/cmd/builder-api/openapi.json:168
- The handler rejects
externalUserIdcontaining:on the/usageendpoint, but the OpenAPI parameter definition doesn’t document that constraint. Adding the same note as the path parameter helps generated clients and avoids surprising 400s.
{
"name": "externalUserId",
"in": "query",
"schema": {
"type": "string"
openmeter-collector/builder-api/internal/openmeter/usage.go:151
ListCustomerKeysForClientpaginates/customersusingpage/pageSize, but the other OpenMeter list call in this codebase uses JSON:API-style pagination (page[size], seeinternal/openmeter/access.go). If the/customersendpoint follows the same convention, these params will be ignored and the scan will either repeat page 1 or use default sizing.
q := req.URL.Query()
q.Set("page", strconv.Itoa(page))
q.Set("pageSize", strconv.Itoa(customerPageSize))
req.URL.RawQuery = q.Encode()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (6)
openmeter-collector/builder-api/cmd/builder-api/openapi.json:345
handleGrantAllowancecan return 503 when the metering backend is not configured (s.admin == nil), but the OpenAPI spec for this endpoint does not document a 503 response.
"description": "Unknown app, or the caller does not own it"
},
"502": {
"description": "Grant failed"
}
openmeter-collector/builder-api/docs/TENANT-ISOLATION.md:185
- The service configuration snippet uses
SIGNER_M2M_CLIENT_ID/SIGNER_M2M_SECRET, but the binary actually readsAUTH0_SIGNER_M2M_CLIENT_ID/AUTH0_SIGNER_M2M_CLIENT_SECRET. The example should match the real env vars to avoid misconfiguration.
OPENMETER_URL=https://us.api.konghq.com/v3/openmeter
OPENMETER_API_KEY=kpat_… # the step-2 token
SIGNER_M2M_CLIENT_ID=… # platform admin principal
SIGNER_M2M_SECRET=…
TENANT_ADMIN_KEYS={"…":"…"} # optional
openmeter-collector/builder-api/internal/openmeter/usage.go:190
decodeCustomerListsilently returns nil on JSON shape/parse errors, so a malformed or unexpected/customers2xx response will be treated as an empty page and prematurely stop the scan (returning an incomplete subject set with no error). Propagate a decode error so callers can fail the request instead of returning partial results.
batch := decodeCustomerList(body)
for _, cust := range batch {
if strings.HasPrefix(cust.Key, prefix) {
keys = append(keys, cust.Key)
}
}
if len(batch) < customerPageSize {
openmeter-collector/builder-api/cmd/builder-api/main.go:81
- The admin API wiring checks
cfg.OpenMeterURL != "" && cfg.OpenMeterAPIKey != "", butOpenMeterURLis always non-empty due toenvOr(..., default)in config. This makes the branch effectively a check for API key only, while the log message implies both may be unset, which is misleading for operators.
if cfg.OpenMeterURL != "" && cfg.OpenMeterAPIKey != "" {
adminAPI = openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey)
} else {
log.Printf("OPENMETER_URL/OPENMETER_API_KEY unset; admin usage routes disabled")
}
openmeter-collector/builder-api/cmd/builder-api/openapi.json:270
handleUserAccesscan return 503 when the metering backend is not configured (s.admin == nil), but the OpenAPI spec for this endpoint does not document a 503 response. This makes generated clients and integrators miss an important failure mode.
This issue also appears on line 341 of the same file.
"description": "Unknown app, or the caller does not own it"
},
"502": {
"description": "Metering backend unavailable"
}
openmeter-collector/builder-api/docs/TENANT-ISOLATION.md:86
- The documented platform admin env var names (
SIGNER_M2M_CLIENT_ID/SIGNER_M2M_SECRET) do not match what the service actually reads (AUTH0_SIGNER_M2M_CLIENT_ID/AUTH0_SIGNER_M2M_CLIENT_SECRETininternal/config/config.go). As written, operators following this doc will configure the wrong variables and fail auth.
This issue also appears on line 181 of the same file.
| Principal | Credential | May address |
|---|---|---|
| Platform admin | `SIGNER_M2M_CLIENT_ID` / `SIGNER_M2M_SECRET` | any tenant |
| Tenant admin | `clientId` / its secret from `TENANT_ADMIN_KEYS` | its own `clientId` only |
Kong Konnect Metering rejects the legacy GET /meters/{slug}/query shape with
405; resolve meter key to id and POST /meters/{id}/query with subject filters.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
openmeter-collector/builder-api/internal/httpapi/boundary_test.go:285
TestBoundaryEndToEndWithRealClientstubs the Konnect backend as if usage queries areGET .../query?subject=..., but the real OpenMeter client now doesGET /metersto resolve a meter ID and thenPOST /meters/{id}/querywith a JSON body containing the subject filter (openmeter/usage.go:90-145 and :209-249). As written, this test will 404 on/metersand/or record an emptysubjectslist, so it won’t validate the on-the-wire request shape the production client actually sends.
konnect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/customers"):
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"data":[]}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": customers})
case strings.Contains(r.URL.Path, "/query"):
subjects := r.URL.Query()["subject"]
meterQueries = append(meterQueries, subjects)
rows := []map[string]any{}
for _, s := range subjects {
rows = append(rows, map[string]any{"subject": s, "value": 1})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": rows})
default:
| customerKey := openmeter.CustomerKey(clientID, externalUserID) | ||
| access, err := s.admin.GetAccess(r.Context(), customerKey, featureKey) | ||
| if err != nil { |
There was a problem hiding this comment.
Agreed — confirmed against live Konnect: credits/entitlement paths 404 on the compound key and 200 on the customer ULID. Fixed in a follow-up PR: handlers now LookupCustomerByKey then call GetAccess / EnsureTrialGrant with customer.ID.
| customerKey := openmeter.CustomerKey(clientID, externalUserID) | ||
| if err := s.admin.EnsureTrialGrant(r.Context(), customerKey, featureKey, grantKey, body.AmountMicros); err != nil { | ||
| writeAPIError(w, http.StatusBadGateway, "grant failed") |
There was a problem hiding this comment.
Same root cause as the access route — grant paths also require the ULID. Fixed together with access in the follow-up PR (LookupCustomerByKey before EnsureTrialGrant).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (4)
openmeter-collector/builder-api/cmd/builder-api/main.go:81
- The
adminAPIconditional is effectively dead code:config.Load()requiresOPENMETER_URLandOPENMETER_API_KEY, so theelsebranch (and the “Left nil when unconfigured” comment) can never occur in production. This also duplicates the OpenMeter client construction (omClientis already created above). Consider wiring the already-createdomClientinto the admin surface and removing the unreachable branch/log to keep startup behavior and docs consistent.
// Admin routes read the shared OpenMeter tenant with one platform
// credential and enforce the per-tenant boundary themselves. Left nil when
// unconfigured so the routes answer 503 instead of panicking.
var adminAPI httpapi.OpenMeterAdmin
if cfg.OpenMeterURL != "" && cfg.OpenMeterAPIKey != "" {
adminAPI = openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey)
} else {
log.Printf("OPENMETER_URL/OPENMETER_API_KEY unset; admin usage routes disabled")
}
openmeter-collector/builder-api/docs/TENANT-ISOLATION.md:189
- This document says missing
OPENMETER_URL/OPENMETER_API_KEYyields503responses from admin routes, butconfig.Load()currently treats these as required env vars and will fail startup instead. Either relax config validation to allow a partially configured server (and rely on the route-level 503s), or update this doc to match the actual startup behavior.
If `OPENMETER_URL` or `OPENMETER_API_KEY` is unset the admin routes answer
`503` rather than starting up in a state where they appear to work.
openmeter-collector/builder-api/internal/httpapi/admin.go:140
QueryUsagecan return a “meter not found” error (e.g. when Konnect returns 404), but this handler maps all metering errors to502 Bad Gateway. That conflicts with the OpenAPI description for this endpoint (400 for invalid meter) and makes client error handling/retries ambiguous. Consider propagating a typed/sentinel error from the OpenMeter client for “unknown meter” so the handler can return 400/404 while still using 502 for backend failures.
if err != nil {
writeAPIError(w, http.StatusBadGateway, "usage query failed")
return
openmeter-collector/builder-api/cmd/builder-api/openapi.json:168
- The OpenAPI spec documents that
externalUserIdmust not contain:for the access/grants endpoints, but the same constraint is also enforced for the/usageendpoint’sexternalUserIdquery parameter. Adding that description here will keep the spec consistent with server-side validation and help generated clients avoid sending invalid values.
{
"name": "externalUserId",
"in": "query",
"schema": {
"type": "string"
}
},
|
Mistake / cleanup This PR was squash-merged to
|
|
Replacement PR: #88 |
Closes #12. Substantially closes #10 — usage querying and entitlement management; plan config and subscription provisioning follow in a smaller PR.
Fourth in the stack (#78 → #79 → #80 → this).
The gap this fills
#10 and #12 have been treated as covered, but nothing implemented them. The service had two routes — create user and OIDC token — and
httpapi.M2MAuthcompared a single global client-id/secret and then read the tenant from the request path. Any holder of the platform secret could address any tenant. There was no per-tenant boundary anywhere.Isolation model
One shared OpenMeter organization, one platform credential held only by the clearinghouse, and the admin API as the sole path to usage data.
The argument for per-tenant Konnect orgs is real and worth stating: Konnect metering roles are org-wide, so a SPAT in a shared org can read every customer. That is exactly why no tenant ever gets one here. The platform SPAT stays inside the clearinghouse and the boundary sits above it:
internal/tenantauthServer.authorizeTenantclientIdfilterRowsToTenantLayer 3 is the one that matters and the easiest to regress: handlers take the client id from
authorizeTenant's return value, never by re-readingr.PathValue.Rejected alternative (
konnect-credentials/, per-tenant orgs) is documented in docs/TENANT-ISOLATION.md — Konnect has no public Create-Organization API, so it puts a human in every onboarding and moves the boundary out of our reach.Deliberate choices worth reviewing
externalUserIdmay not contain:— it becomes half ofclientId:externalUserId.acmenever matchesacme-corp:eve.Endpoints
Auth is HTTP Basic as either the platform M2M credential (any tenant) or a tenant's
clientId+ secret from the newTENANT_ADMIN_KEYS. The pre-existing create-user route now goes through the same boundary. OpenAPI spec updated.Verification
The boundary suite covers every admin route against cross-tenant access, unauthenticated access, colon smuggling via both the path segment and the query parameter, neighbouring client ids, a backend that ignores the subject filter, and an unconfigured authenticator failing closed. An end-to-end test wires the real OpenMeter client against a fake Konnect backend and asserts on the wire request rather than handler intent.
Mutation-checked so the suite isn't passing vacuously: disabling the tenant comparison fails 3 tests, removing the row filter fails 1.
gofmt,
go vet, build, and all 5 packages green.Operator note
docs/TENANT-ISOLATION.mddocuments the manual prerequisites — Konnect has no Create-Organization API and system accounts are console-only, so org creation and platform SPAT issuance are irreducibly manual. That documentation is the remaining half of #12's acceptance criteria.🤖 Generated with Claude Code