Skip to content

Commit 539095a

Browse files
feat: api keys
move API key handlers and route scope into oss all server modes - prism, query, standalone - share the same webscope
1 parent 5e7a0f2 commit 539095a

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

src/handlers/http/apikeys.rs

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
use std::collections::HashSet;
2+
3+
use actix_web::{HttpRequest, HttpResponse, Responder, web};
4+
use chrono::{DateTime, Utc};
5+
use serde::{Deserialize, Serialize};
6+
use ulid::Ulid;
7+
8+
use crate::{
9+
apikeys::{ApiKeyError, CreateApiKeyRequest},
10+
handlers::http::{
11+
modal::utils::rbac_utils::{get_metadata, put_metadata},
12+
rbac::{RBACError, UPDATE_LOCK},
13+
},
14+
parseable::DEFAULT_TENANT,
15+
rbac::{
16+
Users,
17+
map::{roles, users},
18+
user::{User, UserType},
19+
},
20+
utils::get_user_and_tenant_from_request,
21+
};
22+
23+
/// Verify the caller is an admin of their tenant or a super-admin.
24+
fn verify_admin(req: &HttpRequest) -> Result<(String, Option<String>), ApiKeyError> {
25+
let (userid, tenant_id) = get_user_and_tenant_from_request(req)
26+
.map_err(|_| ApiKeyError::Unauthorized("Missing user identity".into()))?;
27+
28+
let user = Users
29+
.get_user(&userid, &tenant_id)
30+
.ok_or_else(|| ApiKeyError::Unauthorized("User not found".into()))?;
31+
32+
// Super-admin can always manage keys
33+
if user.is_super_admin() {
34+
return Ok((userid, tenant_id));
35+
}
36+
37+
// Tenant admin (has "admin" role) can manage keys for their tenant
38+
if user.roles.contains("admin") {
39+
return Ok((userid, tenant_id));
40+
}
41+
42+
Err(ApiKeyError::Unauthorized(
43+
"Only admins can manage API keys".into(),
44+
))
45+
}
46+
47+
/// Generate a fresh, opaque API key value as a UUID v4 string
48+
/// (matches the original api key format used by CreateApiKeyRequest clients).
49+
fn generate_api_key_value() -> String {
50+
uuid::Uuid::new_v4().to_string()
51+
}
52+
53+
/// Serialisable shape returned by create / get.
54+
#[derive(Debug, Serialize)]
55+
#[serde(rename_all = "camelCase")]
56+
struct ApiKeyResponse<'a> {
57+
key_id: Ulid,
58+
api_key: &'a str,
59+
key_name: &'a str,
60+
roles: &'a HashSet<String>,
61+
created_by: &'a str,
62+
created_at: DateTime<Utc>,
63+
modified_at: DateTime<Utc>,
64+
}
65+
66+
impl<'a> TryFrom<&'a User> for ApiKeyResponse<'a> {
67+
type Error = ApiKeyError;
68+
69+
fn try_from(user: &'a User) -> Result<Self, Self::Error> {
70+
let api_key = user
71+
.as_api_key()
72+
.ok_or_else(|| ApiKeyError::KeyNotFound(user.userid().to_string()))?;
73+
Ok(Self {
74+
key_id: api_key.key_id,
75+
api_key: &api_key.api_key,
76+
key_name: &api_key.key_name,
77+
roles: &user.roles,
78+
created_by: &api_key.created_by,
79+
created_at: api_key.created_at,
80+
modified_at: api_key.modified_at,
81+
})
82+
}
83+
}
84+
85+
/// Serialisable shape returned by list (api_key value masked).
86+
#[derive(Debug, Serialize)]
87+
#[serde(rename_all = "camelCase")]
88+
struct ApiKeyListEntry {
89+
key_id: Ulid,
90+
api_key: String,
91+
key_name: String,
92+
roles: HashSet<String>,
93+
created_by: String,
94+
created_at: DateTime<Utc>,
95+
modified_at: DateTime<Utc>,
96+
}
97+
98+
#[derive(Debug, Serialize)]
99+
#[serde(rename_all = "camelCase")]
100+
struct ValidateApiKeyResponse {
101+
valid: bool,
102+
}
103+
104+
#[derive(Debug, Deserialize)]
105+
#[serde(rename_all = "camelCase")]
106+
pub struct ValidateApiKeyRequest {
107+
api_key: String,
108+
}
109+
110+
impl ApiKeyListEntry {
111+
fn from_user(user: &User) -> Option<Self> {
112+
let api_key = user.as_api_key()?;
113+
let masked = if api_key.api_key.len() >= 4 {
114+
let last4 = &api_key.api_key[api_key.api_key.len() - 4..];
115+
format!("****{last4}")
116+
} else {
117+
"****".to_string()
118+
};
119+
Some(Self {
120+
key_id: api_key.key_id,
121+
api_key: masked,
122+
key_name: api_key.key_name.clone(),
123+
roles: user.roles.clone(),
124+
created_by: api_key.created_by.clone(),
125+
created_at: api_key.created_at,
126+
modified_at: api_key.modified_at,
127+
})
128+
}
129+
}
130+
131+
fn tenant_or_default(tenant: &Option<String>) -> &str {
132+
tenant.as_deref().unwrap_or(DEFAULT_TENANT)
133+
}
134+
135+
/// Collect all API-key-backed users for a tenant into an owned list.
136+
/// Read lock on the users map is held only for the duration of this fn.
137+
fn collect_tenant_api_keys(tenant_id: &Option<String>) -> Vec<User> {
138+
let users_guard = users();
139+
let tenant = tenant_or_default(tenant_id);
140+
users_guard
141+
.get(tenant)
142+
.into_iter()
143+
.flat_map(|m| m.values())
144+
.filter(|u| matches!(u.ty, UserType::ApiKey(_)))
145+
.cloned()
146+
.collect()
147+
}
148+
149+
/// Validate that every role in the request exists in the tenant's role map.
150+
/// Mirrors the check performed by the native-user CRUD endpoint.
151+
fn validate_roles(
152+
role_names: &HashSet<String>,
153+
tenant_id: &Option<String>,
154+
) -> Result<(), ApiKeyError> {
155+
let tenant = tenant_or_default(tenant_id);
156+
let tenant_roles = roles();
157+
let missing: Vec<String> = role_names
158+
.iter()
159+
.filter(|r| {
160+
tenant_roles
161+
.get(tenant)
162+
.map(|m| !m.contains_key(*r))
163+
.unwrap_or(true)
164+
})
165+
.cloned()
166+
.collect();
167+
if missing.is_empty() {
168+
Ok(())
169+
} else {
170+
Err(ApiKeyError::Rbac(RBACError::RolesDoNotExist(missing)))
171+
}
172+
}
173+
174+
/// POST /api/prism/v1/apikeys
175+
///
176+
/// Create a new API key. Only admins can create keys.
177+
pub async fn create_api_key(
178+
req: HttpRequest,
179+
web::Json(body): web::Json<CreateApiKeyRequest>,
180+
) -> Result<impl Responder, ApiKeyError> {
181+
let (created_by, tenant_id) = verify_admin(&req)?;
182+
183+
// Reject any role names that don't exist in this tenant before we acquire
184+
// the update lock or touch storage.
185+
validate_roles(&body.roles, &tenant_id)?;
186+
187+
let guard = UPDATE_LOCK.lock().await;
188+
189+
// Duplicate key-name detection inside the same tenant.
190+
let existing = collect_tenant_api_keys(&tenant_id);
191+
if existing
192+
.iter()
193+
.filter_map(|u| u.as_api_key())
194+
.any(|k| k.key_name == body.key_name)
195+
{
196+
return Err(ApiKeyError::DuplicateKeyName(body.key_name));
197+
}
198+
199+
let key_id = Ulid::new();
200+
let api_key_value = generate_api_key_value();
201+
let user = User::new_api_key(
202+
key_id,
203+
api_key_value,
204+
body.key_name,
205+
body.roles,
206+
created_by,
207+
tenant_id.clone(),
208+
);
209+
210+
// Persist the new user in parseable.json and push into memory on this node.
211+
let mut metadata = get_metadata(&tenant_id).await?;
212+
metadata.users.push(user.clone());
213+
put_metadata(&metadata, &tenant_id).await?;
214+
Users.put_user(user.clone());
215+
216+
// Drop the lock once local state is durable; the cluster fan-out below
217+
// can be slow and must not block other create/delete requests.
218+
drop(guard);
219+
220+
// Best-effort sync to other live nodes (reuses the standard user sync).
221+
let caller_userid = user
222+
.as_api_key()
223+
.map(|k| k.created_by.clone())
224+
.unwrap_or_default();
225+
if let Err(e) = crate::handlers::http::cluster::sync_user_creation(
226+
&req,
227+
user.clone(),
228+
&None,
229+
&tenant_id,
230+
&caller_userid,
231+
)
232+
.await
233+
{
234+
tracing::error!("Failed to sync API-key user creation: {e}");
235+
}
236+
237+
let response = ApiKeyResponse::try_from(&user)?;
238+
Ok(HttpResponse::Ok().json(response))
239+
}
240+
241+
/// DELETE /api/prism/v1/apikeys/{key_id}
242+
///
243+
/// Delete an API key by key_id. Only admins can delete keys.
244+
pub async fn delete_api_key(
245+
req: HttpRequest,
246+
path: web::Path<String>,
247+
) -> Result<impl Responder, ApiKeyError> {
248+
let (caller_userid, tenant_id) = verify_admin(&req)?;
249+
let key_id_str = path.into_inner();
250+
let key_id =
251+
Ulid::from_string(&key_id_str).map_err(|_| ApiKeyError::KeyNotFound(key_id_str.clone()))?;
252+
253+
let guard = UPDATE_LOCK.lock().await;
254+
255+
// Find the user entry for this api key.
256+
let (userid, key_name) = collect_tenant_api_keys(&tenant_id)
257+
.into_iter()
258+
.find_map(|u| {
259+
u.as_api_key()
260+
.filter(|k| k.key_id == key_id)
261+
.map(|k| (u.userid().to_string(), k.key_name.clone()))
262+
})
263+
.ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?;
264+
265+
// Remove from parseable.json.
266+
let mut metadata = get_metadata(&tenant_id).await?;
267+
metadata.users.retain(|u| u.userid() != userid.as_str());
268+
put_metadata(&metadata, &tenant_id).await?;
269+
270+
// Remove from in-memory map on this node.
271+
Users.delete_user(&userid, &tenant_id);
272+
273+
// Drop the lock once local state is durable; the cluster fan-out below
274+
// can be slow and must not block other create/delete requests.
275+
drop(guard);
276+
277+
// Best-effort sync the deletion to other live nodes.
278+
if let Err(e) = crate::handlers::http::cluster::sync_user_deletion_with_ingestors(
279+
&req,
280+
&userid,
281+
&tenant_id,
282+
&caller_userid,
283+
)
284+
.await
285+
{
286+
tracing::error!("Failed to sync API-key user deletion: {e}");
287+
}
288+
289+
Ok(HttpResponse::Ok().json(serde_json::json!({
290+
"keyId": key_id,
291+
"keyName": key_name,
292+
"message": "API key deleted successfully",
293+
})))
294+
}
295+
296+
/// GET /api/prism/v1/apikeys
297+
///
298+
/// List all API keys (masked). Only admins can list keys.
299+
pub async fn list_api_keys(req: HttpRequest) -> Result<impl Responder, ApiKeyError> {
300+
let (_, tenant_id) = verify_admin(&req)?;
301+
let entries: Vec<ApiKeyListEntry> = collect_tenant_api_keys(&tenant_id)
302+
.iter()
303+
.filter_map(ApiKeyListEntry::from_user)
304+
.collect();
305+
Ok(HttpResponse::Ok().json(entries))
306+
}
307+
308+
/// POST /api/prism/v1/apikeys/validate
309+
///
310+
/// Validate whether an API key exists in the caller's tenant.
311+
pub async fn validate_api_key(
312+
req: HttpRequest,
313+
web::Json(body): web::Json<ValidateApiKeyRequest>,
314+
) -> Result<impl Responder, ApiKeyError> {
315+
let (_, tenant_id) = get_user_and_tenant_from_request(&req)
316+
.map_err(|_| ApiKeyError::Unauthorized("Missing user identity".into()))?;
317+
let valid = collect_tenant_api_keys(&tenant_id)
318+
.iter()
319+
.filter_map(|u| u.as_api_key())
320+
.any(|k| k.api_key == body.api_key);
321+
322+
Ok(HttpResponse::Ok().json(ValidateApiKeyResponse { valid }))
323+
}
324+
325+
/// GET /api/prism/v1/apikeys/{key_id}
326+
///
327+
/// Get a specific API key (full key visible). Only admins can get keys.
328+
pub async fn get_api_key(
329+
req: HttpRequest,
330+
path: web::Path<String>,
331+
) -> Result<impl Responder, ApiKeyError> {
332+
let (_, tenant_id) = verify_admin(&req)?;
333+
let key_id_str = path.into_inner();
334+
let key_id =
335+
Ulid::from_string(&key_id_str).map_err(|_| ApiKeyError::KeyNotFound(key_id_str.clone()))?;
336+
337+
let user = collect_tenant_api_keys(&tenant_id)
338+
.into_iter()
339+
.find(|u| u.as_api_key().map(|k| k.key_id == key_id).unwrap_or(false))
340+
.ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?;
341+
342+
let response = ApiKeyResponse::try_from(&user)?;
343+
Ok(HttpResponse::Ok().json(response))
344+
}

src/handlers/http/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use self::query::Query;
3030

3131
pub mod about;
3232
pub mod alerts;
33+
pub mod apikeys;
3334
pub mod cluster;
3435
pub mod correlation;
3536
pub mod demo_data;

src/handlers/http/modal/query_server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ impl ParseableServer for QueryServer {
8383
.service(Server::get_prism_home())
8484
.service(Server::get_prism_logstream())
8585
.service(Server::get_prism_datasets())
86+
.service(Server::get_apikeys_webscope())
8687
.service(Server::get_dataset_stats_webscope()),
8788
)
8889
.service(Server::get_generated());

0 commit comments

Comments
 (0)