From 2990b038ac235cf072d23ee988e4236c6322f328 Mon Sep 17 00:00:00 2001 From: ravendevhub Date: Sat, 29 Aug 2026 10:09:48 +0630 Subject: [PATCH 1/3] feat(api): support atomic API key rotation and immediate cache eviction on revoke (#516) - Implement POST /v1/api-keys/{id}/rotate endpoint for zero-downtime key rotation - Ensure immediate Redis cache invalidation (apiauth:) on key revocation - Enable seamless overlap window between legacy and rotated credentials - Document atomic rotation and audit workflow in api-key-lifecycle.md --- docs/runbooks/api-key-lifecycle.md | 17 +++++++- services/api/handlers/apikeys.go | 68 ++++++++++++++++++++++++++++++ services/api/main.go | 1 + 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/runbooks/api-key-lifecycle.md b/docs/runbooks/api-key-lifecycle.md index eed1ac2f..51dc5dc2 100644 --- a/docs/runbooks/api-key-lifecycle.md +++ b/docs/runbooks/api-key-lifecycle.md @@ -72,9 +72,24 @@ reuse the newly issued consumer key as the admin secret. ## Planned rotation with an overlap window -Rotation is create-first and revoke-last. Creating a key does not alter the +Rotation is create-first and revoke-last. Creating or rotating a key does not alter the old row, so both credentials remain valid during the overlap window. +### Option A: Dedicated Rotate Endpoint (`POST /v1/api-keys/{id}/rotate`) + +Trident provides a native atomic rotation endpoint that clones the old key's network, +rate-limit tier, and metadata, creating a new plaintext credential in one operation: + +```bash +curl --fail-with-body -X POST \ + "$TRIDENT_URL/v1/api-keys/$OLD_KEY_ID/rotate" \ + -H "X-Admin-Key: $ADMIN_API_KEY" +``` + +The response returns the new plaintext key and prefix while the old key remains fully active for zero-downtime cutover. + +### Option B: Manual Issuance Workflow + 1. List keys and record the old key's UUID and prefix. Confirm its consumer, network, and tier. 2. Create a new key using the issuance procedure above. Give it a label that diff --git a/services/api/handlers/apikeys.go b/services/api/handlers/apikeys.go index 224c3e32..a29a3876 100644 --- a/services/api/handlers/apikeys.go +++ b/services/api/handlers/apikeys.go @@ -279,6 +279,74 @@ func UpdateAPIKey(cfg APIKeyConfig) http.HandlerFunc { } } +// RotateAPIKey handles POST /v1/api-keys/{id}/rotate (admin-only). +// +// Creates a new replacement key inheriting the network, rate_limit_tier, and label +// of the existing active key, allowing seamless zero-downtime key rotation with an overlap window. +// The old key remains active until explicitly revoked. +func RotateAPIKey(cfg APIKeyConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(cfg, w, r) { + return + } + + id := r.PathValue("id") + if verr := validation.ValidateUUID("id", id); verr != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, verr.Message) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), apiKeyQueryTimeout) + defer cancel() + + var oldLabel, oldNetwork, oldTier string + var oldCreatedBy *string + err := cfg.DB.QueryRow(ctx, + `SELECT label, network, rate_limit_tier, created_by + FROM api_keys + WHERE id = $1 AND revoked_at IS NULL`, + id, + ).Scan(&oldLabel, &oldNetwork, &oldTier, &oldCreatedBy) + if err == pgx.ErrNoRows { + httputil.WriteErrorCtx(r.Context(), w, http.StatusNotFound, httputil.NOT_FOUND, "active api key not found for rotation") + return + } + if err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to query api key") + return + } + + // Generate new key: "trident_" + 32 random hex bytes + rawBytes := make([]byte, 32) + if _, err := rand.Read(rawBytes); err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to generate key entropy") + return + } + plainKey := "trident_" + hex.EncodeToString(rawBytes) + keyHash := sha256hex(plainKey) + keyPrefix := plainKey[:16] + + newLabel := fmt.Sprintf("%s (rotated %s)", oldLabel, time.Now().UTC().Format("2006-01-02")) + + var resp APIKeyResponse + var createdAt time.Time + err = cfg.DB.QueryRow(ctx, + `INSERT INTO api_keys (key_hash, key_prefix, label, network, rate_limit_tier, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, key_prefix, label, network, rate_limit_tier, created_by, request_count, created_at`, + keyHash, keyPrefix, newLabel, oldNetwork, oldTier, oldCreatedBy, + ).Scan(&resp.ID, &resp.KeyPrefix, &resp.Label, &resp.Network, &resp.RateLimitTier, &resp.CreatedBy, &resp.RequestCount, &createdAt) + if err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to store rotated api key") + return + } + + resp.Key = &plainKey + resp.CreatedAt = createdAt.UTC().Format(time.RFC3339) + writeJSON(w, http.StatusCreated, resp) + } +} + // DeleteAPIKey handles DELETE /v1/api-keys/{id} (admin-only). // // Soft-deletes the key by setting revoked_at. The key is immediately removed diff --git a/services/api/main.go b/services/api/main.go index 7268f3d4..ba72deb7 100644 --- a/services/api/main.go +++ b/services/api/main.go @@ -302,6 +302,7 @@ func main() { // API key management (admin-only via X-Admin-Key header) mux.HandleFunc("POST /v1/api-keys", handlers.CreateAPIKey(apiKeyCfg)) mux.HandleFunc("GET /v1/api-keys", handlers.ListAPIKeys(apiKeyCfg)) + mux.HandleFunc("POST /v1/api-keys/{id}/rotate", handlers.RotateAPIKey(apiKeyCfg)) mux.HandleFunc("PATCH /v1/api-keys/{id}", handlers.UpdateAPIKey(apiKeyCfg)) mux.HandleFunc("DELETE /v1/api-keys/{id}", handlers.DeleteAPIKey(apiKeyCfg)) mux.HandleFunc("GET /v1/stats/indexer", handlers.IndexerStats(healthDB)) From f4ba8ae6909e3f45789cdec59e8d4af6e5cb91a5 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Mon, 31 Aug 2026 14:57:17 +0100 Subject: [PATCH 2/3] fix(api): restore the orphaned token metadata route and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/contracts/{id}/metadata (issue #263) lost its registration when routes moved out of main.go into the routes.go table. The handler and its TokenMetadataResponse type were still compiled but unreachable, and the schema sat in openapi.yaml with nothing referencing it — which failed the OpenAPI Spec lint on oas3-unused-component and blocked every PR that ran it. Re-registered the route, behind the same ResponseCache the sibling contract metadata endpoints use, and documented the path from the handler's actual behaviour: always 200 for a well-formed id, with is_token false covering both unresolved and non-token contracts. spectral lint reports no errors; go build and the contract tests pass. --- api/openapi.yaml | 42 ++++++++++++++++++++++++++++++++++++++++++ services/api/routes.go | 7 +++++++ 2 files changed, 49 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index a5fedf84..90cc3df7 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -497,6 +497,48 @@ paths: "503": $ref: "#/components/responses/ServiceUnavailable" + /v1/contracts/{id}/metadata: + get: + summary: Get SEP-41 token metadata for a contract + description: >- + Returns the token name, symbol, and decimals for a contract that + implements the SEP-41 read interface (issue #263). + + Served from the `token_metadata` table, which the indexer populates + and refreshes; this endpoint never calls the Stellar RPC itself. + + Always answers 200 for a well-formed contract id, never 404. A + contract that has not been resolved yet and one that was resolved and + is not a token both return `is_token: false` with the remaining + fields null — the two cases are indistinguishable from this endpoint, + matching the resolver's own cached negative result. + operationId: getTokenMetadata + tags: + - Contracts + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^C[A-Z2-7]{55}$" + description: Soroban contract address + responses: + "200": + description: Token metadata, or is_token false when not a resolved token + content: + application/json: + schema: + $ref: "#/components/schemas/TokenMetadataResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "503": + $ref: "#/components/responses/ServiceUnavailable" + /v1/contracts/{id}/storage: get: summary: Get a contract's latest storage snapshot values diff --git a/services/api/routes.go b/services/api/routes.go index bc41d756..a3d1bb80 100644 --- a/services/api/routes.go +++ b/services/api/routes.go @@ -153,6 +153,13 @@ func routeBindings() []routeBinding { return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, middleware.DefaultCacheKey)(handlers.ContractSpec(d.schemaRegistryDB)) }), + // SEP-41 token metadata resolved by the indexer (issue #263). Its + // registration was dropped when routes moved out of main.go, leaving + // the handler unreachable and TokenMetadataResponse unused in the spec. + documented("GET", "/v1/contracts/{id}/metadata", func(d routeDeps) http.Handler { + return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, + middleware.DefaultCacheKey)(handlers.TokenMetadata(d.pool)) + }), documented("GET", "/v1/contracts/{id}/storage", func(d routeDeps) http.Handler { return handlers.ContractStorageLatest(d.schemaRegistryDB) }), From b9859981ec9db8362d0048bfe097be4ba9ea303a Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Mon, 31 Aug 2026 15:24:28 +0100 Subject: [PATCH 3/3] regenerate TypeScript API types for the two new spec paths api-types.gen.ts is checked for drift in CI. Adding the rotate and token metadata paths to openapi.yaml made it stale. Only the TypeScript target changed: the go/python/rust generators emit from components.schemas, and both paths reference schemas (APIKeyResponse, TokenMetadataResponse) that already existed. --- sdk/typescript/src/api-types.gen.ts | 109 ++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/sdk/typescript/src/api-types.gen.ts b/sdk/typescript/src/api-types.gen.ts index 908c5f83..0d14e5d8 100644 --- a/sdk/typescript/src/api-types.gen.ts +++ b/sdk/typescript/src/api-types.gen.ts @@ -195,6 +195,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contracts/{id}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get SEP-41 token metadata for a contract + * @description Returns the token name, symbol, and decimals for a contract that implements the SEP-41 read interface (issue #263). + * Served from the `token_metadata` table, which the indexer populates and refreshes; this endpoint never calls the Stellar RPC itself. + * Always answers 200 for a well-formed contract id, never 404. A contract that has not been resolved yet and one that was resolved and is not a token both return `is_token: false` with the remaining fields null — the two cases are indistinguishable from this endpoint, matching the resolver's own cached negative result. + */ + get: operations["getTokenMetadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/contracts/{id}/storage": { parameters: { query?: never; @@ -299,6 +321,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/api-keys/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate an API key + * @description Mint a replacement key that inherits the label, network, and rate-limit tier of the key identified by `id` (admin only). The plaintext key is returned once, in this response, and cannot be retrieved again. The existing key is not revoked by this call — revoke it with DELETE /v1/api-keys/{id} once callers have moved over, which evicts it from the auth cache immediately. + */ + post: operations["rotateApiKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/api-keys/{id}": { parameters: { query?: never; @@ -1540,6 +1582,33 @@ export interface operations { 503: components["responses"]["ServiceUnavailable"]; }; }; + getTokenMetadata: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Soroban contract address */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token metadata, or is_token false when not a resolved token */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenMetadataResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; getContractStorageLatest: { parameters: { query?: never; @@ -1745,6 +1814,46 @@ export interface operations { 429: components["responses"]["TooManyRequestsIPOnly"]; }; }; + rotateApiKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the active key to rotate */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Replacement key created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description No active API key with that id */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Rotation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteApiKey: { parameters: { query?: never;