Skip to content

feat(openapi): add apiKeyQuotaUsages query for service-account keys#1779

Merged
looplj merged 5 commits into
looplj:unstablefrom
suixinio:feat/openapi-apikey-quota-usage
Jun 4, 2026
Merged

feat(openapi): add apiKeyQuotaUsages query for service-account keys#1779
looplj merged 5 commits into
looplj:unstablefrom
suixinio:feat/openapi-apikey-quota-usage

Conversation

@suixinio

@suixinio suixinio commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an apiKeyQuotaUsages query 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

  • biz: quota.go + api_key.go — aggregate quota usage for an API key.
  • gql/openapi: new apiKeyQuotaUsages resolver, schema, and generated code.
  • middleware/auth: wire service-account key context for the new query.
  • examples/openapi: example GraphQL query + genqlient bindings and README update.

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 the unstable merge-base.

suixinio added 4 commits June 2, 2026 09:35
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-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an apiKeyQuotaUsages query to the OpenAPI GraphQL surface, allowing service-account keys to read per-profile quota usage (request count, tokens, cost) for API keys within their own project. Access is enforced through the existing ent privacy layer, scoped to the caller's project and read_api_keys scope.

  • New biz layer: GetForRead in api_key.go looks up a key by ID or plaintext value through the privacy-aware ent client, and ProfileQuotaUsages in quota.go aggregates quota usage across all quota-enabled profiles on a given key.
  • New resolver: APIKeyQuotaUsages in the openapi resolver wires these together, validates the GUID type, and maps results to GraphQL response types.
  • Security hardening: GET transport is intentionally removed from the OpenAPI GraphQL handler so the plaintext key argument can never land in URL query strings or access logs.

Confidence Score: 5/5

This 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

Filename Overview
internal/server/biz/api_key.go Adds GetForRead, a read-path lookup that enforces the 'exactly one of id or key' invariant and routes through the context-bound ent client so the APIKey privacy policy applies; logic and error handling are correct.
internal/server/biz/quota.go Adds ProfileQuotaUsages to aggregate per-profile quota usage, extracted from the admin resolver so the logic is shared; nil guards for Profiles and per-profile Quota fields are correct.
internal/server/gql/openapi/openapi.resolvers.go New APIKeyQuotaUsages resolver validates GUID type before lookup, delegates authorization to the biz layer (ent privacy), and correctly maps biz results to GraphQL types.
internal/server/middleware/auth.go Comment-only update to WithOpenAPIAuth; no behavioral change; the middleware correctly injects principal, project, and session scopes for the new query.
internal/server/gql/openapi/graphql.go GET transport intentionally removed to prevent plaintext key values from appearing in URL query strings; QuotaService wired into the handler constructor.
internal/server/gql/openapi/openapi_e2e_test.go New e2e test file; covers happy path (by ID and by key), cross-project isolation, missing scope, non-service-account bearer, missing bearer, and GET rejection — comprehensive coverage of the security surface.
internal/server/gql/openapi/openapi_test.go Unit tests added for the resolver: by ID, by key, no-quota profiles, cross-project by ID and key, missing scope, exactly-one-arg enforcement, invalid GUID type, and GET transport rejection.
examples/openapi/main.go Example updated to demonstrate apiKeyQuotaUsages; Usage is nil-guarded before dereferencing, and Window is not dereferenced at all, so the example won't panic on malformed responses.

Sequence Diagram

sequenceDiagram
    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)"
Loading

Reviews (2): Last reviewed commit: "fix(examples/openapi): decode Decimal as..." | Re-trigger Greptile

Comment thread examples/openapi/main.go

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

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

Comment thread examples/openapi/graphql/generated.go Outdated
type APIKeyQuotaUsagesApiKeyQuotaUsagesAPIKeyProfileQuotaUsageUsageAPIKeyQuotaUsage struct {
RequestCount int `json:"requestCount"`
TotalTokens int `json:"totalTokens"`
TotalCost string `json:"totalCost"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.
@looplj looplj merged commit 90ec465 into looplj:unstable Jun 4, 2026
4 checks passed
junjiangao pushed a commit to junjiangao/axonhub that referenced this pull request Jun 5, 2026
…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.
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.

2 participants