feat(builder-api): tenant-scoped usage and allowance admin API - #88
Conversation
Closes #12. Substantially closes #10 (usage querying and entitlement management; plan config and subscription provisioning follow separately). One shared OpenMeter organization with the admin API as the sole tenant boundary. Includes Konnect-compatible meter query (POST + ULID), customer key→id resolution for access/grants, USAGE.md, and corrected AUTH0_SIGNER_M2M_* env names in TENANT-ISOLATION.md. Replaces the prematurely merged #81 (reverted on main) so review comments are addressed before merge.
There was a problem hiding this comment.
Pull request overview
This PR re-introduces and hardens a tenant-scoped “admin surface” in builder-api for usage queries and allowance/entitlement operations against a single shared OpenMeter (Konnect) organization, enforcing per-tenant isolation via authenticated principal → path-tenant authorization and tenant-derived subject construction.
Changes:
- Added tenant principal authentication/authorization (
internal/tenantauth) and routed tenant-scoped admin endpoints through a centralizedauthorizeTenantboundary. - Implemented OpenMeter usage querying via Konnect-compatible
POST /meters/{id}/query, plus bounded customer-key scans to build tenant-scoped subject lists. - Added/updated boundary-focused tests and operator/integrator documentation, and extended the OpenAPI spec for the new admin routes.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| openmeter-collector/builder-api/README.md | Documents the new admin surface and routes at a high level. |
| openmeter-collector/builder-api/internal/tenantauth/tenantauth.go | Adds tenant/platform principal model and constant-time credential checks. |
| openmeter-collector/builder-api/internal/tenantauth/tenantauth_test.go | Unit tests for tenant/platform auth behavior and parsing of tenant secrets. |
| openmeter-collector/builder-api/internal/openmeter/usage.go | Adds Konnect-compatible meter query + bounded customer listing for tenant subject discovery. |
| openmeter-collector/builder-api/internal/openmeter/usage_test.go | Tests for subject scoping, Konnect query shape, and bounded scans. |
| openmeter-collector/builder-api/internal/openmeter/customer.go | Adds customer lookup by key to resolve key → ULID for credit/entitlement calls. |
| openmeter-collector/builder-api/internal/httpapi/token_test.go | Updates server construction signature for tests. |
| openmeter-collector/builder-api/internal/httpapi/server.go | Wires new admin routes and switches create-user to tenant authorization boundary. |
| openmeter-collector/builder-api/internal/httpapi/boundary_test.go | Adds defense-in-depth tests covering cross-tenant access, subject smuggling, and wire-shape. |
| openmeter-collector/builder-api/internal/httpapi/admin.go | Implements tenant-scoped admin handlers (usage/access/grants) and filtering. |
| openmeter-collector/builder-api/internal/config/config.go | Adds TENANT_ADMIN_KEYS configuration wiring. |
| openmeter-collector/builder-api/docs/USAGE.md | Adds integrator-facing usage/access/grants examples and expectations. |
| openmeter-collector/builder-api/docs/TENANT-ISOLATION.md | Documents the shared-tenant isolation model and manual operator prerequisites. |
| openmeter-collector/builder-api/cmd/builder-api/openapi.json | Adds tenantBasic security and documents the new admin endpoints. |
| openmeter-collector/builder-api/cmd/builder-api/main.go | Wires tenant auth + optional admin API client depending on OpenMeter config. |
| .env.example | Documents TENANT_ADMIN_KEYS environment variable usage. |
Suppressed comments (1)
openmeter-collector/builder-api/cmd/builder-api/openapi.json:346
- The grant handler returns 503 when the metering backend is not configured (admin == nil), but this response code is missing from the OpenAPI spec for the grants route.
"responses": {
"200": {
"description": "Grant applied"
},
"400": {
"description": "Invalid request"
},
"401": {
"description": "Missing or invalid credentials"
},
"404": {
"description": "Unknown app, or the caller does not own it"
},
"502": {
"description": "Grant failed"
}
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
openmeter-collector/builder-api/internal/openmeter/usage.go:322
- decodeCustomerList returns nil when it can’t decode either of the expected response shapes. ListCustomerKeysForClient then treats that as an empty page and stops paginating, which can silently turn backend errors/unexpected responses into an empty subject list (and therefore an empty usage result). Returning an error for invalid/unexpected JSON shapes and propagating it makes failures visible to callers.
return keys, nil
}
func decodeCustomerList(body []byte) ([]Customer, error) {
var page customerPage
openmeter-collector/builder-api/internal/tenantauth/tenantauth.go:132
- constantTimeEqual relies on subtle.ConstantTimeCompare on raw byte slices. In Go, ConstantTimeCompare returns immediately when the slice lengths differ, so this comparison is not constant-time for attacker-controlled inputs (despite the comment above claiming constant-time comparisons). Padding both values to the same length before comparing avoids the early return and makes the timing behavior more consistent.
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (3)
openmeter-collector/builder-api/internal/openmeter/usage.go:263
- looksLikeULID currently accepts any A–Z character, but ULIDs use Crockford base32 (excluding I, L, O, U). This can misclassify a 26-char meter key as a ULID and cause resolveMeterID to skip returning a clear "meter not found" error (or query the wrong path). Tighten validation to the ULID alphabet.
func looksLikeULID(s string) bool {
if len(s) != 26 {
return false
}
for _, r := range s {
openmeter-collector/builder-api/internal/openmeter/usage.go:213
- resolveMeterID only fetches a single /meters page (pageSize=100) and doesn’t paginate. If the Konnect/OpenMeter org has >100 meters, valid meter keys beyond the first page will be reported as "not found". Consider either paginating (with a reasonable cap) or using a server-side filter query if the API supports it.
func (c *Client) resolveMeterID(ctx context.Context, key string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/meters", nil)
if err != nil {
return "", err
}
openmeter-collector/builder-api/internal/httpapi/server.go:110
- handleCreateUser now allows tenant-scoped auth, but it does not reject ':' in externalUserId. Other admin routes explicitly forbid ':' because customer keys are built as "{clientId}:{externalUserId}"; allowing ':' here can create customers that cannot be addressed via /access or /grants (and breaks the documented invariant). Reject ':' consistently on create as well.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
clientID, ok := s.authorizeTenant(w, r)
if !ok {
return
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (2)
openmeter-collector/builder-api/cmd/builder-api/openapi.json:349
- The OpenAPI spec for
POST /api/v1/apps/{clientId}/users/{externalUserId}/grantsis missing the503response, but the handler returns503when the metering backend is not configured (s.admin == nil). This makes the spec incomplete for clients/error handling.
"responses": {
"200": {
"description": "Grant applied"
},
"400": {
"description": "Invalid request"
},
"401": {
"description": "Missing or invalid credentials"
},
"404": {
"description": "Unknown app, or the caller does not own it"
},
"502": {
"description": "Grant failed"
}
}
openmeter-collector/builder-api/internal/httpapi/server.go:110
externalUserIdis documented/treated elsewhere as forbidden to contain:(to keep customer keys unambiguous), buthandleCreateUserdoes not enforce that invariant. This can create customers that later cannot be addressed by the admin access/grants endpoints (which reject:), and contradicts the boundary model described in the docs.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
clientID, ok := s.authorizeTenant(w, r)
if !ok {
return
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (4)
openmeter-collector/builder-api/cmd/builder-api/openapi.json:266
- The 404 response description for the access route is incomplete: the handler also returns 404 when the customer key does not exist ("customer not found"), not only when the app is unknown/unauthorized. Updating the description avoids misleading API consumers. (The usage route's 404 is still accurate as-is.)
"description": "Unknown app, or the caller does not own it"
openmeter-collector/builder-api/cmd/builder-api/openapi.json:344
- The 404 response description for the grants route is incomplete: the handler returns 404 both for unknown/unauthorized apps and for missing customers ("customer not found"), so the OpenAPI description should mention both cases.
"description": "Unknown app, or the caller does not own it"
openmeter-collector/builder-api/docs/TENANT-ISOLATION.md:185
- The TENANT_ADMIN_KEYS example in the shell snippet is unquoted JSON. In many shells this can be affected by brace expansion / quoting rules; quoting it makes the copy-paste example reliably parse as a JSON string.
TENANT_ADMIN_KEYS={"…":"…"} # optional
openmeter-collector/builder-api/docs/USAGE.md:188
- This checklist item says to add a production BUILDER_API URL, but the doc already hard-codes a production Railway URL above. Consider rewording it to reflect the remaining work (e.g., replacing the Railway URL with the final domain) to avoid contradiction.
- [ ] Add production `BUILDER_API` URL to deploy docs once domains stabilize.
Summary
main.POST /meters/{id}/query(legacy GET+slug → 405)AUTH0_SIGNER_M2M_*in TENANT-ISOLATION.mdCloses #12. Substantially closes #10.
Why the revert
#81 was merged while two Copilot findings (customer key vs ULID on access/grants) were still open, and after a Konnect query-shape fix that landed mid-validation. Reverted on
main(7d597aa) so this lands once as a reviewed unit. See the comment on #81.Test plan
gofmt -lclean onopenmeter-collector/builder-apigo test ./...inopenmeter-collector/builder-api