feat(openapi): add apiKeyQuotaUsages query for service-account keys#1779
Conversation
Expose API-key quota usage over the OpenAPI GraphQL endpoint. A service_account key can query by apiKeyId or plaintext key (exactly one); the ent privacy policy enforces the read_api_keys scope and same-project isolation on both lookup paths, so foreign keys are invisible (NotFound). - biz: share QuotaService.ProfileQuotaUsages between admin and openapi - biz: APIKeyService.GetForRead loads via the context client so privacy applies - openapi: new query resolver + DI wiring; admin resolver refactored to reuse - security: drop GET transport on /openapi/v1/graphql so a plaintext key can never travel in a URL (GET now returns 400 "transport not supported") - examples: sync genqlient schema mirror, add query op, optional demo, README - tests: by id/key, no-quota, cross-project deny (both paths), missing-scope deny, two-choose-one, invalid GUID type, GET rejection
Stand up the production OpenAPI route stack (WithEntClient -> WithOpenAPIAuth -> gqlgen handler) on an httptest server and drive it with a real HTTP client. Covers: query by apiKeyId/key returns seeded usage (requestCount/totalTokens/totalCost), cross-project deny (id + key) as NotFound, missing read_api_keys deny, non-service-account and missing bearer -> 401, and GET on the graphql path -> 404 (POST-only routing).
Satisfies the modernize linter (rangeint) so golangci-lint CI passes.
Greptile SummaryThis PR adds an
Confidence Score: 5/5This PR is safe to merge. The new query is gated behind the existing ent privacy layer, project isolation is enforced, and the removal of GET transport prevents plaintext key values from leaking into URL-based logs. The authorization path for the new query follows the same ent-privacy pattern already used by all mutations: the context-bound client enforces project scoping and scope checks, cross-project lookups are blocked, and both the unit and e2e test suites cover the security boundaries exhaustively. No behavioral changes are made to existing paths. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as Service Account Client
participant Auth as WithOpenAPIAuth Middleware
participant Resolver as queryResolver.APIKeyQuotaUsages
participant APIKeySvc as APIKeyService.GetForRead
participant QuotaSvc as QuotaService.ProfileQuotaUsages
participant Ent as Ent (Privacy Layer)
Client->>Auth: POST /openapi/v1/graphql (Bearer: service-account-key)
Auth->>Ent: AuthenticateAPIKey + inject principal/project/scopes into ctx
Auth-->>Resolver: ctx with API key principal
Resolver->>Resolver: Validate GUID type (if apiKeyID provided)
Resolver->>APIKeySvc: GetForRead(ctx, id, key)
APIKeySvc->>Ent: APIKey.Query().Where(...).Only(ctx)
Note over Ent: Privacy policy checks: read_api_keys scope + same project as caller
Ent-->>APIKeySvc: "*ent.APIKey (or NotFound)"
APIKeySvc-->>Resolver: apiKey
Resolver->>QuotaSvc: ProfileQuotaUsages(ctx, apiKey)
loop "For each profile with Quota != nil"
QuotaSvc->>QuotaSvc: GetQuota(ctx, apiKey.ID, profile.Quota)
end
QuotaSvc-->>Resolver: []ProfileQuotaUsage
Resolver-->>Client: "[]*APIKeyProfileQuotaUsage (JSON)"
Reviews (2): Last reviewed commit: "fix(examples/openapi): decode Decimal as..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 501fc737f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| type APIKeyQuotaUsagesApiKeyQuotaUsagesAPIKeyProfileQuotaUsageUsageAPIKeyQuotaUsage struct { | ||
| RequestCount int `json:"requestCount"` | ||
| TotalTokens int `json:"totalTokens"` | ||
| TotalCost string `json:"totalCost"` |
There was a problem hiding this comment.
Bind totalCost to a JSON-compatible Decimal type
When the example calls APIKeyQuotaUsages and the server returns any quota row, totalCost is decoded into a Go string, but the server's Decimal marshaler writes the decimal as an unquoted JSON number (internal/objects/decimal.go). That makes the generated client fail response decoding for this new query (e.g. json: cannot unmarshal number into Go struct field ... totalCost of type string) instead of printing the usage. Bind Decimal to a type that can unmarshal numeric JSON (or provide a custom scalar binding) before selecting totalCost here.
Useful? React with 👍 / 👎.
The server marshals the Decimal scalar as a bare (unquoted) JSON number (internal/objects/decimal.go MarshalDecimal writes d.String() raw), but the genqlient client bound Decimal -> Go string. Decoding a numeric token into a string fails with "json: cannot unmarshal number into Go struct field ... totalCost of type string", so apiKeyQuotaUsages never printed real usage. Bind the output Decimal scalar to encoding/json.Number (preserves the exact value, no precision loss, prints fine with %s) and regenerate the client. DecimalInput stays string since the server accepts quoted-string input. Also guard usage.Usage against nil in the example loop (it is a pointer under use_struct_references) to keep the example robust on malformed responses.
…ooplj#1779) * feat(openapi): add apiKeyQuotaUsages query for service-account keys Expose API-key quota usage over the OpenAPI GraphQL endpoint. A service_account key can query by apiKeyId or plaintext key (exactly one); the ent privacy policy enforces the read_api_keys scope and same-project isolation on both lookup paths, so foreign keys are invisible (NotFound). - biz: share QuotaService.ProfileQuotaUsages between admin and openapi - biz: APIKeyService.GetForRead loads via the context client so privacy applies - openapi: new query resolver + DI wiring; admin resolver refactored to reuse - security: drop GET transport on /openapi/v1/graphql so a plaintext key can never travel in a URL (GET now returns 400 "transport not supported") - examples: sync genqlient schema mirror, add query op, optional demo, README - tests: by id/key, no-quota, cross-project deny (both paths), missing-scope deny, two-choose-one, invalid GUID type, GET rejection * test(openapi): full-stack e2e for apiKeyQuotaUsages over real HTTP Stand up the production OpenAPI route stack (WithEntClient -> WithOpenAPIAuth -> gqlgen handler) on an httptest server and drive it with a real HTTP client. Covers: query by apiKeyId/key returns seeded usage (requestCount/totalTokens/totalCost), cross-project deny (id + key) as NotFound, missing read_api_keys deny, non-service-account and missing bearer -> 401, and GET on the graphql path -> 404 (POST-only routing). * style(openapi): use range over int in apiKeyQuotaUsages e2e test Satisfies the modernize linter (rangeint) so golangci-lint CI passes. * fix(examples/openapi): decode Decimal as json.Number, not string The server marshals the Decimal scalar as a bare (unquoted) JSON number (internal/objects/decimal.go MarshalDecimal writes d.String() raw), but the genqlient client bound Decimal -> Go string. Decoding a numeric token into a string fails with "json: cannot unmarshal number into Go struct field ... totalCost of type string", so apiKeyQuotaUsages never printed real usage. Bind the output Decimal scalar to encoding/json.Number (preserves the exact value, no precision loss, prints fine with %s) and regenerate the client. DecimalInput stays string since the server accepts quoted-string input. Also guard usage.Usage against nil in the example loop (it is a pointer under use_struct_references) to keep the example robust on malformed responses.
Summary
Adds an
apiKeyQuotaUsagesquery to the OpenAPI GraphQL surface, letting a service-account key read its own quota usage (request count, total tokens, total cost) aggregated over its usage logs. Access is scoped to the calling key's project so a key cannot read another project's usage.Changes
quota.go+api_key.go— aggregate quota usage for an API key.apiKeyQuotaUsagesresolver, schema, and generated code.Tests
openapi_test.go— resolver-level coverage.openapi_e2e_test.go— full-stack e2e over real HTTP, including project-isolation (a key cannot see a foreign project's usage).Lint / CI
golangci-lint(v2, matching CI config) passes with 0 new issues against theunstablemerge-base.