Skip to content

Commit 275ed41

Browse files
committed
feat: add ingest-only mode for caller-provided API keys
1 parent cb2e944 commit 275ed41

3 files changed

Lines changed: 58 additions & 23 deletions

File tree

‎src/apikeys.rs‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,25 @@ use serde::Deserialize;
3030
/// Request body for creating a new API key. `roles` is a set of role names
3131
/// that must already exist in the tenant; permissions for the backing user
3232
/// are derived from these roles (same flow as native/OAuth users).
33-
/// `providedApiKey` lets a caller register their own key; omitting it preserves
33+
/// `apiKey` lets a caller register their own key; omitting it preserves
3434
/// the existing server-generated key behavior.
3535
#[derive(Debug, Deserialize)]
36-
#[serde(rename_all = "camelCase")]
36+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
3737
pub struct CreateApiKeyRequest {
3838
pub key_name: String,
3939
#[serde(default)]
4040
pub roles: HashSet<String>,
41-
pub provided_api_key: Option<String>,
41+
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,
4245
}
4346

44-
/// Reject short or malformed caller-supplied credentials before persisting them.
45-
pub fn validate_provided_api_key(value: &str) -> Result<(), ApiKeyError> {
46-
if !(32..=256).contains(&value.len())
47-
|| !value
48-
.bytes()
49-
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
50-
{
51-
return Err(ApiKeyError::InvalidProvidedApiKey);
47+
/// Require a hyphenated UUID v4, allowing either letter case.
48+
pub fn validate_api_key(value: &str) -> Result<(), ApiKeyError> {
49+
let uuid = uuid::Uuid::parse_str(value).map_err(|_| ApiKeyError::InvalidApiKey)?;
50+
if value.len() != 36 || uuid.get_version_num() != 4 {
51+
return Err(ApiKeyError::InvalidApiKey);
5252
}
5353
Ok(())
5454
}
@@ -64,8 +64,11 @@ pub enum ApiKeyError {
6464
#[error("API key value is already registered")]
6565
DuplicateApiKey,
6666

67-
#[error("providedApiKey must be 32-256 ASCII letters, digits, hyphens, or underscores")]
68-
InvalidProvidedApiKey,
67+
#[error("apiKey must be a UUID v4 in 8-4-4-4-12 format")]
68+
InvalidApiKey,
69+
70+
#[error("ingestorOnly cannot be combined with roles")]
71+
IngestorOnlyWithRoles,
6972

7073
#[error("Unauthorized: {0}")]
7174
Unauthorized(String),
@@ -90,7 +93,9 @@ impl actix_web::ResponseError for ApiKeyError {
9093
ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => {
9194
actix_web::http::StatusCode::CONFLICT
9295
}
93-
ApiKeyError::InvalidProvidedApiKey => actix_web::http::StatusCode::BAD_REQUEST,
96+
ApiKeyError::InvalidApiKey | ApiKeyError::IngestorOnlyWithRoles => {
97+
actix_web::http::StatusCode::BAD_REQUEST
98+
}
9499
ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN,
95100
ApiKeyError::RevocationSyncFailed(_) => {
96101
actix_web::http::StatusCode::SERVICE_UNAVAILABLE

‎src/handlers/http/apikeys.rs‎

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

88
use crate::{
9-
apikeys::{ApiKeyError, CreateApiKeyRequest, validate_provided_api_key},
9+
apikeys::{ApiKeyError, CreateApiKeyRequest, validate_api_key as validate_api_key_value},
1010
handlers::http::{
1111
modal::utils::rbac_utils::{get_metadata, put_metadata},
1212
rbac::{RBACError, UPDATE_LOCK},
1313
},
1414
parseable::DEFAULT_TENANT,
1515
rbac::{
16-
Users,
16+
GLOBAL_INGESTOR_ROLE, Users,
1717
map::{roles, users},
1818
user::{User, UserType},
1919
},
@@ -172,9 +172,18 @@ pub async fn create_api_key(
172172

173173
let guard = UPDATE_LOCK.lock().await;
174174

175-
// Reject any role names that don't exist in this tenant while holding the
176-
// update lock so a concurrent role update cannot invalidate the check.
177-
validate_roles(&body.roles, &tenant_id)?;
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);
180+
}
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
186+
};
178187

179188
// Duplicate key-name detection inside the same tenant.
180189
let existing = collect_tenant_api_keys(&tenant_id);
@@ -187,9 +196,9 @@ pub async fn create_api_key(
187196
}
188197

189198
let key_id = Ulid::new();
190-
let api_key_value = if let Some(provided_api_key) = body.provided_api_key {
191-
validate_provided_api_key(&provided_api_key)?;
192-
provided_api_key
199+
let api_key_value = if let Some(api_key) = body.api_key {
200+
validate_api_key_value(&api_key)?;
201+
api_key
193202
} else {
194203
generate_api_key_value()
195204
};
@@ -204,7 +213,7 @@ pub async fn create_api_key(
204213
key_id,
205214
api_key_value,
206215
body.key_name,
207-
body.roles,
216+
assigned_roles,
208217
created_by,
209218
tenant_id.clone(),
210219
);

‎src/rbac/mod.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ use self::role::{Permission, RoleBuilder};
4545
use self::user::UserType;
4646

4747
pub const EXPIRY_DURATION: Duration = Duration::hours(1);
48+
/// Reserved, server-managed role for Helm's ingest-only API keys.
49+
/// This name is outside the user-defined role-name grammar.
50+
pub const GLOBAL_INGESTOR_ROLE: &str = "__global_ingestor__";
4851

4952
#[derive(PartialEq, Debug)]
5053
pub enum Response {
@@ -397,6 +400,10 @@ pub struct UsersPrism {
397400
pub fn roles_to_permission(roles: Vec<String>, tenant_id: &str) -> Vec<Permission> {
398401
let mut perms = HashSet::new();
399402
for role in &roles {
403+
if role == GLOBAL_INGESTOR_ROLE {
404+
perms.insert(Permission::Resource(Action::Ingest, None));
405+
continue;
406+
}
400407
let role_map = &map::roles();
401408
if let Some(roles) = role_map.get(tenant_id)
402409
&& let Some(privilege_list) = roles.get(role)
@@ -410,3 +417,17 @@ pub fn roles_to_permission(roles: Vec<String>, tenant_id: &str) -> Vec<Permissio
410417
}
411418
perms.into_iter().collect()
412419
}
420+
421+
#[cfg(test)]
422+
mod global_ingestor_tests {
423+
use super::*;
424+
425+
#[test]
426+
fn internal_ingestor_role_grants_only_global_ingest() {
427+
let permissions = roles_to_permission(vec![GLOBAL_INGESTOR_ROLE.to_string()], "tenant");
428+
assert_eq!(
429+
permissions,
430+
vec![Permission::Resource(Action::Ingest, None)]
431+
);
432+
}
433+
}

0 commit comments

Comments
 (0)