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();