Skip to content

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

Merged
eliteprox merged 4 commits into
mainfrom
feat/admin-usage-boundary
Aug 20, 2026
Merged

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

Conversation

@eliteprox

@eliteprox eliteprox commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Re-introduces the tenant-scoped admin usage / access / grants API after #81 was reverted on main.
  • Addresses Copilot review on feat(builder-api): tenant-scoped usage and allowance admin API #81 before merge:
    • Konnect meter query via POST /meters/{id}/query (legacy GET+slug → 405)
    • Access/grant handlers resolve customer key → ULID before credits/entitlement calls
    • Boundary e2e mock matches the Konnect wire shape
  • Adds USAGE.md; corrects AUTH0_SIGNER_M2M_* in TENANT-ISOLATION.md

Closes #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 -l clean on openmeter-collector/builder-api
  • go test ./... in openmeter-collector/builder-api
  • CI builder-api workflow green
  • Redeploy builder-api; verify usage + access against live Konnect

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.
Copilot AI lite review requested due to automatic review settings August 17, 2026 22:02
@eliteprox

Copy link
Copy Markdown
Collaborator Author

Replaces reverted #81. Includes the ULID resolve fix that was briefly in closed #87.

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

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 centralized authorizeTenant boundary.
  • 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.

Comment thread openmeter-collector/builder-api/internal/openmeter/usage.go Outdated
Comment thread openmeter-collector/builder-api/cmd/builder-api/openapi.json
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 22:23
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

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 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
}

Copilot AI review requested due to automatic review settings August 17, 2026 22:27

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 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
	}

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 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}/grants is missing the 503 response, but the handler returns 503 when 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

  • externalUserId is documented/treated elsewhere as forbidden to contain : (to keep customer keys unambiguous), but handleCreateUser does 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
	}

Copilot AI review requested due to automatic review settings August 20, 2026 03:52

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

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