feat(openapi): add updateAPIKeyProfiles and loadApiKeyProfileTemplate#1617
Conversation
Adds two GraphQL mutations to /openapi/v1/graphql for programmatic LLM API key profile management via service account keys: - updateAPIKeyProfiles: integral state replacement (mirrors admin) - loadApiKeyProfileTemplate: append a project template to a key Both reuse existing admin biz methods directly — zero new business logic. Following the createLLMAPIKey pattern from looplj#637, the OpenAPI schema stays isolated from admin (own gqlgen module, own minimal APIKey output type, scope checks delegated to ent privacy). Privacy model: - New scopes.APIKeyProjectScopeReadRule restricts API-key-principal reads to the caller's project, gated by required scope. Mounted on both APIKey and APIKeyProfileTemplate Query policies so service accounts can resolve the target key and template. - scopes.UserProjectScopeReadRule now skips when no user is in context (matching UserProjectScopeWriteRule's existing convention) so API key principals fall through to APIKeyProjectScopeReadRule instead of being denied at the user-rule layer. Required scopes for the new mutations: read_api_keys + write_api_keys. Frontend compatibility fix: the OpenAPI updateAPIKeyProfiles resolver coerces nil ModelMappings to []. Admin UI's Zod schema strictly requires a non-null array for this single field (sibling array fields are .nullable()), so OpenAPI clients omitting modelMappings would otherwise produce rows the UI can't render. Tests: - internal/scopes: APIKeyProjectScopeReadRule unit tests - internal/server/gql/openapi (new test file): 9 resolver-level e2e tests covering happy paths, cross-project denial, missing-scope denial, and ModelMappings normalization regression Example client (examples/openapi/): - synced server schema, added genqlient operations for new mutations - regenerated client code with Decimal scalar binding - README updated with scope requirements and capability list
There was a problem hiding this comment.
Code Review
This pull request introduces new OpenAPI mutations for programmatically managing LLM API keys, including updateAPIKeyProfiles and loadApiKeyProfileTemplate. It updates the GraphQL schema, implements the corresponding resolvers with normalization logic for model mappings, and enhances the privacy layer to allow service accounts to access resources within their own projects. Feedback highlights a security concern regarding the exposure of raw API keys in mutation responses and suggests using explicit denial in privacy rules when required scopes are missing.
| return &APIKey{ | ||
| Key: k.Key, | ||
| Name: k.Name, | ||
| Scopes: k.Scopes, | ||
| Profiles: k.Profiles, | ||
| } |
There was a problem hiding this comment.
The APIKey type includes the sensitive key field (the raw secret). Since updateAPIKeyProfiles and loadApiKeyProfileTemplate return this type, any service account with read_api_keys and write_api_keys scopes can retrieve the raw secrets of other API keys within the same project. This violates the principle of least privilege. While acknowledged in the PR description as a known limitation, this is a high-severity security risk that should be addressed by using a separate output type (e.g., ManagedAPIKey) that excludes the secret key for non-creation mutations.
| if !hasScope(apiKey.Scopes, string(requiredScope)) { | ||
| return privacy.Skipf("API key %d does not have required scope: %s", apiKey.ID, requiredScope) | ||
| } |
There was a problem hiding this comment.
In APIKeyProjectScopeReadRule, if the API key principal is identified but lacks the required scope, it should return privacy.Denyf instead of privacy.Skipf. This ensures that the authorization failure is explicit and consistent with APIKeyScopeQueryRule (line 33) and APIKeyProjectScopeWriteRule (line 96). Using Skipf here might allow the request to proceed if a subsequent rule in the policy accidentally grants access.
| if !hasScope(apiKey.Scopes, string(requiredScope)) { | |
| return privacy.Skipf("API key %d does not have required scope: %s", apiKey.ID, requiredScope) | |
| } | |
| if !hasScope(apiKey.Scopes, string(requiredScope)) { | |
| return privacy.Denyf("API key %d does not have required scope: %s", apiKey.ID, requiredScope) | |
| } |
Greptile SummaryAdds
Confidence Score: 5/5Safe to merge; the two new mutations are thin pass-throughs with correct project-scoped read guards, and the privacy-layer changes are functionally equivalent for all currently existing entities. The resolvers delegate entirely to tested biz methods, the new APIKeyProjectScopeReadRule uses Denyf consistently with sibling rules, cross-project and missing-scope denial are covered by E2E tests, and the UserProjectScopeReadRule Deny→Skip change produces the same net result (default deny) for all entities that don’t already have an API-key read rule. The two comments are about a normalization gap in loadApiKeyProfileTemplate and a forward-looking documentation opportunity — neither is a current defect. No files require special attention; the observations on openapi.resolvers.go and rule_user_project_scope.go are non-blocking. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as OpenAPI Client
participant GQL as /openapi/v1/graphql
participant Auth as WithOpenAPIAuth
participant Policy as Ent Privacy Layer
participant Biz as Biz Service
Client->>GQL: mutation updateAPIKeyProfiles(id, input)
GQL->>Auth: inject APIKey principal into ctx
Auth-->>GQL: ctx with APIKey + ProjectID
GQL->>Policy: Query APIKey by id
Policy->>Policy: UserProjectScopeReadRule → Skip (no user)
Policy->>Policy: APIKeyProjectScopeReadRule → Allow
Policy-->>GQL: APIKey entity
GQL->>GQL: coerce nil ModelMappings → []
GQL->>Biz: APIKeyService.UpdateAPIKeyProfiles
Biz->>Policy: APIKeyProjectScopeWriteRule → Allow
Biz-->>GQL: updated APIKey
GQL-->>Client: APIKey response
Client->>GQL: mutation loadApiKeyProfileTemplate(input)
GQL->>Policy: Query APIKeyProfileTemplate
Policy->>Policy: UserProjectScopeReadRule → Skip (no user)
Policy->>Policy: APIKeyProjectScopeReadRule → Allow
Policy-->>GQL: template entity
GQL->>Biz: APIKeyProfileTemplateService.LoadTemplate
Biz-->>GQL: updated APIKey
GQL-->>Client: APIKey response
Reviews (2): Last reviewed commit: "style: gofumpt-fix trailing newline and ..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
This PR extends the OpenAPI GraphQL surface (/openapi/v1/graphql) to support programmatic API key profile management (update/replace profiles and append profile templates) for service-account API keys, aligning with the OpenAPI graph pattern introduced in #637 and reusing existing admin business services.
Changes:
- Added OpenAPI GraphQL mutations
updateAPIKeyProfilesandloadApiKeyProfileTemplate, plus associated schema/types and resolver wiring. - Introduced
APIKeyProjectScopeReadRuleand adjustedUserProjectScopeReadRuleto allow non-user principals (API keys) to pass through to API-key-based read authorization. - Added OpenAPI end-to-end resolver tests and updated
examples/openapischema + generated client artifacts/documentation.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/server/gql/openapi/resolver.go | Adds template service dependency to OpenAPI schema wiring. |
| internal/server/gql/openapi/openapi.resolvers.go | Implements new OpenAPI mutations and APIKey projection helper. |
| internal/server/gql/openapi/openapi.graphql | Expands OpenAPI schema with profiles/template types and new mutations. |
| internal/server/gql/openapi/openapi_test.go | Adds E2E-style tests for OpenAPI mutations and authz scenarios. |
| internal/server/gql/openapi/models_gen.go | Updates generated OpenAPI models to include profiles and new inputs. |
| internal/server/gql/openapi/graphql.go | Wires new dependency into OpenAPI GraphQL handler. |
| internal/server/gql/openapi/gqlgen.yml | Adds scalar/model mappings used by the OpenAPI gqlgen module. |
| internal/server/gql/openapi/generated.go | Regenerates gqlgen bindings for the expanded OpenAPI schema. |
| internal/scopes/rule_user_project_scope.go | Changes “no user in context” behavior from deny to skip for read rule. |
| internal/scopes/rule_apikey_scope.go | Adds APIKeyProjectScopeReadRule for API-key principals with project scoping. |
| internal/scopes/rule_apikey_scope_test.go | Adds unit tests for APIKeyProjectScopeReadRule. |
| internal/ent/schema/api_key.go | Adds API-key-principal read rule to APIKey query policy. |
| internal/ent/schema/api_key_profile_template.go | Adds API-key-principal read rule to template query policy. |
| examples/openapi/README.md | Documents new mutations, required scopes, and semantics. |
| examples/openapi/graphql/openapi.graphql | Syncs example schema with backend OpenAPI schema. |
| examples/openapi/graphql/genqlient.yaml | Updates genqlient bindings for Decimal scalars and formatting. |
| examples/openapi/graphql/generated.go | Regenerates example Go client for new mutations/types. |
| examples/openapi/graphql/api_key.graphql | Adds example operations for new mutations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // toOpenAPIAPIKey projects the rich ent.APIKey down to the minimal OpenAPI | ||
| // surface — only what programmatic callers need (key/name/scopes/profiles). | ||
| func toOpenAPIAPIKey(k *ent.APIKey) *APIKey { | ||
| if k == nil { | ||
| return nil | ||
| } | ||
|
|
||
| return &APIKey{ | ||
| Key: k.Key, | ||
| Name: k.Name, | ||
| Scopes: k.Scopes, | ||
| Profiles: k.Profiles, | ||
| } |
|
|
||
| input UpdateAPIKeyProfilesInput { | ||
| activeProfile: String! | ||
| profiles: [APIKeyProfileInput!] |
| - github.com/looplj/axonhub/internal/objects.Decimal | ||
| DecimalInput: | ||
| model: | ||
| - github.com/looplj/axonhub/internal/objects.Decimal |
Add id: ID! to the OpenAPI APIKey output so callers can chain createLLMAPIKey into updateAPIKeyProfiles / loadApiKeyProfileTemplate without a separate lookup. Previously the only way to obtain a freshly-created LLM key's id was via admin UI / direct DB access, which programmatic OpenAPI clients don't have access to. Mechanical changes: - openapi.graphql: APIKey gains id field - toOpenAPIAPIKey lifted into a new helper.go file so subsequent gqlgen regenerations don't sweep it into the warning block as unknown code - examples/openapi/graphql: schema mirror synced; api_key.graphql operations now select id; genqlient client regenerated
Address greptile-apps review on PR looplj#1617: P2 — APIKeyProjectScopeReadRule now Denyf (was Skipf) when an API key principal lacks the required scope. Aligns with the explicit-deny contract used by APIKeyScopeQueryRule and APIKeyProjectScopeWriteRule. Skip is reserved for "this rule's principal type doesn't apply"; here the principal IS an API key, just unauthorised. P1 — UpdateAPIKeyProfilesInput.profiles is now non-null ([APIKeyProfileInput!]!). Closes the gap where a client could submit profiles: null and rely on biz validateActiveProfile to reject — failing earlier at the GraphQL parse layer is the safer contract. Diverges from admin's looser type intentionally; admin/openapi schemas are independently versioned.
Summary
Adds two GraphQL mutations to
/openapi/v1/graphqlfor programmatic LLM API key profile management via service-account keys, mirroring admin's existing capabilities under the OpenAPI design pattern from #637.updateAPIKeyProfiles(id, input)— integral state replacement of a key's profiles listloadApiKeyProfileTemplate(input)— append a project'sAPIKeyProfileTemplateto a target keyBoth resolvers are thin pass-throughs to the existing admin biz methods (
APIKeyService.UpdateAPIKeyProfiles,APIKeyProfileTemplateService.LoadTemplate) — zero new business logic. Schema, output types, and gqlgen bindings live in the standaloneinternal/server/gql/openapimodule; admin GraphQL is unchanged.Privacy / scope model
scopes.APIKeyProjectScopeReadRulescopes API-key-principal reads to the caller's project + required scope. Mounted on bothAPIKeyandAPIKeyProfileTemplateQuery policies (the only schemas the new mutations read).scopes.UserProjectScopeReadRulenow skips when no user is in context (mirroringUserProjectScopeWriteRule's convention). Without this, the user-rule firesDenybefore the API-key rule gets to evaluate. Behavior is identical for legitimate user principals; only the no-user fallthrough changes.Required scopes for the new mutations:
read_api_keys+write_api_keys(write mutates target key, read fetches target/template via biz). ExistingcreateLLMAPIKeyonly requireswrite_api_keys.Frontend compatibility fix
The OpenAPI
updateAPIKeyProfilesresolver coerces nilModelMappingsto[]before save. The admin UI's Zod schema strictly requires a non-null array formodelMappings(sibling array fields are.nullable()), so OpenAPI clients omitting the field would otherwise produce rows the UI can't render. Scoped to the OpenAPI resolver because admin GraphQL inputs are validated client-side and never carry nil here.Known limitation (follow-up)
Mutation responses (including
updateAPIKeyProfiles/loadApiKeyProfileTemplate) currently exposekey: String!via the OpenAPIAPIKeyoutput type. A service account withread_api_keys + write_api_keyscould call these mutations on any same-project key id and read its raw bearer in the response. Mitigation (separateCreatedAPIKeyforcreateLLMAPIKeyandAPIKeywithoutkeyfor management mutations) deferred to a follow-up.Test plan
go test ./internal/scopes/...— newAPIKeyProjectScopeReadRuleunit tests passgo test ./internal/server/gql/openapi/...— 9 new e2e tests pass (happy paths × 3 mutations, cross-project denial × 2, missing-scope denial × 3, modelMappings normalization regression × 1)go test ./internal/server/biz/...— admin biz tests not regressed by the privacy rule changego test ./internal/server/gql/...— admin GraphQL resolver tests not regressedcurlagainst/openapi/v1/graphql{"error":{"code":"quota_exceeded","message":"requests quota exceeded: 2/2"}}after limit hitexamples/openapi/rebuilt with genqlient against synced schema; example program compiles