Skip to content

Commit 5dfffc8

Browse files
committed
feat: support typed API keys with global reader access
1 parent 275ed41 commit 5dfffc8

3 files changed

Lines changed: 87 additions & 20 deletions

File tree

‎src/apikeys.rs‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,22 @@ use serde::Deserialize;
3232
/// are derived from these roles (same flow as native/OAuth users).
3333
/// `apiKey` lets a caller register their own key; omitting it preserves
3434
/// the existing server-generated key behavior.
35+
#[derive(Debug, Deserialize, PartialEq, Eq)]
36+
#[serde(rename_all = "lowercase")]
37+
pub enum ApiKeyType {
38+
Ingestor,
39+
Query,
40+
}
41+
3542
#[derive(Debug, Deserialize)]
3643
#[serde(rename_all = "camelCase", deny_unknown_fields)]
3744
pub struct CreateApiKeyRequest {
3845
pub key_name: String,
3946
#[serde(default)]
4047
pub roles: HashSet<String>,
4148
pub api_key: Option<String>,
42-
/// Helm uses this mode to request the server-managed global ingest role.
43-
#[serde(default)]
44-
pub ingestor_only: bool,
49+
/// Omit for the existing UI/API flow that assigns explicit roles.
50+
pub api_key_type: Option<ApiKeyType>,
4551
}
4652

