From 4600c3fc622fd12c48c7cba5eba4234e9de1c324 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:02:23 +0000 Subject: [PATCH] feat: show when each API key was last used Give each API key a random, non-secret public_id. OpenSecret sends it as api_key_id in the usage event of API-key requests, and the API key settings read each key's latest billed use from the billing server by that ID. The key, its hash and its name stay in the enclave. The TypeScript and Rust SDK key types gain an optional public_id, so they keep working against servers that predate it. Refs #1138 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HhSn98TEgLj9EwtFekv8Qy --- .../frontend/src/billing/billingApi.test.ts | 42 ++++++- .../frontend/src/billing/billingApi.ts | 35 ++++++ .../frontend/src/billing/billingService.ts | 6 + .../settings/api/ApiKeysSettings.test.tsx | 115 ++++++++++++++++++ .../settings/api/ApiKeysSettings.tsx | 22 +++- .../src/components/settings/api/useApiKeys.ts | 19 +++ sdk/rust/src/types.rs | 36 ++++++ sdk/rust/tests/api_keys.rs | 7 ++ sdk/src/lib/api.ts | 2 + sdk/src/lib/test/integration/apiKeys.test.ts | 4 + .../down.sql | 2 + .../up.sql | 6 + .../opensecret/src/aead_db_tamper_tests.rs | 100 +++++++++++++++ services/opensecret/src/db.rs | 11 +- services/opensecret/src/models/schema.rs | 1 + .../opensecret/src/models/user_api_keys.rs | 4 + .../opensecret/src/security_invariants.rs | 2 +- services/opensecret/src/sqs.rs | 33 +++++ services/opensecret/src/web/openai.rs | 50 ++++++-- services/opensecret/src/web/openai_auth.rs | 25 +++- .../opensecret/src/web/protected_routes.rs | 7 ++ 21 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.test.tsx create mode 100644 services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/down.sql create mode 100644 services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/up.sql diff --git a/apps/maple-research/frontend/src/billing/billingApi.test.ts b/apps/maple-research/frontend/src/billing/billingApi.test.ts index 7d8c45753..0c6eaa2a9 100644 --- a/apps/maple-research/frontend/src/billing/billingApi.test.ts +++ b/apps/maple-research/frontend/src/billing/billingApi.test.ts @@ -1,5 +1,5 @@ import { expect, spyOn, test } from "bun:test"; -import { createZapriteUpgrade, fetchProducts } from "./billingApi"; +import { createZapriteUpgrade, fetchBillingApiKeys, fetchProducts } from "./billingApi"; test("fetchProducts rejects an HTTP error response before parsing it as products", async () => { const previousBillingApiUrl = process.env.VITE_MAPLE_BILLING_API_URL; @@ -72,3 +72,43 @@ test("createZapriteUpgrade rejects a checkout URL from the wrong host", async () fetchSpy.mockRestore(); } }); + +test("fetchBillingApiKeys reads per-key usage with the billing token", async () => { + const previousBillingApiUrl = process.env.VITE_MAPLE_BILLING_API_URL; + process.env.VITE_MAPLE_BILLING_API_URL = "https://billing.example.test"; + const body = { + api_keys: [ + { + api_key_id: "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa", + last_used_at: "2026-10-01T12:00:00Z" + } + ] + }; + const fetchSpy = spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ) + .mockResolvedValueOnce(new Response("Invalid JWT token", { status: 401 })); + const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(fetchBillingApiKeys("billing-token")).resolves.toEqual(body); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://billing.example.test/v1/maple/api-keys"); + expect((init.headers as Record).Authorization).toBe("Bearer billing-token"); + + // The billing service mints a new token after this error. + await expect(fetchBillingApiKeys("expired-token")).rejects.toThrow("Unauthorized"); + } finally { + if (previousBillingApiUrl === undefined) { + delete process.env.VITE_MAPLE_BILLING_API_URL; + } else { + process.env.VITE_MAPLE_BILLING_API_URL = previousBillingApiUrl; + } + fetchSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + } +}); diff --git a/apps/maple-research/frontend/src/billing/billingApi.ts b/apps/maple-research/frontend/src/billing/billingApi.ts index f8e340bb4..62c0a738f 100644 --- a/apps/maple-research/frontend/src/billing/billingApi.ts +++ b/apps/maple-research/frontend/src/billing/billingApi.ts @@ -846,6 +846,41 @@ export async function fetchApiCreditBalance(thirdPartyToken: string): Promise { + const response = await fetch(`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-keys`, { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Fetch API key usage error:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to fetch API key usage: ${errorText}`); + } + + return response.json(); +} + export async function fetchApiCreditSettings(thirdPartyToken: string): Promise { const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/settings`, diff --git a/apps/maple-research/frontend/src/billing/billingService.ts b/apps/maple-research/frontend/src/billing/billingService.ts index e497e8f69..dadf99910 100644 --- a/apps/maple-research/frontend/src/billing/billingService.ts +++ b/apps/maple-research/frontend/src/billing/billingService.ts @@ -21,11 +21,13 @@ import { updateTeamName, fetchApiCreditBalance, fetchApiCreditSettings, + fetchBillingApiKeys, purchaseApiCredits, purchaseApiCreditsZaprite, updateApiCreditSettings, ApiCreditBalance, ApiCreditSettings, + BillingApiKeysResponse, PurchaseCreditsRequest, PurchaseCreditsZapriteRequest, CheckoutResponse, @@ -331,6 +333,10 @@ export class BillingService { return this.executeWithToken((token) => fetchApiCreditBalance(token)); } + async getApiKeys(): Promise { + return this.executeWithToken((token) => fetchBillingApiKeys(token)); + } + async getApiCreditSettings(): Promise { return this.executeWithToken((token) => fetchApiCreditSettings(token)); } diff --git a/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.test.tsx b/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.test.tsx new file mode 100644 index 000000000..9ffebe447 --- /dev/null +++ b/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterContextProvider +} from "@tanstack/react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import * as sdk from "@mapleai/sdk"; +import * as billingService from "@/billing/billingService"; +import { SettingsNavigationLockContext } from "@/contexts/SettingsNavigationLockContext"; +import { ApiKeysSettings } from "./ApiKeysSettings"; +import type { ApiKeyLastUsed, ApiKeySummary } from "./useApiKeys"; + +const USED_AT = "2026-10-01T12:00:00Z"; +const API_KEYS: ApiKeySummary[] = [ + { + name: "Used key", + created_at: "2026-09-01T12:00:00Z", + public_id: "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa" + }, + { + name: "Unused key", + created_at: "2026-08-01T12:00:00Z", + public_id: "6142db59-fc0c-413d-8792-579fc1457fe2" + } +]; + +function textContent(node: ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : textContent(child))) + .join(""); +} + +describe("API key last used", () => { + let renderer: ReactTestRenderer | null = null; + const cleanup: Array<() => void> = []; + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = null; + cleanup + .splice(0) + .reverse() + .forEach((restore) => restore()); + }); + + async function mount(lastUsed?: ApiKeyLastUsed) { + const authSpy = spyOn(sdk, "useOpenSecret").mockReturnValue({ + auth: { user: { user: { id: "account-fixture" } }, loading: false }, + listApiKeys: async () => ({ keys: API_KEYS }), + deleteApiKey: async () => {} + } as unknown as ReturnType); + const billingSpy = spyOn(billingService, "getBillingService").mockReturnValue({ + getApiKeys: () => Promise.reject(new Error("billing unavailable")) + } as unknown as billingService.BillingService); + cleanup.push( + () => authSpy.mockRestore(), + () => billingSpy.mockRestore() + ); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } + }); + client.setQueryData(["apiKeys"], API_KEYS); + if (lastUsed) client.setQueryData(["apiKeyLastUsed"], lastUsed); + cleanup.push(() => client.clear()); + + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: ["/"] }), + isServer: false, + origin: "http://localhost" + }); + await router.load(); + + await act(async () => { + renderer = create( + + + {} }} + > + + + + + ); + }); + return textContent(renderer!.root); + } + + test("shows each key's latest billed use from billing", async () => { + const content = await mount(new Map([[API_KEYS[0].public_id!, USED_AT]])); + const usedAt = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }).format(new Date(USED_AT)); + + expect(content).toContain(`Last used ${usedAt}`); + expect(content).toContain("No use recorded yet"); + expect(content).toContain("most recent request that used credits"); + }); + + test("leaves last used out when billing usage is unavailable", async () => { + const content = await mount(); + + expect(content).toContain("Used key"); + expect(content).not.toContain("Last used"); + expect(content).not.toContain("No use recorded yet"); + }); +}); diff --git a/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.tsx b/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.tsx index 22c9af90d..0229d6b30 100644 --- a/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.tsx +++ b/apps/maple-research/frontend/src/components/settings/api/ApiKeysSettings.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { Link, useBlocker } from "@tanstack/react-router"; import { useOpenSecret } from "@mapleai/sdk"; -import { AlertCircle, Calendar, KeyRound, Loader2, Plus, Trash2 } from "lucide-react"; +import { AlertCircle, Calendar, Clock, KeyRound, Loader2, Plus, Trash2 } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext"; import { SettingsSection } from "../SettingsPage"; -import { useApiKeys } from "./useApiKeys"; +import { useApiKeyLastUsed, useApiKeys } from "./useApiKeys"; const API_KEY_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", @@ -23,6 +23,8 @@ function formatDate(dateString: string) { export function ApiKeysSettings() { const { deleteApiKey } = useOpenSecret(); const { data: apiKeys, isLoading, error, refetch } = useApiKeys(); + // Usage comes from billing; without it, the list simply omits last-used details. + const { data: lastUsed } = useApiKeyLastUsed(); const [pendingDelete, setPendingDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); @@ -92,6 +94,8 @@ export function ApiKeysSettings() {
{apiKeys.map((apiKey) => { const isConfirmingDelete = pendingDelete === apiKey.name; + const lastUsedAt = + lastUsed && apiKey.public_id ? (lastUsed.get(apiKey.public_id) ?? null) : undefined; return (
@@ -102,6 +106,14 @@ export function ApiKeysSettings() { Created {formatDate(apiKey.created_at)}

+ {lastUsedAt !== undefined && ( +

+ + {lastUsedAt + ? `Last used ${formatDate(lastUsedAt)}` + : "No use recorded yet"} +

+ )}
{!isConfirmingDelete && (
diff --git a/apps/maple-research/frontend/src/components/settings/api/useApiKeys.ts b/apps/maple-research/frontend/src/components/settings/api/useApiKeys.ts index fc58212eb..fbb7ea316 100644 --- a/apps/maple-research/frontend/src/components/settings/api/useApiKeys.ts +++ b/apps/maple-research/frontend/src/components/settings/api/useApiKeys.ts @@ -1,11 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { useOpenSecret } from "@mapleai/sdk"; +import { getBillingService } from "@/billing/billingService"; export type ApiKeySummary = { name: string; created_at: string; + /** Non-secret key ID that billing reports usage by. Absent from older servers. */ + public_id?: string; }; +/** Each key's latest request that used credits, by `public_id`. */ +export type ApiKeyLastUsed = Map; + export function useApiKeys() { const { auth, listApiKeys } = useOpenSecret(); @@ -20,3 +26,16 @@ export function useApiKeys() { enabled: !!auth.user && !auth.loading }); } + +export function useApiKeyLastUsed() { + const { auth } = useOpenSecret(); + + return useQuery({ + queryKey: ["apiKeyLastUsed"], + queryFn: async () => { + const { api_keys } = await getBillingService().getApiKeys(); + return new Map(api_keys.map((key) => [key.api_key_id, key.last_used_at])); + }, + enabled: !!auth.user && !auth.loading + }); +} diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index db513370b..66c3b58f8 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -495,6 +495,10 @@ pub struct ConfirmAccountDeletionRequest { pub struct ApiKey { pub name: String, pub created_at: DateTime, + /// Non-secret key ID that billing uses to report per-key usage. `None` + /// from servers that predate it. + #[serde(default)] + pub public_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -512,6 +516,10 @@ pub struct ApiKeyCreateResponse { pub key: String, // UUID format with dashes, only returned on creation pub name: String, pub created_at: DateTime, + /// Non-secret key ID that billing uses to report per-key usage. `None` + /// from servers that predate it. + #[serde(default)] + pub public_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1441,6 +1449,34 @@ mod tests { ); } + #[test] + fn api_key_public_id_is_optional_for_older_servers() { + let listed: ApiKeyListResponse = serde_json::from_value(json!({ + "keys": [ + { "name": "old", "created_at": "2026-10-11T00:00:00Z" }, + { + "name": "new", + "created_at": "2026-10-11T00:00:00Z", + "public_id": "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa" + } + ] + })) + .unwrap(); + assert_eq!(listed.keys[0].public_id, None); + assert_eq!( + listed.keys[1].public_id, + Some(Uuid::parse_str("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa").unwrap()) + ); + + let created: ApiKeyCreateResponse = serde_json::from_value(json!({ + "key": "6142db59-fc0c-413d-8792-579fc1457fe2", + "name": "old", + "created_at": "2026-10-11T00:00:00Z" + })) + .unwrap(); + assert_eq!(created.public_id, None); + } + #[test] fn credential_update_response_tolerates_missing_message() { let response: CredentialUpdateResponse = diff --git a/sdk/rust/tests/api_keys.rs b/sdk/rust/tests/api_keys.rs index 2fa3d7937..c05e2028c 100644 --- a/sdk/rust/tests/api_keys.rs +++ b/sdk/rust/tests/api_keys.rs @@ -69,12 +69,19 @@ async fn test_create_list_delete_api_key() -> Result<()> { let uuid_result = Uuid::parse_str(&created_key.key); assert!(uuid_result.is_ok(), "API key should be a valid UUID"); + // The non-secret key ID is separate from the key itself + let public_id = created_key + .public_id + .expect("the in-tree server should return the key's public ID"); + assert_ne!(public_id.to_string(), created_key.key); + // List API keys and verify the new key is present let keys = client.list_api_keys().await?; let found_key = keys.iter().find(|k| k.name == created_key.name); assert!(found_key.is_some()); assert_eq!(found_key.unwrap().name, key_name); + assert_eq!(found_key.unwrap().public_id, Some(public_id)); // Delete the API key client.delete_api_key(&created_key.name).await?; diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index 7d3687be2..3fb6d8cca 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -1484,6 +1484,8 @@ export type DocumentUploadInitResponse = { export type ApiKey = { name: string; created_at: string; + /** Non-secret key ID that billing uses to report per-key usage. Absent from older servers. */ + public_id?: string; }; export type ApiKeyCreateResponse = ApiKey & { diff --git a/sdk/src/lib/test/integration/apiKeys.test.ts b/sdk/src/lib/test/integration/apiKeys.test.ts index 7f041f26a..848507d2d 100644 --- a/sdk/src/lib/test/integration/apiKeys.test.ts +++ b/sdk/src/lib/test/integration/apiKeys.test.ts @@ -40,6 +40,9 @@ describe("API Key Management", () => { // Verify the key format is a UUID with dashes const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; expect(createdKey.key).toMatch(uuidRegex); + // The non-secret key ID is separate from the key itself + expect(createdKey.public_id).toMatch(uuidRegex); + expect(createdKey.public_id).not.toBe(createdKey.key); // List API keys and verify the new key is present const response = await listApiKeys(); @@ -52,6 +55,7 @@ describe("API Key Management", () => { expect(foundKey).toBeDefined(); expect(foundKey!.name).toBe(keyName); expect(foundKey!.created_at).toBe(createdKey.created_at); + expect(foundKey!.public_id).toBe(createdKey.public_id); // The key field should NOT be present in the list response expect((foundKey as any).key).toBeUndefined(); diff --git a/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/down.sql b/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/down.sql new file mode 100644 index 000000000..6670560e2 --- /dev/null +++ b/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_api_keys + DROP COLUMN IF EXISTS public_id; diff --git a/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/up.sql b/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/up.sql new file mode 100644 index 000000000..95bfca5e4 --- /dev/null +++ b/services/opensecret/migrations/2026-10-11-000000_user_api_keys_public_id/up.sql @@ -0,0 +1,6 @@ +-- A random, non-secret identifier for each API key. It is the only key +-- identifier that may leave the enclave (for example in usage events) and is +-- unrelated to the key secret and its hash. Existing keys each get their own +-- value because the default is evaluated per row. +ALTER TABLE user_api_keys + ADD COLUMN public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE; diff --git a/services/opensecret/src/aead_db_tamper_tests.rs b/services/opensecret/src/aead_db_tamper_tests.rs index 2c04b8935..f8fda142f 100644 --- a/services/opensecret/src/aead_db_tamper_tests.rs +++ b/services/opensecret/src/aead_db_tamper_tests.rs @@ -161,6 +161,106 @@ async fn db_account_deletion_keeps_confirmation_on_billing_failure_then_retries( let _ = server.await; } +#[tokio::test] +#[ignore = "requires AEAD_TAMPER_TEST_DATABASE_URL pointing at disposable migrated local Postgres"] +async fn db_api_key_auth_attributes_requests_to_the_key_public_id() { + use crate::web::openai_auth::{validate_openai_auth, AuthMethod}; + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + middleware::from_fn_with_state, + routing::get, + Extension, Router, + }; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + + let database_url = std::env::var("AEAD_TAMPER_TEST_DATABASE_URL") + .expect("this test requires disposable migrated Postgres"); + let app_state = Arc::new(build_local_test_app_state(database_url).await); + let project = first_active_project(&app_state); + let user = app_state + .db + .create_user(NewUser::new(None, None, project.id)) + .unwrap(); + let marker = Uuid::new_v4(); + let api_key = Uuid::new_v4(); + let used_key = app_state + .db + .create_user_api_key(NewUserApiKey::new( + user.uuid, + format!("{:x}", Sha256::digest(api_key.to_string().as_bytes())), + format!("public-id-used-{marker}"), + )) + .unwrap(); + let other_key = app_state + .db + .create_user_api_key(NewUserApiKey::new( + user.uuid, + format!("test-api-key-hash-{marker}"), + format!("public-id-other-{marker}"), + )) + .unwrap(); + assert!(!used_key.public_id.is_nil()); + assert_ne!( + used_key.public_id, other_key.public_id, + "each API key must get its own public ID" + ); + let mut listed_public_ids: Vec = app_state + .db + .get_all_user_api_keys_for_user(user.uuid) + .unwrap() + .into_iter() + .map(|key| key.public_id) + .collect(); + listed_public_ids.sort(); + let mut created_public_ids = vec![used_key.public_id, other_key.public_id]; + created_public_ids.sort(); + assert_eq!(listed_public_ids, created_public_ids); + + let router = Router::new() + .route( + "/", + get( + |Extension(user): Extension, + Extension(auth_method): Extension| async move { + format!("{}|{:?}", user.uuid, auth_method.api_key_public_id()) + }, + ), + ) + .layer(from_fn_with_state(app_state.clone(), validate_openai_auth)); + let request_with_key = |key: Uuid| { + Request::builder() + .uri("/") + .header(header::AUTHORIZATION, format!("Bearer {key}")) + .body(Body::empty()) + .unwrap() + }; + + let response = router + .clone() + .oneshot(request_with_key(api_key)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + body, + format!("{}|{:?}", user.uuid, Some(used_key.public_id)).as_bytes(), + "API-key auth must attribute the request to the presented key's public ID" + ); + + let unknown_key_response = router + .oneshot(request_with_key(Uuid::new_v4())) + .await + .unwrap(); + assert_eq!(unknown_key_response.status(), StatusCode::UNAUTHORIZED); + + let _ = app_state.db.delete_user(&user); +} + #[tokio::test] #[ignore = "requires AEAD_TAMPER_TEST_DATABASE_URL pointing at disposable migrated local Postgres"] async fn db_password_registration_creates_initial_seed_wrap_and_login_works() { diff --git a/services/opensecret/src/db.rs b/services/opensecret/src/db.rs index 7cdb727ac..990b10aa7 100644 --- a/services/opensecret/src/db.rs +++ b/services/opensecret/src/db.rs @@ -503,7 +503,8 @@ pub trait DBConnection { fn create_user_api_key(&self, new_key: NewUserApiKey) -> Result; fn get_user_api_key_by_id(&self, id: i32) -> Result, DBError>; fn get_user_api_key_by_hash(&self, key_hash: &str) -> Result, DBError>; - fn get_user_by_api_key_hash(&self, key_hash: &str) -> Result, DBError>; + /// Returns the key's owner and the key's non-secret `public_id`. + fn get_user_by_api_key_hash(&self, key_hash: &str) -> Result, DBError>; fn get_all_user_api_keys_for_user(&self, user_id: Uuid) -> Result, DBError>; fn delete_user_api_key(&self, id: i32, user_id: Uuid) -> Result<(), DBError>; fn delete_user_api_key_by_name(&self, name: &str, user_id: Uuid) -> Result<(), DBError>; @@ -2070,18 +2071,18 @@ impl DBConnection for PostgresConnection { UserApiKey::get_by_key_hash(conn, key_hash).map_err(DBError::from) } - fn get_user_by_api_key_hash(&self, key_hash: &str) -> Result, DBError> { + fn get_user_by_api_key_hash(&self, key_hash: &str) -> Result, DBError> { use crate::models::schema::{user_api_keys, users}; use diesel::prelude::*; let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - // Single JOIN query to get user directly from API key hash + // Single JOIN query to get the user and the key's public ID from the hash users::table .inner_join(user_api_keys::table.on(users::uuid.eq(user_api_keys::user_id))) .filter(user_api_keys::key_hash.eq(key_hash)) - .select(users::all_columns) - .first::(conn) + .select((users::all_columns, user_api_keys::public_id)) + .first::<(User, Uuid)>(conn) .optional() .map_err(DBError::from) } diff --git a/services/opensecret/src/models/schema.rs b/services/opensecret/src/models/schema.rs index 7db204790..46d71a1ac 100644 --- a/services/opensecret/src/models/schema.rs +++ b/services/opensecret/src/models/schema.rs @@ -486,6 +486,7 @@ diesel::table! { name -> Text, created_at -> Timestamptz, updated_at -> Timestamptz, + public_id -> Uuid, } } diff --git a/services/opensecret/src/models/user_api_keys.rs b/services/opensecret/src/models/user_api_keys.rs index 511559d4e..9fb9aeebc 100644 --- a/services/opensecret/src/models/user_api_keys.rs +++ b/services/opensecret/src/models/user_api_keys.rs @@ -26,6 +26,9 @@ pub struct UserApiKey { pub name: String, pub created_at: DateTime, pub updated_at: DateTime, + /// Random, non-secret key identifier. Unlike the key and its hash, it may + /// leave the enclave, for example to attribute billed usage to a key. + pub public_id: Uuid, } impl std::fmt::Debug for UserApiKey { @@ -37,6 +40,7 @@ impl std::fmt::Debug for UserApiKey { .field("name", &self.name) .field("created_at", &self.created_at) .field("updated_at", &self.updated_at) + .field("public_id", &self.public_id) .finish() } } diff --git a/services/opensecret/src/security_invariants.rs b/services/opensecret/src/security_invariants.rs index a4596a97f..3ade7a58f 100644 --- a/services/opensecret/src/security_invariants.rs +++ b/services/opensecret/src/security_invariants.rs @@ -487,7 +487,7 @@ fn openai_jwt_fallback_inserts_signed_auth_context_but_api_keys_do_not() { ], ); - let api_key_insert = "req.extensions_mut().insert(AuthMethod::ApiKey)"; + let api_key_insert = ".insert(AuthMethod::ApiKey { public_id })"; let api_key_index = middleware_body .find(api_key_insert) .expect("OpenAI API-key branch should insert AuthMethod::ApiKey"); diff --git a/services/opensecret/src/sqs.rs b/services/opensecret/src/sqs.rs index d08bd97dd..9398a9c64 100644 --- a/services/opensecret/src/sqs.rs +++ b/services/opensecret/src/sqs.rs @@ -42,6 +42,11 @@ pub struct UsageEvent { pub model_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cached_input_tokens: Option, + /// Non-secret `public_id` of the API key behind an API request, so billing + /// can attribute usage to a key. Absent for JWT requests; never the key + /// or its hash. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_id: Option, } #[cfg(test)] @@ -152,11 +157,13 @@ mod tests { provider_name: "continuum".to_string(), model_name: "kimi-k2-6".to_string(), cached_input_tokens: None, + api_key_id: None, }; let serialized = serde_json::to_value(event).unwrap(); assert!(serialized.get("cached_input_tokens").is_none()); + assert!(serialized.get("api_key_id").is_none()); } #[test] @@ -172,6 +179,7 @@ mod tests { provider_name: "continuum".to_string(), model_name: "kimi-k2-6".to_string(), cached_input_tokens: Some(42), + api_key_id: None, }; let serialized = serde_json::to_value(event).unwrap(); @@ -179,6 +187,30 @@ mod tests { assert_eq!(serialized.get("cached_input_tokens"), Some(&json!(42))); } + #[test] + fn usage_event_serializes_api_key_id_when_present() { + let event = UsageEvent { + event_id: Uuid::parse_str("8c6c975a-1f33-439d-98f1-7dd26d4d3e89").unwrap(), + user_id: Uuid::parse_str("6142db59-fc0c-413d-8792-579fc1457fe2").unwrap(), + input_tokens: 100, + output_tokens: 20, + estimated_cost: BigDecimal::from_str("0.001").unwrap(), + chat_time: Utc::now(), + is_api_request: true, + provider_name: "continuum".to_string(), + model_name: "kimi-k2-6".to_string(), + cached_input_tokens: None, + api_key_id: Some(Uuid::parse_str("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa").unwrap()), + }; + + let serialized = serde_json::to_value(event).unwrap(); + + assert_eq!( + serialized.get("api_key_id"), + Some(&json!("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa")) + ); + } + #[test] fn usage_event_deserializes_missing_cached_input_tokens_as_none() { let event: UsageEvent = serde_json::from_value(json!({ @@ -195,6 +227,7 @@ mod tests { .unwrap(); assert_eq!(event.cached_input_tokens, None); + assert_eq!(event.api_key_id, None); } } diff --git a/services/opensecret/src/web/openai.rs b/services/opensecret/src/web/openai.rs index 0b6611db5..cd11160dd 100644 --- a/services/opensecret/src/web/openai.rs +++ b/services/opensecret/src/web/openai.rs @@ -159,7 +159,7 @@ pub(crate) async fn authorize_completion_caller( let cache_policy = CompletionCachePolicy::for_request(session, cache_namespace_root, user.uuid)?; let billing_access = state - .chat_billing_access(user.uuid, auth_method == AuthMethod::ApiKey) + .chat_billing_access(user.uuid, auth_method.is_api_key()) .await; let model_plan = completion_entitlement(user.uuid, user.is_guest(), billing_access, surface)?; Ok(CompletionCaller { @@ -3457,7 +3457,7 @@ pub(crate) async fn publish_usage_event_internal( // Spawn background task for DB + SQS let state_clone = state.clone(); let user_id = user.uuid; - let is_api_request = billing_context.auth_method == AuthMethod::ApiKey; + let auth_method = billing_context.auth_method; let provider_name = provider_name.to_string(); let model_name = billing_context.model_name.clone(); @@ -3482,7 +3482,7 @@ pub(crate) async fn publish_usage_event_internal( user_id, usage, total_cost, - is_api_request, + auth_method, provider_name, model_name, ); @@ -3514,7 +3514,7 @@ fn build_usage_event( user_id: Uuid, usage: CompletionUsage, estimated_cost: BigDecimal, - is_api_request: bool, + auth_method: AuthMethod, provider_name: String, model_name: String, ) -> UsageEvent { @@ -3525,10 +3525,11 @@ fn build_usage_event( output_tokens: usage.completion_tokens, estimated_cost, chat_time: Utc::now(), - is_api_request, + is_api_request: auth_method.is_api_key(), provider_name, model_name, cached_input_tokens: usage.cached_prompt_tokens, + api_key_id: auth_method.api_key_public_id(), } } @@ -7515,7 +7516,9 @@ mod tests { Uuid::parse_str("6142db59-fc0c-413d-8792-579fc1457fe2").unwrap(), usage, BigDecimal::from_str("0.001").unwrap(), - true, + AuthMethod::ApiKey { + public_id: Uuid::parse_str("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa").unwrap(), + }, "continuum".to_string(), "glm-5-3-flash".to_string(), ); @@ -7528,6 +7531,39 @@ mod tests { assert_eq!(event.model_name, "glm-5-3-flash"); } + #[test] + fn usage_events_attribute_api_requests_to_the_key_public_id() { + let usage = CompletionUsage { + prompt_tokens: 100, + completion_tokens: 20, + cached_prompt_tokens: None, + }; + let user_id = Uuid::parse_str("6142db59-fc0c-413d-8792-579fc1457fe2").unwrap(); + let public_id = Uuid::parse_str("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa").unwrap(); + + let api_event = build_usage_event( + user_id, + usage.clone(), + BigDecimal::from_str("0.001").unwrap(), + AuthMethod::ApiKey { public_id }, + "continuum".to_string(), + "glm-5-3-flash".to_string(), + ); + assert!(api_event.is_api_request); + assert_eq!(api_event.api_key_id, Some(public_id)); + + let jwt_event = build_usage_event( + user_id, + usage, + BigDecimal::from_str("0.001").unwrap(), + AuthMethod::Jwt, + "continuum".to_string(), + "glm-5-3-flash".to_string(), + ); + assert!(!jwt_event.is_api_request); + assert_eq!(jwt_event.api_key_id, None); + } + #[test] fn route_identity_is_preserved_in_usage_events() { let usage = CompletionUsage { @@ -7549,7 +7585,7 @@ mod tests { Uuid::parse_str("6142db59-fc0c-413d-8792-579fc1457fe2").unwrap(), usage.clone(), BigDecimal::from_str("0.001").unwrap(), - false, + AuthMethod::Jwt, provider.to_string(), public_model.to_string(), ); diff --git a/services/opensecret/src/web/openai_auth.rs b/services/opensecret/src/web/openai_auth.rs index 9985e7fa5..ab8f266ab 100644 --- a/services/opensecret/src/web/openai_auth.rs +++ b/services/opensecret/src/web/openai_auth.rs @@ -18,7 +18,25 @@ use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq)] pub enum AuthMethod { Jwt, - ApiKey, + /// A user API key. `public_id` is the key's random, non-secret identifier, + /// the only key identifier that may leave the enclave. + ApiKey { + public_id: Uuid, + }, +} + +impl AuthMethod { + pub fn is_api_key(self) -> bool { + matches!(self, AuthMethod::ApiKey { .. }) + } + + /// The non-secret ID of the API key that authenticated the request. + pub fn api_key_public_id(self) -> Option { + match self { + AuthMethod::ApiKey { public_id } => Some(public_id), + AuthMethod::Jwt => None, + } + } } #[derive(Clone, Copy)] @@ -69,9 +87,10 @@ pub async fn validate_openai_auth( hasher.update(api_key_uuid.to_string().as_bytes()); let key_hash = format!("{:x}", hasher.finalize()); match data.db.get_user_by_api_key_hash(&key_hash) { - Ok(Some(user)) => { + Ok(Some((user, public_id))) => { req.extensions_mut().insert(user); - req.extensions_mut().insert(AuthMethod::ApiKey); + req.extensions_mut() + .insert(AuthMethod::ApiKey { public_id }); return next.run(req).await; } Ok(None) => { diff --git a/services/opensecret/src/web/protected_routes.rs b/services/opensecret/src/web/protected_routes.rs index ed0359fa5..fd4ce6c93 100644 --- a/services/opensecret/src/web/protected_routes.rs +++ b/services/opensecret/src/web/protected_routes.rs @@ -464,6 +464,8 @@ pub struct CreateApiKeyResponse { pub key: String, // UUID format with dashes - only returned on creation pub name: String, pub created_at: DateTime, + /// Non-secret key ID that billing uses to report per-key usage. + pub public_id: Uuid, } impl std::fmt::Debug for CreateApiKeyResponse { @@ -472,6 +474,7 @@ impl std::fmt::Debug for CreateApiKeyResponse { .field("key", &"[redacted]") .field("name", &self.name) .field("created_at", &self.created_at) + .field("public_id", &self.public_id) .finish() } } @@ -480,6 +483,8 @@ impl std::fmt::Debug for CreateApiKeyResponse { pub struct ApiKeyInfo { pub name: String, pub created_at: DateTime, + /// Non-secret key ID that billing uses to report per-key usage. + pub public_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1267,6 +1272,7 @@ pub async fn create_api_key( key: api_key_uuid.to_string(), // Return the actual UUID to the user (only time it's shown) name: api_key_record.name, created_at: api_key_record.created_at, + public_id: api_key_record.public_id, }; info!("Created API key '{}' for user {}", response.name, user.uuid); @@ -1293,6 +1299,7 @@ pub async fn list_api_keys( .map(|key| ApiKeyInfo { name: key.name, created_at: key.created_at, + public_id: key.public_id, }) .collect();