Skip to content

feat(builder-api): tenant-scoped usage and allowance admin API - #81

Merged
eliteprox merged 3 commits into
mainfrom
feat/admin-usage-boundary
Aug 17, 2026
Merged

feat(builder-api): tenant-scoped usage and allowance admin API#81
eliteprox merged 3 commits into
mainfrom
feat/admin-usage-boundary

Conversation

@eliteprox

Copy link
Copy Markdown
Collaborator

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.M2MAuth compared 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:

# Layer Stops
1 internal/tenantauth unknown / mismatched credentials reach nothing
2 Server.authorizeTenant a tenant addressing another tenant's clientId
3 scope construction subjects built from the authorized client id, never caller input
4 filterRowsToTenant a backend that ignores the subject filter

Layer 3 is the one that matters and the easiest to regress: handlers take the client id from authorizeTenant's return value, never by re-reading r.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

  • Cross-tenant access answers 404, not 403 — a 403 confirms another tenant's client id exists.
  • externalUserId may not contain : — it becomes half of clientId:externalUserId.
  • 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, which on a shared tenant would return everyone's usage.
  • Tenant-wide queries bounded at 500 subjects / 50 customer pages.

Endpoints

GET  /api/v1/apps/{clientId}/usage?meter=&from=&to=[&externalUserId=][&groupBy=]
GET  /api/v1/apps/{clientId}/users/{externalUserId}/access[?feature=]
POST /api/v1/apps/{clientId}/users/{externalUserId}/grants

Auth is HTTP Basic as either the platform M2M credential (any tenant) or a tenant's clientId + secret from the new TENANT_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.md documents 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

@eliteprox
eliteprox requested a review from rickstaa August 12, 2026 21:09
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from b3e7722 to cdcc1d4 Compare August 12, 2026 22:14
@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 140c91f to 7590264 Compare August 12, 2026 22:14
Copilot AI lite review requested due to automatic review settings August 12, 2026 23:31
@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 7590264 to 119b6e6 Compare August 12, 2026 23:31
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from cdcc1d4 to 56b9a57 Compare August 12, 2026 23:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via Server.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.

Comment thread openmeter-collector/builder-api/internal/httpapi/admin.go Outdated
Comment thread openmeter-collector/builder-api/internal/httpapi/admin.go Outdated
Comment thread openmeter-collector/builder-api/cmd/builder-api/openapi.json
Comment thread openmeter-collector/builder-api/internal/openmeter/usage.go Outdated
@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 119b6e6 to 201204c Compare August 12, 2026 23:44
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from 56b9a57 to 91accd0 Compare August 12, 2026 23:44
Copilot AI review requested due to automatic review settings August 12, 2026 23:48
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from 91accd0 to dd5d6eb Compare August 12, 2026 23:48
@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 201204c to 4ef89be Compare August 12, 2026 23:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
	}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
	}

@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 42e050a to 9b735ec Compare August 13, 2026 04:22
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from a9a0290 to bbdf4a9 Compare August 13, 2026 04:22
@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 9b735ec to dbff9fe Compare August 13, 2026 16:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • Authenticate claims 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}
	}

@eliteprox
eliteprox force-pushed the feat/collector-builder-api branch from 472897d to 564915d Compare August 17, 2026 19:10
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from 9f9c278 to a9ba4d4 Compare August 17, 2026 19:15
Copilot AI review requested due to automatic review settings August 17, 2026 19:15
Base automatically changed from feat/collector-builder-api to main August 17, 2026 19:18
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>
@eliteprox
eliteprox force-pushed the feat/admin-usage-boundary branch from a9ba4d4 to 7920128 Compare August 17, 2026 19:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • decodeCustomerList silently returns nil when the response JSON matches neither the paged nor list shape. In that case ListCustomerKeysForClient treats 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.ConstantTimeCompare returns immediately when the byte-slice lengths differ, so constantTimeEqual is 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 externalUserId containing : on the /usage endpoint, 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

  • ListCustomerKeysForClient paginates /customers using page / pageSize, but the other OpenMeter list call in this codebase uses JSON:API-style pagination (page[size], see internal/openmeter/access.go). If the /customers endpoint 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()

Copilot AI review requested due to automatic review settings August 17, 2026 19:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • handleGrantAllowance can 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 reads AUTH0_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

  • decodeCustomerList silently returns nil on JSON shape/parse errors, so a malformed or unexpected /customers 2xx 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 != "", but OpenMeterURL is always non-empty due to envOr(..., 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

  • handleUserAccess can 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_SECRET in internal/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.
Copilot AI review requested due to automatic review settings August 17, 2026 21:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • TestBoundaryEndToEndWithRealClient stubs the Konnect backend as if usage queries are GET .../query?subject=..., but the real OpenMeter client now does GET /meters to resolve a meter ID and then POST /meters/{id}/query with a JSON body containing the subject filter (openmeter/usage.go:90-145 and :209-249). As written, this test will 404 on /meters and/or record an empty subjects list, 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:

Comment on lines +204 to +206
customerKey := openmeter.CustomerKey(clientID, externalUserID)
access, err := s.admin.GetAccess(r.Context(), customerKey, featureKey)
if err != nil {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +266 to +268
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")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot AI review requested due to automatic review settings August 17, 2026 21:56
@eliteprox
eliteprox merged commit d372fba into main Aug 17, 2026
5 checks passed
@eliteprox
eliteprox deleted the feat/admin-usage-boundary branch August 17, 2026 21:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 adminAPI conditional is effectively dead code: config.Load() requires OPENMETER_URL and OPENMETER_API_KEY, so the else branch (and the “Left nil when unconfigured” comment) can never occur in production. This also duplicates the OpenMeter client construction (omClient is already created above). Consider wiring the already-created omClient into 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_KEY yields 503 responses from admin routes, but config.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

  • QueryUsage can return a “meter not found” error (e.g. when Konnect returns 404), but this handler maps all metering errors to 502 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 externalUserId must not contain : for the access/grants endpoints, but the same constraint is also enforced for the /usage endpoint’s externalUserId query 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"
            }
          },

@eliteprox

Copy link
Copy Markdown
Collaborator Author

Mistake / cleanup

This PR was squash-merged to main while two Copilot review comments were still open and while we were mid-validating against the swapped Konnect org:

  1. Access / grants used the compound customer key (clientId:externalUserId) on Konnect credits & entitlement routes. Those paths require the customer ULID; keys return 404 (confirmed live). That made access look like hasAccess: false instead of a real balance read.
  2. Konnect meter query is POST /meters/{meterId}/query, not the legacy GET /meters/{slug}/query (405 on the new org). The boundary e2e mock had to match that wire shape too.

main now has a revert of this merge (7d597aa). The feature is reopened with those fixes folded in — see the replacement PR that follows this comment. Sorry for the churn; better one clean merge than leaving a known ULID bug on main.

@eliteprox

Copy link
Copy Markdown
Collaborator Author

Replacement PR: #88

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provision shared OpenMeter instance with tenant data isolation Administrative API wrapper over KongHQ metering & billing

2 participants