Skip to content

Commit 0e86386

Browse files
authored
Merge pull request #67 from VibeEngineeringAI/fix/apikey-role-escalation
Reject protected role assignments for API keys and querier RBAC
2 parents 2593321 + 8988e95 commit 0e86386

5 files changed

Lines changed: 828 additions & 37 deletions

File tree

‎src/handlers/http/apikeys.rs‎

Lines changed: 189 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::{
1515
rbac::{
1616
Users,
1717
map::{read_user_groups, roles, users},
18-
role::model::{DefaultPrivilege, Role},
18+
role::model::{DefaultPrivilege, Role, RoleType},
1919
user::{User, UserType},
2020
},
2121
utils::{get_user_and_tenant_from_request, is_admin},
@@ -240,29 +240,43 @@ fn collect_tenant_api_keys(tenant_id: &Option<String>) -> Vec<User> {
240240
.collect()
241241
}
242242

243-
/// Validate that every role in the request exists in the tenant's role map.
243+
/// Validate that every requested role exists and can be assigned in this tenant.
244244
/// Mirrors the check performed by the native-user CRUD endpoint.
245245
fn validate_roles(
246246
role_names: &HashSet<String>,
247247
tenant_id: &Option<String>,
248248
) -> Result<(), ApiKeyError> {
249249
let tenant = tenant_or_default(tenant_id);
250-
let tenant_roles = roles();
251-
let missing: Vec<String> = role_names
252-
.iter()
253-
.filter(|r| {
254-
tenant_roles
255-
.get(tenant)
256-
.map(|m| !m.contains_key(*r))
257-
.unwrap_or(true)
250+
let missing: Vec<String> = {
251+
let tenant_roles = roles();
252+
role_names
253+
.iter()
254+
.filter(|r| {
255+
tenant_roles
256+
.get(tenant)
257+
.map(|m| !m.contains_key(*r))
258+
.unwrap_or(true)
259+
})
260+
.cloned()
261+
.collect()
262+
};
263+
if !missing.is_empty() {
264+
return Err(ApiKeyError::Rbac(RBACError::RolesDoNotExist(missing)));
265+
}
266+
// Takes USERS, so no ROLES guard may be held here.
267+
if Users.any_role_held_by_protected_user(role_names, tenant_id) {
268+
return Err(ApiKeyError::Rbac(RBACError::ProtectedRole));
269+
}
270+
if let Some(tenant_roles) = roles().get(tenant)
271+
&& role_names.iter().any(|name| {
272+
tenant_roles.get(name).is_some_and(|role| {
273+
role.role_type() == &RoleType::Internal || role.deny_super_admin()
274+
})
258275
})
259-
.cloned()
260-
.collect();
261-
if missing.is_empty() {
262-
Ok(())
263-
} else {
264-
Err(ApiKeyError::Rbac(RBACError::RolesDoNotExist(missing)))
276+
{
277+
return Err(ApiKeyError::Rbac(RBACError::ProtectedRole));
265278
}
279+
Ok(())
266280
}
267281

268282
/// POST /api/prism/v1/apikeys
@@ -276,8 +290,8 @@ pub async fn create_api_key(
276290

277291
let guard = UPDATE_LOCK.lock().await;
278292

279-
// Reject any role names that don't exist in this tenant while holding the
280-
// update lock so a concurrent role update cannot invalidate the check.
293+
// Validate role assignments under the update lock so a concurrent role
294+
// update cannot invalidate the check.
281295
validate_roles(&body.roles, &tenant_id)?;
282296

283297
// Duplicate key-name detection inside the same tenant.
@@ -861,4 +875,161 @@ mod tests {
861875
assert_eq!(names(&unknown, None), ["orphan-key"]);
862876
assert!(names(&unknown, reader()).is_empty());
863877
}
878+
879+
mod role_escalation {
880+
use actix_web::{HttpMessage, ResponseError, test::TestRequest};
881+
use chrono::TimeDelta;
882+
883+
use super::*;
884+
use crate::rbac::user::Basic;
885+
886+
const ADMIN_ROLE: &str = "tenant-admin";
887+
const READER_ROLE: &str = "reader";
888+
const INTERNAL_ROLE: &str = "internal";
889+
// Mirrors the internal role `rbac::map::init` adds to the default tenant.
890+
const SUPER_ADMIN_ROLE: &str = "super-admin";
891+
// A user-typed role holding SuperAdmin, e.g. from legacy metadata.
892+
const ELEVATED_ROLE: &str = "elevated";
893+
894+
/// A fresh tenant laid out like the default tenant after `map::init`.
895+
fn tenant() -> String {
896+
let tenant = format!("apikey-escalation-{}", Ulid::new());
897+
mut_roles().insert(
898+
tenant.clone(),
899+
HashMap::from([
900+
(
901+
ADMIN_ROLE.to_owned(),
902+
Role::create_user_role(vec![DefaultPrivilege::Admin]),
903+
),
904+
(
905+
READER_ROLE.to_owned(),
906+
Role::create_user_role(vec![DefaultPrivilege::Reader { resource: None }]),
907+
),
908+
(
909+
INTERNAL_ROLE.to_owned(),
910+
Role::create_internal_role(vec![DefaultPrivilege::Admin]),
911+
),
912+
(
913+
SUPER_ADMIN_ROLE.to_owned(),
914+
Role::create_internal_role(vec![DefaultPrivilege::SuperAdmin]),
915+
),
916+
(
917+
ELEVATED_ROLE.to_owned(),
918+
Role::create_user_role(vec![DefaultPrivilege::SuperAdmin]),
919+
),
920+
]),
921+
);
922+
tenant
923+
}
924+
925+
fn role_set(name: &str) -> HashSet<String> {
926+
HashSet::from([name.to_owned()])
927+
}
928+
929+
/// A request from a tenant admin (Admin, not SuperAdmin) with a live session.
930+
fn tenant_admin_request(tenant: &str) -> HttpRequest {
931+
let admin = User {
932+
ty: UserType::Native(Basic {
933+
username: format!("admin-{}", Ulid::new()),
934+
password_hash: String::new(),
935+
email: None,
936+
}),
937+
roles: role_set(ADMIN_ROLE),
938+
user_groups: HashSet::new(),
939+
tenant: Some(tenant.to_owned()),
940+
protected: false,
941+
};
942+
mut_users().insert(admin.clone());
943+
let session = SessionKey::SessionId(Ulid::new());
944+
Users.new_session(&admin, session.clone(), TimeDelta::minutes(5));
945+
let req = TestRequest::default().to_http_request();
946+
req.extensions_mut().insert(session);
947+
req
948+
}
949+
950+
#[test]
951+
fn validate_roles_accepts_ordinary_role() {
952+
let _serial = blocking_rbac_state();
953+
let tenant = Some(tenant());
954+
assert!(validate_roles(&role_set(READER_ROLE), &tenant).is_ok());
955+
}
956+
957+
#[test]
958+
fn validate_roles_rejects_internal_role() {
959+
let _serial = blocking_rbac_state();
960+
let tenant = Some(tenant());
961+
let result = validate_roles(&role_set(INTERNAL_ROLE), &tenant);
962+
assert!(
963+
matches!(result, Err(ApiKeyError::Rbac(RBACError::ProtectedRole))),
964+
"internal role must be rejected like the native-user path, got {result:?}"
965+
);
966+
}
967+
968+
#[test]
969+
fn validate_roles_rejects_super_admin_privilege_role() {
970+
let _serial = blocking_rbac_state();
971+
let tenant = Some(tenant());
972+
let result = validate_roles(&role_set(ELEVATED_ROLE), &tenant);
973+
assert!(
974+
matches!(result, Err(ApiKeyError::Rbac(RBACError::ProtectedRole))),
975+
"role holding SuperAdmin must be rejected like the native-user path, got {result:?}"
976+
);
977+
}
978+
979+
#[test]
980+
fn validate_roles_rejects_role_held_by_protected_user() {
981+
let _serial = blocking_rbac_state();
982+
let tenant = tenant();
983+
mut_users().insert(User {
984+
ty: UserType::Native(Basic {
985+
username: format!("protected-{}", Ulid::new()),
986+
password_hash: String::new(),
987+
email: None,
988+
}),
989+
roles: role_set(READER_ROLE),
990+
user_groups: HashSet::new(),
991+
tenant: Some(tenant.clone()),
992+
protected: true,
993+
});
994+
let result = validate_roles(&role_set(READER_ROLE), &Some(tenant));
995+
assert!(
996+
matches!(result, Err(ApiKeyError::Rbac(RBACError::ProtectedRole))),
997+
"a protected user's role must be rejected like the native-user path, got {result:?}"
998+
);
999+
}
1000+
1001+
#[tokio::test]
1002+
async fn tenant_admin_cannot_create_super_admin_api_key() {
1003+
let _serial = rbac_state().await;
1004+
let tenant = tenant();
1005+
let req = tenant_admin_request(&tenant);
1006+
// An existing key with the same name makes the vulnerable handler stop
1007+
// with 409 before it reaches storage, which tests cannot initialise.
1008+
let key_name = "escalation".to_owned();
1009+
mut_users().insert(User::new_api_key(
1010+
Ulid::new(),
1011+
format!("existing-{}", Ulid::new()),
1012+
key_name.clone(),
1013+
HashSet::new(),
1014+
"admin".into(),
1015+
Some(tenant.clone()),
1016+
));
1017+
1018+
for role in [SUPER_ADMIN_ROLE, ELEVATED_ROLE] {
1019+
let body = CreateApiKeyRequest {
1020+
key_name: key_name.clone(),
1021+
roles: role_set(role),
1022+
ttl_seconds: None,
1023+
};
1024+
let Err(err) = create_api_key(req.clone(), web::Json(body)).await else {
1025+
panic!("API key with role {role} must be rejected");
1026+
};
1027+
assert!(
1028+
matches!(err, ApiKeyError::Rbac(RBACError::ProtectedRole)),
1029+
"role {role}: expected ProtectedRole, got {err:?}"
1030+
);
1031+
assert_eq!(err.status_code(), StatusCode::BAD_REQUEST, "role {role}");
1032+
}
1033+
}
1034+
}
8641035
}

0 commit comments

Comments
 (0)