4753
/// Require a hyphenated UUID v4, allowing either letter case.
@@ -67,8 +73,8 @@ pub enum ApiKeyError {
6773
#[error("apiKey must be a UUID v4 in 8-4-4-4-12 format")]
6874
InvalidApiKey,
6975

70-
#[error("ingestorOnly cannot be combined with roles")]
71-
IngestorOnlyWithRoles,
76+
#[error("apiKeyType cannot be combined with roles")]
77+
ApiKeyTypeWithRoles,
7278

7379
#[error("Unauthorized: {0}")]
7480
Unauthorized(String),
@@ -93,7 +99,7 @@ impl actix_web::ResponseError for ApiKeyError {
9399
ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => {
94100
actix_web::http::StatusCode::CONFLICT
95101
}
96-
ApiKeyError::InvalidApiKey | ApiKeyError::IngestorOnlyWithRoles => {
102+
ApiKeyError::InvalidApiKey | ApiKeyError::ApiKeyTypeWithRoles => {
97103
actix_web::http::StatusCode::BAD_REQUEST
98104
}
99105
ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN,
@@ -107,3 +113,31 @@ impl actix_web::ResponseError for ApiKeyError {
107113
}
108114
}
109115
}
116+
117+
#[cfg(test)]
118+
mod request_tests {
119+
use super::{ApiKeyType, CreateApiKeyRequest};
120+
121+
#[test]
122+
fn typed_keys_and_existing_role_requests_deserialize() {
123+
let ingestor: CreateApiKeyRequest =
124+
serde_json::from_str(r#"{"keyName":"ingest-key","apiKeyType":"ingestor"}"#).unwrap();
125+
assert_eq!(ingestor.api_key_type, Some(ApiKeyType::Ingestor));
126+
127+
let query: CreateApiKeyRequest =
128+
serde_json::from_str(r#"{"keyName":"query-key","apiKeyType":"query"}"#).unwrap();
129+
assert_eq!(query.api_key_type, Some(ApiKeyType::Query));
130+
131+
let ui: CreateApiKeyRequest =
132+
serde_json::from_str(r#"{"keyName":"ui-key","roles":["reader-role"]}"#).unwrap();
133+
assert_eq!(ui.api_key_type, None);
134+
assert!(ui.roles.contains("reader-role"));
135+
136+
assert!(
137+
serde_json::from_str::<CreateApiKeyRequest>(
138+
r#"{"keyName":"old-key","ingestorOnly":true}"#
139+
)
140+
.is_err()
141+
);
142+
}
143+
}

‎src/handlers/http/apikeys.rs‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ use serde::{Deserialize, Serialize};
66
use ulid::Ulid;
77

88
use crate::{
9-
apikeys::{ApiKeyError, CreateApiKeyRequest, validate_api_key as validate_api_key_value},
9+
apikeys::{
10+
ApiKeyError, ApiKeyType, CreateApiKeyRequest, validate_api_key as validate_api_key_value,
11+
},
1012
handlers::http::{
1113
modal::utils::rbac_utils::{get_metadata, put_metadata},
1214
rbac::{RBACError, UPDATE_LOCK},
1315
},
1416
parseable::DEFAULT_TENANT,
1517
rbac::{
16-
GLOBAL_INGESTOR_ROLE, Users,
18+
GLOBAL_INGESTOR_ROLE, GLOBAL_QUERIER_ROLE, Users,
1719
map::{roles, users},
1820
user::{User, UserType},
1921
},
@@ -172,17 +174,24 @@ pub async fn create_api_key(
172174

173175
let guard = UPDATE_LOCK.lock().await;
174176

175-
// Helm's ingest-only mode uses an internal role. Explicit roles retain
176-
// the existing UI/API behavior and cannot be mixed with this mode.
177-
let assigned_roles = if body.ingestor_only {
178-
if !body.roles.is_empty() {
179-
return Err(ApiKeyError::IngestorOnlyWithRoles);
177+
// Typed keys get server-managed roles; the existing UI/API flow keeps
178+
// assigning explicit roles when apiKeyType is omitted.
179+
let assigned_roles = match body.api_key_type {
180+
Some(api_key_type) => {
181+
if !body.roles.is_empty() {
182+
return Err(ApiKeyError::ApiKeyTypeWithRoles);
183+
}
184+
let role = match api_key_type {
185+
ApiKeyType::Ingestor => GLOBAL_INGESTOR_ROLE,
186+
ApiKeyType::Query => GLOBAL_QUERIER_ROLE,
187+
};
188+
HashSet::from([role.to_string()])
189+
}
190+
None => {
191+
// Hold the update lock while checking user-defined roles.
192+
validate_roles(&body.roles, &tenant_id)?;
193+
body.roles
180194
}
181-
HashSet::from([GLOBAL_INGESTOR_ROLE.to_string()])
182-
} else {
183-
// Hold the update lock while checking user-defined roles.
184-
validate_roles(&body.roles, &tenant_id)?;
185-
body.roles
186195
};
187196

188197
// Duplicate key-name detection inside the same tenant.

‎src/rbac/mod.rs‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ use crate::rbac::user::User;
4141
use crate::utils::get_tenant_id_from_key;
4242

4343
use self::map::SessionKey;
44-
use self::role::{Permission, RoleBuilder};
44+
use self::role::{ParseableResourceType, Permission, RoleBuilder, model::DefaultPrivilege};
4545
use self::user::UserType;
4646

4747
pub const EXPIRY_DURATION: Duration = Duration::hours(1);
4848
/// Reserved, server-managed role for Helm's ingest-only API keys.
4949
/// This name is outside the user-defined role-name grammar.
5050
pub const GLOBAL_INGESTOR_ROLE: &str = "__global_ingestor__";
51+
/// Reserved, server-managed role with the existing reader privilege on all datasets.
52+
pub const GLOBAL_QUERIER_ROLE: &str = "__global_querier__";
5153

5254
#[derive(PartialEq, Debug)]
5355
pub enum Response {
@@ -404,6 +406,15 @@ pub fn roles_to_permission(roles: Vec<String>, tenant_id: &str) -> Vec<Permissio
404406
perms.insert(Permission::Resource(Action::Ingest, None));
405407
continue;
406408
}
409+
if role == GLOBAL_QUERIER_ROLE {
410+
perms.extend(
411+
RoleBuilder::from(&DefaultPrivilege::Reader {
412+
resource: Some(ParseableResourceType::Stream("*".to_string())),
413+
})
414+
.build(),
415+
);
416+
continue;
417+
}
407418
let role_map = &map::roles();
408419
if let Some(roles) = role_map.get(tenant_id)
409420
&& let Some(privilege_list) = roles.get(role)
@@ -419,7 +430,7 @@ pub fn roles_to_permission(roles: Vec<String>, tenant_id: &str) -> Vec<Permissio
419430
}
420431

421432
#[cfg(test)]
422-
mod global_ingestor_tests {
433+
mod global_api_key_role_tests {
423434
use super::*;
424435

425436
#[test]
@@ -430,4 +441,17 @@ mod global_ingestor_tests {
430441
vec![Permission::Resource(Action::Ingest, None)]
431442
);
432443
}
444+
445+
#[test]
446+
fn internal_querier_role_has_reader_access_to_all_datasets() {
447+
let permissions = roles_to_permission(vec![GLOBAL_QUERIER_ROLE.to_string()], "tenant");
448+
let all_datasets = Some(ParseableResourceType::Stream("*".to_string()));
449+
assert!(permissions.contains(&Permission::Resource(Action::Query, all_datasets.clone())));
450+
assert!(permissions.contains(&Permission::Resource(Action::ListStream, all_datasets)));
451+
assert!(!permissions.iter().any(|permission| matches!(
452+
permission,
453+
Permission::Resource(Action::Ingest | Action::All, _)
454+
| Permission::Unit(Action::DeleteStream | Action::PutRole)
455+
)));
456+
}
433457
}

0 commit comments

Comments
 (0)